From 19e19bcd2bd4a3926bf80708a50ef0720eb3d570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 12 Aug 2026 17:44:13 +0800 Subject: [PATCH 001/267] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8B=9B?= =?UTF-8?q?=E8=81=98=E8=81=8C=E4=BD=8D=E7=AE=A1=E7=90=86=20to#85340676?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changes/976-recruit-job.md | 5 + internal/helpers/recruit.go | 284 ++++++++++++++++++ internal/helpers/recruit_test.go | 207 +++++++++++++ internal/helpers/register_products_test.go | 2 +- internal/helpers/register_recruit.go | 11 + pkg/edition/default.go | 16 +- pkg/edition/edition_test.go | 8 + skills/content-qa/mono-multi-coverage.yaml | 4 + skills/mono/SKILL.md | 2 + skills/mono/references/products/recruit.md | 29 ++ skills/multi/dingtalk-misc/SKILL.md | 3 +- .../multi/dingtalk-misc/references/recruit.md | 64 ++++ test/cli/unregistered_modules_command_test.go | 5 +- 13 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 .changes/976-recruit-job.md create mode 100644 internal/helpers/recruit.go create mode 100644 internal/helpers/recruit_test.go create mode 100644 internal/helpers/register_recruit.go create mode 100644 skills/mono/references/products/recruit.md create mode 100644 skills/multi/dingtalk-misc/references/recruit.md diff --git a/.changes/976-recruit-job.md b/.changes/976-recruit-job.md new file mode 100644 index 00000000..a1be13c3 --- /dev/null +++ b/.changes/976-recruit-job.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go new file mode 100644 index 00000000..315fbc39 --- /dev/null +++ b/internal/helpers/recruit.go @@ -0,0 +1,284 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + recruitServerID = "recruit" + recruitCreateJobTool = "create_job" + recruitGetJobTool = "get_job_detail" + recruitListJobsTool = "list_jobs" +) + +var recruitDryRun = &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewInvocation, + RemoteReads: false, +} + +func newRecruitCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "recruit", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询和创建钉钉招聘职位", + UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"}, + AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"}, + }, + }) + + root := &cobra.Command{ + Use: "recruit", + Short: "钉钉招聘", + Long: "查询和创建钉钉招聘中的职位信息。", + RunE: groupRunE, + } + job := &cobra.Command{ + Use: "job", + Short: "招聘职位管理", + RunE: groupRunE, + } + job.AddCommand( + newRecruitJobListCommand(), + newRecruitJobGetCommand(), + newRecruitJobCreateCommand(), + ) + root.AddCommand(job) + return root +} + +func newRecruitJobListCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + Flags: []LeafFlag{ + {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, + {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, + {Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses}, + {Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true}, + {Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"}, + {Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, + {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, + {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时传返回的 nextCursor", Kind: LeafInt, Bind: "cursor"}, + {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", ArgDefault: "20"}, + }, + Validate: validateRecruitList, + Call: runRecruitJobList, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, + Description: "按条件分页查询招聘职位", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitListJobsTool), + Selection: contract.SelectionSpec{ + AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", + UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"}, + AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"}, + Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`}, + }, + Parameters: recruitListParamDecls(), + }, + }) +} + +func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error { + params := map[string]any{"param": map[string]any{}, "size": args["size"]} + query := params["param"].(map[string]any) + for key, value := range args { + switch key { + case "cursor": + params[key] = value + case "size": + default: + query[key] = value + } + } + return callMCPToolOnServer(recruitServerID, tool, params) +} + +func newRecruitJobGetCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + Flags: []LeafFlag{{ + Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, + Required: true, RequiredHint: "--job-id 为必填", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, + Description: "根据职位 ID 查询招聘职位详情", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitGetJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定招聘职位的完整信息", + UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"}, + AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"}, + Examples: []string{"dws recruit job get --job-id --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}}, + }, + }) +} + +func newRecruitJobCreateCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + Flags: []LeafFlag{{ + Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, + Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, + Description: "从 JSON 文件创建招聘职位", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitCreateJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "使用结构化 JSON 创建招聘职位", + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、minSalary、maxSalary"}, + AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, + Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 读取后作为 atsAddJobParam 对象发送"}}, + }, + }) +} + +func recruitListParamDecls() []contract.ParamDecl { + return []contract.ParamDecl{ + {Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"}, + {Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"}, + {Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}}, + {Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"}, + {Name: "campus", Property: "param.campus", InterfaceType: "boolean"}, + {Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"}, + {Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"}, + {Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"}, + {Name: "keyword", Property: "param.keyword", InterfaceType: "string"}, + {Name: "category", Property: "param.category", InterfaceType: "string"}, + {Name: "cursor", Property: "cursor", InterfaceType: "number"}, + {Name: "size", Property: "size", Required: boolPtr(true), InterfaceType: "number"}, + } +} + +func recruitMCPInterface(tool string) *contract.InterfaceSpec { + return &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool}, + } +} + +func recruitSafetyRead() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func recruitSafetyCreate() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"} +} + +func transformRecruitStatuses(raw string) (any, error) { + values := strings.Split(raw, ",") + statuses := make([]int, 0, len(values)) + seen := make(map[int]bool, len(values)) + lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3} + for _, value := range values { + name := strings.ToLower(strings.TrimSpace(value)) + if name == "" { + continue + } + status, ok := lookup[name] + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value)) + } + if !seen[status] { + statuses = append(statuses, status) + seen[status] = true + } + } + return statuses, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { + if cursor, _ := cmd.Flags().GetInt("cursor"); cursor < 0 { + return apperrors.NewValidation("--cursor 必须大于或等于 0") + } + size, _ := cmd.Flags().GetInt("size") + if cmd.Flags().Changed("size") && (size < 1 || size > 100) { + return apperrors.NewValidation("--size 必须在 1 到 100 之间") + } + return nil +} + +func loadRecruitJobFile(path string) (any, error) { + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("读取职位 JSON 失败: %w", err) + } + var job map[string]any + if err := json.Unmarshal(data, &job); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err)) + } + if job == nil { + return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象") + } + if err := validateRecruitJob(job); err != nil { + return nil, err + } + return job, nil +} + +func validateRecruitJob(job map[string]any) error { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "minSalary", "maxSalary"} { + value, ok := job[name] + if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) + } + } + for _, name := range []string{"name", "description", "jobNature"} { + if !isString(job[name]) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + for _, name := range []string{"requiredEdu", "minSalary", "maxSalary"} { + if _, ok := job[name].(float64); !ok { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + } + } + if job["minSalary"].(float64) > job["maxSalary"].(float64) { + return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + } + return nil +} + +func isString(value any) bool { + _, ok := value.(string) + return ok +} diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go new file mode 100644 index 00000000..274ab409 --- /dev/null +++ b/internal/helpers/recruit_test.go @@ -0,0 +1,207 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "context" + "encoding/json" + stderrors "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type recruitCapturedCall struct { + productID string + tool string + args map[string]any +} + +type recruitCaptureCaller struct { + productID string + tool string + args map[string]any + calls []recruitCapturedCall +} + +func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.tool = tool + c.args = args + c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (c *recruitCaptureCaller) Format() string { return "json" } +func (c *recruitCaptureCaller) DryRun() bool { return false } +func (c *recruitCaptureCaller) Fields() string { return "" } +func (c *recruitCaptureCaller) JQ() string { return "" } + +func withRecruitCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestRecruitJobListWrapsQueryParameters(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobListCommand() + cmd.SetArgs([]string{ + "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", + "--campus=false", "--cursor", "10", "--size", "30", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) + } + if caller.args["cursor"] != 10 || caller.args["size"] != 30 { + t.Fatalf("pagination args = %#v", caller.args) + } + param, ok := caller.args["param"].(map[string]any) + if !ok { + t.Fatalf("param = %#v, want object", caller.args["param"]) + } + if param["keyword"] != "Java" || param["campus"] != false { + t.Fatalf("param = %#v", param) + } + statuses, ok := param["statusList"].([]int) + if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 { + t.Fatalf("statusList = %#v", param["statusList"]) + } + creators, ok := param["creatorUserIds"].([]string) + if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" { + t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"]) + } +} + +func TestRecruitJobListDefaultsAndValidation(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobListCommand() + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.args["size"] != 20 { + t.Fatalf("size = %#v, want 20", caller.args["size"]) + } + if _, ok := caller.args["cursor"]; ok { + t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) + } + + badStatus := newRecruitJobListCommand() + badStatus.SetArgs([]string{"--status", "unknown"}) + if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { + t.Fatalf("invalid status error = %v", err) + } + + badSize := newRecruitJobListCommand() + badSize.SetArgs([]string{"--size", "101"}) + if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { + t.Fatalf("invalid size error = %v", err) + } +} + +func TestRecruitJobGetDispatchesJobID(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobGetCommand() + cmd.SetArgs([]string{"--job-id", "job-1"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" { + t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args) + } +} + +func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { + t.Helper() + path := filepath.Join(t.TempDir(), "job.json") + job := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": 1, "minSalary": 20000, "maxSalary": 35000, + } + data, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, job +} + +func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := newRecruitJobCreateCommand() + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"--from", path}) + err := cmd.Execute() + var appErr *apperrors.Error + if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls) + } +} + +func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := newRecruitJobCreateCommand() + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls) + } + call := caller.calls[0] + if call.productID != recruitServerID || call.tool != recruitCreateJobTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool) + } + wantArgs := map[string]any{"atsAddJobParam": map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + }} + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestRecruitCreateValidatesJobFile(t *testing.T) { + withRecruitCaller(t) + badPath := filepath.Join(t.TempDir(), "bad-job.json") + if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { + t.Fatal(err) + } + bad := newRecruitJobCreateCommand() + bad.Flags().Bool("yes", false, "确认创建") + bad.SetArgs([]string{"--from", badPath, "--yes"}) + if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("missing field error = %v", err) + } +} + +func TestRecruitCommandTree(t *testing.T) { + root := newRecruitCommand() + for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { + cmd, _, err := root.Find(path) + if err != nil || cmd == root || cmd.Name() != path[len(path)-1] { + t.Fatalf("Find(%v) = %v, %v", path, cmd, err) + } + } +} diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go index 7e89a565..16933d43 100644 --- a/internal/helpers/register_products_test.go +++ b/internal/helpers/register_products_test.go @@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t for _, want := range []string{ "agoal", "aisearch", "aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "doc", "drive", "live", "mail", - "markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard", + "markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard", } { if !seenProducts[want] { t.Errorf("public product %q was not registered", want) diff --git a/internal/helpers/register_recruit.go b/internal/helpers/register_recruit.go new file mode 100644 index 00000000..3ba9ceb2 --- /dev/null +++ b/internal/helpers/register_recruit.go @@ -0,0 +1,11 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。 +func init() { + RegisterPublic(func() Handler { + return wukongHandler{name: "recruit", buildFn: newRecruitCommand} + }) +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 5e0310b6..04e92642 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -43,11 +43,11 @@ func defaultHooks() *Hooks { } } -// openSupplementServers returns helper-only MCP endpoints owned by the open -// CLI. They are callable by explicit server ID but are deliberately excluded -// from VisibleProducts, so no top-level product command is generated. They -// stay separate from syncdata.StaticServers because only explicitly wired CLI -// helpers may call them; they are not public MCP product surfaces. +// openSupplementServers returns explicitly wired MCP endpoints owned by the +// open CLI. They are callable by explicit server ID but are deliberately +// excluded from VisibleProducts, so this hook never generates a top-level +// product command. A public command using one of these endpoints must register +// its Cobra tree explicitly via helpers.RegisterPublic. func openSupplementServers() []ServerInfo { return []ServerInfo{ { @@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo { Name: "钉钉白板", Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard", }, + { + ID: "recruit", + Name: "钉钉招聘", + Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff", + Prefixes: []string{"recruit", "job"}, + }, } } diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index c581c714..7418836e 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { servers := openSupplementServers() foundMCPMeta := false foundWhiteboard := false + foundRecruit := false for _, server := range servers { + if server.ID == "recruit" { + foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" && + len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job" + } if server.ID == "whiteboard" { foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard" } @@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { if !foundWhiteboard { t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint") } + if !foundRecruit { + t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") + } } diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index 440c149e..8dc8895a 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -108,6 +108,10 @@ coverage: multi_skill: dingtalk-misc multi_refs: - references/whiteboard.md + - mono: recruit + multi_skill: dingtalk-misc + multi_refs: + - references/recruit.md omit_coverage: - mono: simple diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 8fbd5975..3f491809 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -94,6 +94,7 @@ cli_version: ">=1.0.15" | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) | | `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) | +| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) | | `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -121,6 +122,7 @@ cli_version: ">=1.0.15" 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` 用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert` +用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit` 用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume` 用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume` diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md new file mode 100644 index 00000000..6c2b48f8 --- /dev/null +++ b/skills/mono/references/products/recruit.md @@ -0,0 +1,29 @@ +# 钉钉招聘 + +`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前 +操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。 + +## 查询 + +```bash +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job get --job-id JOB_ID --format json +``` + +列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`; +`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得 +`jobId`。 + +## 创建 + +创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并 +交由 CLI 的确认流程处理: + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、 +`minSalary`、`maxSalary`。后三者为数字,且最低薪资不得大于最高薪资。文件只写 +职位对象,不要添加 MCP 信封字段 `atsAddJobParam`。 diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index 487f8c7f..d4eed650 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -31,6 +31,7 @@ metadata: | 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) | | 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) | | 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) | +| 招聘 / 职位 / JD / 在招职位 / 创建职位 / 职位详情 | 钉钉招聘职位的查询、详情与创建 | `dws recruit` | [recruit.md](references/recruit.md) | | 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) | | 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) | | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md new file mode 100644 index 00000000..d6209be9 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -0,0 +1,64 @@ +# 钉钉招聘 + +`dws recruit` 查询和创建钉钉招聘职位。当前公开命令只覆盖职位列表、职位详情和 +职位创建;候选人、面试、Offer、职位修改、开放或关闭尚未作为稳定命令发布。 + +所有命令都应加 `--format json`。组织、业务标识和当前操作人由登录身份及 MCP +Connector 注入,不要要求用户提供或猜测 `corpId`、`bizCode`、`opUserId`。 + +## 查询职位列表 + +```bash +dws recruit job list --format json +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json +``` + +可选筛选包括 `--job-ids`、`--required-edu`、`--status`、`--job-nature`、 +`--campus`、`--start-modified-time`、`--end-modified-time`、 +`--creator-user-ids`、`--keyword`、`--category`。状态接受: + +- `draft`:草稿 +- `open`:招聘中 +- `invalid`:已失效 +- `closed`:已关闭/完成 + +`--size` 默认 20,范围 1–100。首页不传 `--cursor`;响应 `hasMore=true` 时, +把 `nextCursor` 回填到下一次查询。 + +## 查询职位详情 + +先从列表结果取得真实 `jobId`,不得编造: + +```bash +dws recruit job get --job-id JOB_ID --format json +``` + +## 创建职位 + +创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资和负责人,取得明确 +确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +最小 `job.json`: + +```json +{ + "name": "Java 开发工程师", + "description": "负责服务端系统开发", + "jobNature": "FULL_TIME", + "requiredEdu": 1, + "minSalary": 20000, + "maxSalary": 35000 +} +``` + +六个字段均为必填;`requiredEdu`、`minSalary`、`maxSalary` 必须为数字,且 +`minSalary` 不得大于 `maxSalary`。可选字段以当前 +`dws recruit job create --help` 和 leaf Schema 为准;不要把 MCP 信封字段 +`atsAddJobParam` 写进文件,CLI 会自动包装。 diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index 1f89476c..89d0f01a 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -22,7 +22,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { // All of these commands should be hidden because they are not in // the dynamic server discovery product list. for _, module := range []string{ - "aisearch", "contract", "recruit", + "aisearch", "contract", "chat", "drive", "minutes", "mail", "credit", "credit-risk", "finance", "message", "notify", "doc", } { @@ -30,4 +30,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { t.Fatalf("root help should not show hidden module %q:\n%s", module, got) } } + if !strings.Contains(got, "● recruit ") { + t.Fatalf("root help should show explicitly registered recruit module:\n%s", got) + } } From 8ee9fc3f48e209e79bf1fcab2ebd5df979309a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Thu, 13 Aug 2026 13:50:18 +0800 Subject: [PATCH 002/267] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=E6=8B=9B?= =?UTF-8?q?=E8=81=98=E7=BB=93=E6=9E=9C=E4=B8=8E=E5=88=86=E9=A1=B5=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20to#85340676?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/schema_agent_examples_dry_run_test.go | 8 ++ internal/helpers/recruit.go | 25 ++++- internal/helpers/recruit_test.go | 99 +++++++++++++++++++ test/fixtures/cli-interface-baseline.txt | 2 +- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index a3b8906f..5497810d 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -274,6 +274,7 @@ type agentExampleFiles struct { markdown string json string batch string + job string binary string image string } @@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown := filepath.Join(root, "content.md") jsonFile := filepath.Join(root, "report.json") batch := filepath.Join(root, "styles.json") + job := filepath.Join(root, "job.json") binary := filepath.Join(root, "report.pdf") image := filepath.Join(root, "chart.png") for path, content := range map[string][]byte{ markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"), jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`), batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), + job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL_TIME","requiredEdu":1,"minSalary":20000,"maxSalary":35000}`), binary: []byte("%PDF-1.4\n%%EOF\n"), image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, } { @@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown: "./" + filepath.Base(markdown), json: "./" + filepath.Base(jsonFile), batch: "./" + filepath.Base(batch), + job: "./" + filepath.Base(job), binary: "./" + filepath.Base(binary), image: "./" + filepath.Base(image), } @@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin replacement = files.markdown case "contents-file": replacement = files.json + case "from": + if strings.HasSuffix(strings.ToLower(value), "job.json") { + replacement = files.job + } case "batch": if strings.HasSuffix(strings.ToLower(value), "styles.json") { replacement = files.batch diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 315fbc39..c2756305 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -23,10 +23,29 @@ const ( ) var recruitDryRun = &contract.DryRunSpec{ - PreviewKind: contract.DryRunPreviewInvocation, + PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false, } +var ( + recruitListResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}},"hasMore":{"type":"boolean","description":"服务端是否还有下一页"},"nextCursor":{"description":"下一页游标;仅在 hasMore 为 true 时用于续查","oneOf":[{"type":"number"},{"type":"string"}]}},"additionalProperties":true}`), + } + recruitJobDetailResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`), + } + recruitCreateJobResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`), + } +) + +func recruitCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"} +} + func newRecruitCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "recruit", @@ -86,6 +105,8 @@ func newRecruitJobListCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, Description: "按条件分页查询招聘职位", DryRun: recruitDryRun, + Result: recruitListResult, + Pagination: recruitCursorPagination(), Interface: recruitMCPInterface(recruitListJobsTool), Selection: contract.SelectionSpec{ AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", @@ -130,6 +151,7 @@ func newRecruitJobGetCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, Description: "根据职位 ID 查询招聘职位详情", DryRun: recruitDryRun, + Result: recruitJobDetailResult, Interface: recruitMCPInterface(recruitGetJobTool), Selection: contract.SelectionSpec{ AgentSummary: "获取指定招聘职位的完整信息", @@ -159,6 +181,7 @@ func newRecruitJobCreateCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, Description: "从 JSON 文件创建招聘职位", DryRun: recruitDryRun, + Result: recruitCreateJobResult, Interface: recruitMCPInterface(recruitCreateJobTool), Selection: contract.SelectionSpec{ AgentSummary: "使用结构化 JSON 创建招聘职位", diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 274ab409..77f319ba 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -14,6 +14,8 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -196,6 +198,52 @@ func TestRecruitCreateValidatesJobFile(t *testing.T) { } } +func TestRecruitValidationBranches(t *testing.T) { + statuses, err := transformRecruitStatuses(" open, ,open,closed ") + if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) { + t.Fatalf("statuses = %#v, err = %v", statuses, err) + } + + negativeCursor := newRecruitJobListCommand() + negativeCursor.SetArgs([]string{"--cursor", "-1"}) + if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { + t.Fatalf("negative cursor error = %v", err) + } + + nullPath := filepath.Join(t.TempDir(), "null.json") + if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { + t.Fatalf("null job error = %v", err) + } + + base := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + } + for _, test := range []struct { + name string + mutate func(map[string]any) + want string + }{ + {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, + {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, + {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, + } { + t.Run(test.name, func(t *testing.T) { + job := make(map[string]any, len(base)) + for key, value := range base { + job[key] = value + } + test.mutate(job) + if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } +} + func TestRecruitCommandTree(t *testing.T) { root := newRecruitCommand() for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { @@ -205,3 +253,54 @@ func TestRecruitCommandTree(t *testing.T) { } } } + +func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) { + root := newRecruitCommand() + tests := []struct { + path []string + canonical string + resultField string + pagination bool + }{ + {path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true}, + {path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"}, + {path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"}, + } + for _, test := range tests { + t.Run(test.canonical, func(t *testing.T) { + leaf, _, err := root.Find(test.path) + if err != nil { + t.Fatal(err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical { + t.Fatalf("final identity = %#v, found = %v", final.Identity, ok) + } + if final.Result == nil { + t.Fatal("final Result is nil") + } + wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure} + if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) { + t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes) + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil { + t.Fatalf("unmarshal result data_schema: %v", err) + } + if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 { + t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField) + } + if test.pagination { + if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor || + final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath { + t.Fatalf("pagination = %#v, want cursor contract", final.Pagination) + } + } else if final.Pagination != nil { + t.Fatalf("pagination = %#v, want nil", final.Pagination) + } + }) + } +} diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index fab13963..31193263 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -1,7 +1,7 @@ [root] runnable: true hidden: false - commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki + commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, recruit, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent [agoal] From 5ef52503aece1f2f27895cdff373f03bc9d80ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Thu, 13 Aug 2026 15:37:49 +0800 Subject: [PATCH 003/267] fix(recruit): align result and cursor contracts --- internal/app/schema_recruit_contract_test.go | 62 ++++++++ internal/helpers/recruit.go | 141 +++++++++++++---- internal/helpers/recruit_test.go | 153 +++++++++++++++++-- 3 files changed, 314 insertions(+), 42 deletions(-) create mode 100644 internal/app/schema_recruit_contract_test.go diff --git a/internal/app/schema_recruit_contract_test.go b/internal/app/schema_recruit_contract_test.go new file mode 100644 index 00000000..6c6c4a2c --- /dev/null +++ b/internal/app/schema_recruit_contract_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any { + t.Helper() + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + args := []string{"schema", canonical, "--format", "json"} + if compact { + args = append(args, "--compact") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err) + } + return payload +} + +func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) { + for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} { + t.Run(canonical, func(t *testing.T) { + full := recruitSchemaLeaf(t, canonical, false) + compact := recruitSchemaLeaf(t, canonical, true) + if full["result"] == nil || compact["result"] == nil { + t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"]) + } + if !reflect.DeepEqual(full["result"], compact["result"]) { + t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"]) + } + if canonical == "recruit.list_jobs" { + if full["pagination"] == nil || compact["pagination"] == nil { + t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + if !reflect.DeepEqual(full["pagination"], compact["pagination"]) { + t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"]) + } + parameters, _ := full["parameters"].(map[string]any) + cursor, _ := parameters["cursor"].(map[string]any) + if cursor["type"] != "string" || cursor["interface_type"] != "number" { + t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor) + } + } else if full["pagination"] != nil || compact["pagination"] != nil { + t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + }) + } +} diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index c2756305..73997371 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -7,12 +7,14 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" "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" ) const ( @@ -30,7 +32,7 @@ var recruitDryRun = &contract.DryRunSpec{ var ( recruitListResult = &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}},"hasMore":{"type":"boolean","description":"服务端是否还有下一页"},"nextCursor":{"description":"下一页游标;仅在 hasMore 为 true 时用于续查","oneOf":[{"type":"number"},{"type":"string"}]}},"additionalProperties":true}`), + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`), } recruitJobDetailResult = &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, @@ -78,13 +80,14 @@ func newRecruitCommand() *cobra.Command { func newRecruitJobListCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "list", - Short: "查询招聘职位列表", - Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", - Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", - Server: recruitServerID, - Tool: recruitListJobsTool, - Safety: recruitSafetyRead(), + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, Flags: []LeafFlag{ {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, @@ -96,11 +99,11 @@ func newRecruitJobListCommand() *cobra.Command { {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, - {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时传返回的 nextCursor", Kind: LeafInt, Bind: "cursor"}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor}, {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", ArgDefault: "20"}, }, - Validate: validateRecruitList, - Call: runRecruitJobList, + Validate: validateRecruitList, + ResultCall: recruitResultCall, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, Description: "按条件分页查询招聘职位", @@ -119,7 +122,7 @@ func newRecruitJobListCommand() *cobra.Command { }) } -func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error { +func recruitListToolArgs(args map[string]any) map[string]any { params := map[string]any{"param": map[string]any{}, "size": args["size"]} query := params["param"].(map[string]any) for key, value := range args { @@ -131,18 +134,88 @@ func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error query[key] = value } } - return callMCPToolOnServer(recruitServerID, tool, params) + return params +} + +func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + toolArgs := args + if tool == recruitListJobsTool { + toolArgs = recruitListToolArgs(args) + } + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "tool": tool, "arguments": toolArgs, "executed": false, + }, output.WithDryRun()), nil + } + data, err := CallMCPToolDataOnServer(cmd.Context(), recruitServerID, tool, toolArgs) + if err != nil { + return nil, err + } + if tool != recruitListJobsTool { + return output.Success(data), nil + } + clean, meta, err := recruitListResultData(data) + if err != nil { + return output.Failure(&output.ErrorInfo{ + Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(), + Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。", + }), nil + } + return output.Success(clean, output.WithMeta(meta)), nil +} + +func recruitListResultData(data any) (any, *output.Meta, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值必须是 JSON 对象") + } + hasMore, ok := object["hasMore"].(bool) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") + } + nextToken := "" + if raw, exists := object["nextCursor"]; exists && raw != nil { + switch value := raw.(type) { + case string: + nextToken = strings.TrimSpace(value) + case float64: + nextToken = strconv.FormatFloat(value, 'f', -1, 64) + default: + return nil, nil, fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + } + } + if hasMore && nextToken == "" { + return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") + } + if !hasMore { + nextToken = "" + } + clean := make(map[string]any, len(object)-2) + for key, value := range object { + if key != "hasMore" && key != "nextCursor" { + clean[key] = value + } + } + pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1} + meta := &output.Meta{Pagination: pagination} + if jobs, exists := clean["jobs"].([]any); exists { + meta.Count = output.NewCount(len(jobs)) + pagination.Items = len(jobs) + } + return clean, meta, nil } func newRecruitJobGetCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "get", - Short: "查询招聘职位详情", - Long: "根据职位 ID 查询招聘职位详情。", - Example: " dws recruit job get --job-id JOB_ID --format json", - Server: recruitServerID, - Tool: recruitGetJobTool, - Safety: recruitSafetyRead(), + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, Flags: []LeafFlag{{ Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, Required: true, RequiredHint: "--job-id 为必填", @@ -166,13 +239,15 @@ func newRecruitJobGetCommand() *cobra.Command { func newRecruitJobCreateCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "create", - Short: "创建招聘职位", - Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", - Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", - Server: recruitServerID, - Tool: recruitCreateJobTool, - Safety: recruitSafetyCreate(), + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, Flags: []LeafFlag{{ Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, @@ -249,10 +324,16 @@ func transformRecruitStatuses(raw string) (any, error) { return statuses, nil } -func validateRecruitList(cmd *cobra.Command, _ []string) error { - if cursor, _ := cmd.Flags().GetInt("cursor"); cursor < 0 { - return apperrors.NewValidation("--cursor 必须大于或等于 0") +func transformRecruitCursor(raw string) (any, error) { + value := strings.TrimSpace(raw) + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数") } + return cursor, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { size, _ := cmd.Flags().GetInt("size") if cmd.Flags().Changed("size") && (size < 1 || size > 100) { return apperrors.NewValidation("--size 必须在 1 到 100 之间") diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 77f319ba..bfcb3936 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -14,9 +14,12 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -31,6 +34,9 @@ type recruitCaptureCaller struct { tool string args map[string]any calls []recruitCapturedCall + err error + text string + dryRun bool } func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -38,11 +44,24 @@ func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool strin c.tool = tool c.args = args c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) - return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil + if c.err != nil { + return nil, c.err + } + text := c.text + if text == "" { + text = `{}` + switch tool { + case recruitListJobsTool: + text = `{"jobs":[],"hasMore":false}` + case recruitCreateJobTool: + text = `{"jobId":"created-job"}` + } + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } func (c *recruitCaptureCaller) Format() string { return "json" } -func (c *recruitCaptureCaller) DryRun() bool { return false } +func (c *recruitCaptureCaller) DryRun() bool { return c.dryRun } func (c *recruitCaptureCaller) Fields() string { return "" } func (c *recruitCaptureCaller) JQ() string { return "" } @@ -54,9 +73,15 @@ func withRecruitCaller(t *testing.T) *recruitCaptureCaller { return caller } +func prepareRecruitTestCommand(cmd *cobra.Command) *cobra.Command { + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return cmd +} + func TestRecruitJobListWrapsQueryParameters(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobListCommand() + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) cmd.SetArgs([]string{ "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", "--campus=false", "--cursor", "10", "--size", "30", @@ -67,7 +92,7 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) } - if caller.args["cursor"] != 10 || caller.args["size"] != 30 { + if caller.args["cursor"] != int64(10) || caller.args["size"] != 30 { t.Fatalf("pagination args = %#v", caller.args) } param, ok := caller.args["param"].(map[string]any) @@ -87,9 +112,29 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { } } +func TestRecruitJobListCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", "9223372036854775807"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("cursor = %#v (%T), want max int64", got, got) + } + + for _, cursor := range []string{"not-a-cursor", "9223372036854775808"} { + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--cursor", cursor}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0 的整数") { + t.Fatalf("cursor %q error = %v", cursor, err) + } + } +} + func TestRecruitJobListDefaultsAndValidation(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobListCommand() + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) if err := cmd.Execute(); err != nil { t.Fatal(err) } @@ -100,13 +145,13 @@ func TestRecruitJobListDefaultsAndValidation(t *testing.T) { t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) } - badStatus := newRecruitJobListCommand() + badStatus := prepareRecruitTestCommand(newRecruitJobListCommand()) badStatus.SetArgs([]string{"--status", "unknown"}) if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { t.Fatalf("invalid status error = %v", err) } - badSize := newRecruitJobListCommand() + badSize := prepareRecruitTestCommand(newRecruitJobListCommand()) badSize.SetArgs([]string{"--size", "101"}) if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { t.Fatalf("invalid size error = %v", err) @@ -115,7 +160,7 @@ func TestRecruitJobListDefaultsAndValidation(t *testing.T) { func TestRecruitJobGetDispatchesJobID(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobGetCommand() + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) cmd.SetArgs([]string{"--job-id", "job-1"}) if err := cmd.Execute(); err != nil { t.Fatal(err) @@ -145,7 +190,7 @@ func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { caller := withRecruitCaller(t) path, _ := writeRecruitJobFixture(t) - cmd := newRecruitJobCreateCommand() + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) cmd.Flags().Bool("yes", false, "确认创建") cmd.SetIn(strings.NewReader("")) cmd.SetArgs([]string{"--from", path}) @@ -162,7 +207,7 @@ func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { caller := withRecruitCaller(t) path, _ := writeRecruitJobFixture(t) - cmd := newRecruitJobCreateCommand() + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) cmd.Flags().Bool("yes", false, "确认创建") cmd.SetArgs([]string{"--from", path, "--yes"}) if err := cmd.Execute(); err != nil { @@ -190,7 +235,7 @@ func TestRecruitCreateValidatesJobFile(t *testing.T) { if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { t.Fatal(err) } - bad := newRecruitJobCreateCommand() + bad := prepareRecruitTestCommand(newRecruitJobCreateCommand()) bad.Flags().Bool("yes", false, "确认创建") bad.SetArgs([]string{"--from", badPath, "--yes"}) if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { @@ -204,7 +249,7 @@ func TestRecruitValidationBranches(t *testing.T) { t.Fatalf("statuses = %#v, err = %v", statuses, err) } - negativeCursor := newRecruitJobListCommand() + negativeCursor := prepareRecruitTestCommand(newRecruitJobListCommand()) negativeCursor.SetArgs([]string{"--cursor", "-1"}) if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { t.Fatalf("negative cursor error = %v", err) @@ -217,6 +262,16 @@ func TestRecruitValidationBranches(t *testing.T) { if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { t.Fatalf("null job error = %v", err) } + if _, err := loadRecruitJobFile(filepath.Join(t.TempDir(), "missing.json")); err == nil || !strings.Contains(err.Error(), "读取职位 JSON 失败") { + t.Fatalf("missing job file error = %v", err) + } + invalidPath := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(invalidPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(invalidPath); err == nil || !strings.Contains(err.Error(), "不是有效的 JSON 对象") { + t.Fatalf("invalid job JSON error = %v", err) + } base := map[string]any{ "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", @@ -244,6 +299,80 @@ func TestRecruitValidationBranches(t *testing.T) { } } +func TestRecruitListResultData(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"}, + {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"}, + {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + + data, meta, err := recruitListResultData(map[string]any{ + "jobs": []any{map[string]any{"jobId": "job-1"}}, "hasMore": true, "nextCursor": float64(12), + }) + if err != nil { + t.Fatal(err) + } + if meta.Count == nil || *meta.Count != 1 || meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "12" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 1 { + t.Fatalf("meta = %#v", meta) + } + clean := data.(map[string]any) + if _, exists := clean["hasMore"]; exists || clean["jobs"] == nil { + t.Fatalf("clean data = %#v", clean) + } + + _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"}) + if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { + t.Fatalf("terminal meta = %#v, err = %v", terminal, err) + } +} + +func TestRecruitResultCallPropagatesMCPError(t *testing.T) { + caller := withRecruitCaller(t) + caller.err = stderrors.New("transport failed") + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + if _, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}); err == nil { + t.Fatal("expected MCP error") + } +} + +func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"jobs":[],"hasMore":true}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeFailure { + t.Fatalf("outcome = %s, want failure", result.Outcome()) + } +} + +func TestRecruitResultCallDryRun(t *testing.T) { + caller := withRecruitCaller(t) + caller.dryRun = true + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeSuccess || len(caller.calls) != 0 { + t.Fatalf("outcome = %s, calls = %d", result.Outcome(), len(caller.calls)) + } +} + func TestRecruitCommandTree(t *testing.T) { root := newRecruitCommand() for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { From 31902a987ef9f432967c54ab12fbc9c0e52b6931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 18:50:27 +0800 Subject: [PATCH 004/267] fix(skills): use canonical global installation --- README.md | 4 +- build/npm/install.js | 233 +++++++++++------ internal/app/skill_setup.go | 130 +++++++--- internal/app/skill_setup_canonical_test.go | 89 +++++++ internal/app/skill_setup_plan_test.go | 28 +- internal/app/skill_setup_test.go | 28 +- internal/upgrade/canonical_skills_test.go | 91 +++++++ internal/upgrade/coverage_edges_test.go | 6 +- internal/upgrade/paths.go | 281 ++++++++++++--------- internal/upgrade/paths_multi_test.go | 245 +++--------------- scripts/install-skills.sh | 164 ++++++------ scripts/install.ps1 | 152 ++++++----- scripts/install.sh | 183 ++++++++------ scripts/release/verify-package-managers.sh | 41 ++- test/scripts/install_js_smoke.mjs | 17 +- test/scripts/install_script_test.go | 42 +-- 16 files changed, 1020 insertions(+), 714 deletions(-) create mode 100644 internal/app/skill_setup_canonical_test.go create mode 100644 internal/upgrade/canonical_skills_test.go diff --git a/README.md b/README.md index b29410ae..ada28247 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index 49e9fd7f..8a7e94d4 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -29,6 +29,17 @@ const AGENT_DIRS = [ ".hermes/skills", ]; +// Same capability split as the `skills` CLI used by lark-cli. Universal +// Agents read ~/.agents/skills directly and must not receive a second copy. +const UNIVERSAL_AGENT_DIRS = new Set([ + ".cursor/skills", + ".gemini/skills", + ".codex/skills", + ".github/skills", + ".cline/skills", + ".amp/skills", +]); + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -65,7 +76,14 @@ function backupStamp() { // and false is returned so callers skip that target rather than silently // deleting data. function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + let info; + try { + info = fs.lstatSync(dir); + } catch (err) { + if (err && err.code === "ENOENT") return true; + throw err; + } + if (!info.isDirectory() && !info.isSymbolicLink()) { return true; } const rel = path.relative(homeDir, dir); @@ -234,15 +252,11 @@ function installSkillsToHomes(skillRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -256,37 +270,44 @@ function installSkillsToHomes(skillRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); - - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) { + installed += 1; + } else { + failed += 1; } - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const agentDir of AGENT_DIRS.slice(1)) { + const baseDir = path.join(homeDir, agentDir); + if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); + failed += 1; + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + victims.push(path.join(baseDir, entry.name)); + } + } + } + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); + installed += 1; + } catch (linkErr) { + console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); + if (installToBase(baseDir)) installed += 1; + else failed += 1; + } } } if (installed === 0) { @@ -346,12 +367,11 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireGenericSkillRoot(homeDir, managedNames) { - const baseDir = path.join(homeDir, ".agents", "skills"); +function retireManagedSkillRoot(homeDir, baseDir, managedNames) { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -374,12 +394,74 @@ function retireGenericSkillRoot(homeDir, managedNames) { } } if (restoreErrors.length > 0) { - throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`); + throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`); } throw err; } } +function samePhysicalDir(left, right) { + try { + return fs.realpathSync(left) === fs.realpathSync(right); + } catch (_) { + return false; + } +} + +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { + fs.mkdirSync(baseDir, { recursive: true }); + const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); + const staged = []; + const backups = []; + const published = []; + const correctLinks = new Set(); + const restore = () => { + for (let i = published.length - 1; i >= 0; i -= 1) { + fs.rmSync(published[i], { recursive: true, force: true }); + } + for (let i = backups.length - 1; i >= 0; i -= 1) { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + fs.renameSync(backups[i].backup, backups[i].original); + } + }; + try { + for (const name of names) { + const target = path.join(canonicalBase, name); + const dest = path.join(baseDir, name); + if (samePhysicalDir(dest, target)) { + correctLinks.add(path.resolve(dest)); + continue; + } + const stagedPath = path.join(stageRoot, name); + const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target); + fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); + } + const seen = new Set(); + for (const victim of victims) { + const normalized = path.resolve(victim); + if (seen.has(normalized) || correctLinks.has(normalized)) continue; + seen.add(normalized); + if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { + throw new Error(`failed to back up Skill directory ${victim}`); + } + } + for (const item of staged) { + fs.renameSync(item.staged, item.dest); + published.push(item.dest); + } + } catch (err) { + try { + restore(); + } catch (restoreErr) { + throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`); + } + throw err; + } finally { + fs.rmSync(stageRoot, { recursive: true, force: true }); + } +} + function skillDirectoryDigest(dir) { const files = []; const visit = (current, prefix) => { @@ -628,10 +710,6 @@ function installMultiSkillsToHomes(multiRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { fs.mkdirSync(baseDir, { recursive: true }); const victims = [path.join(baseDir, "dws")]; @@ -639,7 +717,7 @@ function installMultiSkillsToHomes(multiRoot) { // the same transaction as every replaced bundled skill. for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { if ( - entry.isDirectory() && + (entry.isDirectory() || entry.isSymbolicLink()) && (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && !skillSet.has(entry.name) ) { @@ -658,37 +736,44 @@ function installMultiSkillsToHomes(multiRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) installed += 1; + else failed += 1; - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } - } - - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const agentDir of AGENT_DIRS.slice(1)) { + const baseDir = path.join(homeDir, agentDir); + if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); + failed += 1; + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ( + (entry.isDirectory() || entry.isSymbolicLink()) && + (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && + !skillSet.has(entry.name) + ) victims.push(path.join(baseDir, entry.name)); + } + } + for (const name of skills) victims.push(path.join(baseDir, name)); + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); + installed += 1; + } catch (linkErr) { + console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); + if (installToBase(baseDir)) installed += 1; + else failed += 1; + } } } if (installed === 0) { diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 62ab2c17..7f5fe2fb 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -65,6 +65,7 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir @@ -74,6 +75,7 @@ var ( skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel skillSetupReadlink = os.Readlink + skillSetupEvalSymlinks = filepath.EvalSymlinks skillSetupOpen = os.Open skillSetupOpenFile = os.OpenFile skillSetupWriteFile = os.WriteFile @@ -91,9 +93,11 @@ type skillSetupBackup struct { } type skillSetupTargetPlan struct { - Destination string - Backups []skillSetupBackup - CleanupOnly bool + Destination string + CanonicalBase string + Backups []skillSetupBackup + CleanupOnly bool + LinkCanonical bool } type skillSetupPlan struct { @@ -149,8 +153,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录; -未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 +原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, +链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -910,8 +915,9 @@ func isSkillSourceRoot(path, mode string) bool { } // resolveSkillSetupTargets returns the list of absolute Agent home destinations. -// If target == "all", returns every agent home whose parent directory exists. -// Otherwise returns the single matching home (whether or not it currently exists). +// The canonical ~/.agents/skills destination is always first. If target == +// "all", detected concrete Agent roots follow it. A specific target follows +// canonical as well so unknown/future Agents retain the universal copy. // // 末段约定: // - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) @@ -923,6 +929,7 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { } target = strings.ToLower(strings.TrimSpace(target)) + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) if target == "" || target == "all" { return detectExistingAgentHomes(home, mode), nil } @@ -931,7 +938,11 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil + dest := agentHomeForMode(filepath.Join(home, rel), mode) + if filepath.Clean(dest) == filepath.Clean(canonical) { + return []string{canonical}, nil + } + return []string{canonical, dest}, nil } // agentHomeForMode appends the mode-specific tail segment to an agent home base. @@ -943,7 +954,8 @@ func agentHomeForMode(base, mode string) string { } func detectExistingAgentHomes(home, mode string) []string { - var specific []string + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) + dests := []string{canonical} for i, rel := range skillSetupAgentHomes { if i == 0 { continue @@ -953,12 +965,45 @@ func detectExistingAgentHomes(home, mode string) []string { if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { continue } - specific = append(specific, agentHomeForMode(base, mode)) + dests = append(dests, agentHomeForMode(base, mode)) } - if len(specific) > 0 { - return specific + return dests +} + +func skillSetupBaseForMode(dest, mode string) string { + if mode == skillSetupModeMono { + return filepath.Dir(dest) } - return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)} + return dest +} + +func isUniversalSkillSetupBase(base string) bool { + base = filepath.ToSlash(filepath.Clean(base)) + for rel := range map[string]bool{ + ".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, + ".github/skills": true, ".cline/skills": true, ".amp/skills": true, + } { + if strings.HasSuffix(base, "/"+rel) { + return true + } + } + return false +} + +func canonicalSkillSetupBase(dests []string, mode string) string { + for _, dest := range dests { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) + if strings.HasSuffix(base, "/.agents/skills") { + return skillSetupBaseForMode(dest, mode) + } + } + return "" +} + +func samePhysicalSkillSetupPath(left, right string) bool { + leftReal, leftErr := skillSetupEvalSymlinks(left) + rightReal, rightErr := skillSetupEvalSymlinks(right) + return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) } func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { @@ -1032,8 +1077,17 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt sortedDests := append([]string(nil), dests...) sort.Strings(sortedDests) managedNames := currentManagedSkillNames() + canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { - target := skillSetupTargetPlan{Destination: dest} + base := skillSetupBaseForMode(dest, mode) + target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase} + if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) { + if isUniversalSkillSetupBase(base) { + target.CleanupOnly = true + } else { + target.LinkCanonical = true + } + } seen := map[string]bool{} add := func(path, reason string) { if seen[path] { @@ -1077,6 +1131,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } for _, path := range replacements { + if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + continue + } info, statErr := skillSetupStat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { @@ -1089,14 +1146,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - plan.Targets = append(plan.Targets, target) - } - cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames) - if cleanupErr != nil { - return nil, cleanupErr - } - if cleanupTarget != nil { - plan.Targets = append(plan.Targets, *cleanupTarget) + if !target.CleanupOnly || len(target.Backups) > 0 { + plan.Targets = append(plan.Targets, target) + } } return plan, nil } @@ -1151,7 +1203,9 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintln(out, " destinations:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination) + } else if target.LinkCanonical { + fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) } else { fmt.Fprintf(out, " - %s\n", target.Destination) } @@ -1649,6 +1703,21 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) + if target.LinkCanonical { + canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest)) + if samePhysicalSkillSetupPath(dest, canonicalTarget) { + return nil + } + relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget) + if relErr != nil { + return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) + } + if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil { + return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr) + } + staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest}) + return nil + } if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil { return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err) } @@ -1752,16 +1821,13 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } for _, target := range plan.Targets { if target.CleanupOnly { - if skipped > 0 { - continue - } if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr) + fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) skipped++ continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) + fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) skipped++ } continue @@ -1777,6 +1843,12 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) + if stageErr != nil && target.LinkCanonical { + fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) + fallback := target + fallback.LinkCanonical = false + stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) + } if stageErr != nil { fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget @@ -1836,7 +1908,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[ } var victims []string for _, e := range entries { - if !e.IsDir() || keepSet[e.Name()] { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] { continue } if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go new file mode 100644 index 00000000..83a43d93 --- /dev/null +++ b/internal/app/skill_setup_canonical_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupAgentHomes, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + if len(dests) != 4 || dests[0] != canonical { + t.Fatalf("targets = %v", dests) + } + + src := t.TempDir() + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("Codex duplicate remains: %v", err) + } + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link %s = %#v, %v", link, info, err) + } + } + } + // Re-running setup must recognize the existing links as already correct; + // canonical refreshes in place without turning links into copied trees. + plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err) + } + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index fcc5ceb0..e46339f2 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -149,31 +149,36 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) { home := t.TempDir() dest := filepath.Join(home, ".codex", "skills") - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { + canonical := filepath.Join(home, ".agents", "skills") + oldCodex := filepath.Join(dest, "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { t.Fatal(err) } testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("transient HOME failure") }) - plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true) if err != nil { t.Fatal(err) } - if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) { - t.Fatalf("generic cleanup target = %#v", plan.Targets) + if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest { + t.Fatalf("universal cleanup target = %#v", plan.Targets) } - if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono { - t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups) + if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex { + t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups) } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") { - t.Fatalf("generic cleanup preview missing: %s", preview.String()) + if !strings.Contains(preview.String(), "universal Agent") { + t.Fatalf("universal cleanup preview missing: %s", preview.String()) } t.Run("managed multi and scan failure", func(t *testing.T) { + genericMono := filepath.Join(home, ".agents", "skills", "dws") + if err := os.MkdirAll(genericMono, 0o755); err != nil { + t.Fatal(err) + } managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") if err := os.MkdirAll(managedDir, 0o755); err != nil { t.Fatal(err) @@ -187,9 +192,6 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { t.Fatalf("generic scan error = %v", targetErr) } - if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) { - t.Fatalf("generic cleanup plan error = %v", planErr) - } }) } @@ -210,7 +212,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") { + if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 2a776355..1a37371e 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills", "dws") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t t.Fatal(err) } want := filepath.Join(home, ".codex", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } } @@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } want := filepath.Join(home, ".zcode", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono) @@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } wantMono := filepath.Join(want, "dws") - if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) { - t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono) + if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) { + t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono) } } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go new file mode 100644 index 00000000..163598ad --- /dev/null +++ b/internal/upgrade/canonical_skills_test.go @@ -0,0 +1,91 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + // beta.6 wrote a physical copy into Codex. The canonical migration must + // remove it without treating a matching user/market name as DWS-owned. + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + + canonical := filepath.Join(home, ".agents", "skills") + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) { + t.Fatalf("universal Codex duplicate remains for %s: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s link = %#v, %v", link, info, err) + } + if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil { + t.Fatalf("%s does not resolve canonical content: %v", link, err) + } + } + } + result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("idempotent upgrade = %#v, %v", result, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") }) + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + info, err := os.Lstat(dest) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } + if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil { + t.Fatalf("copy fallback content missing: %v", err) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 960d81f7..9794f290 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) { } knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"} result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 { + if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 { t.Fatalf("skill upgrade = %#v, %v", result, err) } knownSkillDirs = []string{".real/skills"} @@ -593,8 +593,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { t.Fatal(err) } result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 { - t.Fatalf("replaced primary fallback = %#v, %v", result, err) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical failure = %#v, %v", result, err) } failure := errors.New("ensure failed") upgradeEnsureDir = func(string, os.FileMode) error { return failure } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index a83309d3..12d89118 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -35,10 +35,11 @@ const ( // - test/scripts/package_script_test.go expectedPackagedSkillTargets // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // -// The first entry (.agents/skills) is a generic fallback. It is used only -// when no concrete Agent home is detected; otherwise publishing there would -// duplicate every Skill for Agents (including Codex) that scan both roots. -// Subsequent entries are updated when their parent directory already exists. +// The first entry (.agents/skills) is the canonical global Skill directory, +// matching the mechanism used by lark-cli's upstream `skills` installer. +// Agents that understand the universal .agents convention read it directly; +// detected non-universal Agents receive relative links to this canonical +// copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", ".claude/skills", @@ -59,6 +60,20 @@ var knownSkillDirs = []string{ ".hermes/skills", } +// universalSkillDirs are concrete Agent directories whose current clients +// natively scan ~/.agents/skills. Publishing another copy or link below these +// roots makes the same Skill appear twice. Keep this classification aligned +// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally +// non-universal because it is a DWS-specific integration absent upstream. +var universalSkillDirs = map[string]bool{ + ".cursor/skills": true, + ".gemini/skills": true, + ".codex/skills": true, + ".github/skills": true, + ".cline/skills": true, + ".amp/skills": true, +} + var ( upgradeUserHomeDir = os.UserHomeDir upgradeExecutable = os.Executable @@ -70,6 +85,8 @@ var ( upgradeMkdirTemp = os.MkdirTemp upgradeReadDir = os.ReadDir upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink upgradeBuildProvenance = skillprovenance.Build upgradeReadSkillState = skillstate.Read upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } @@ -90,14 +107,14 @@ const skillBackupSubdir = ".dws/skill-backups" // opposite layout next to it silently). Missing paths and regular files are // no-ops ("", nil). func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeStat(dir) + info, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() { + if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { return "", nil } rel, relErr := filepath.Rel(homeDir, dir) @@ -204,11 +221,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { // `dws skill setup --mode mono` flow. // // Strategy (matches npm install.js installSkillsToHomes): -// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their -// parent directory exists (e.g. ~/.codex/ exists => user has Codex) -// - ~/.agents/skills/ is used only when no concrete Agent is detected +// - ~/.agents/skills/ is always the canonical global store +// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the +// canonical store directly, so old agent-specific copies are retired +// - detected non-universal Agents receive relative links to canonical; +// filesystems that reject links receive a direct-copy fallback // - ~/.real/ and other blacklisted paths are NEVER touched -// - If no location was updated at all, fall back to ~/.agents/skills/ +// - canonical publication is mandatory and fails the upgrade loudly // // Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then // removed so mono and multi never co-exist after an upgrade; a leftover that @@ -517,100 +536,146 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str return publishStagedSkillSet(homeDir, staged, victims) } -func hasDetectedSpecificSkillRoot(homeDir string) bool { - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) { - continue - } - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if info, err := upgradeStat(parentGate); err == nil && info.IsDir() { - return true - } - } - return false -} - func isGenericSkillRoot(agentDir string) bool { return filepath.Clean(agentDir) == filepath.Clean(".agents/skills") } -func retireGenericSkillRoot(homeDir string, managed map[string]bool) error { - base := filepath.Join(homeDir, ".agents", "skills") +func isUniversalSkillRoot(agentDir string) bool { + return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] +} + +func skillRootDetected(homeDir, agentDir string) bool { + parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) + info, err := upgradeStat(parentGate) + return err == nil && info.IsDir() +} + +func samePhysicalSkillRoot(left, right string) bool { + leftReal, leftErr := upgradeEvalSymlinks(left) + rightReal, rightErr := upgradeEvalSymlinks(right) + return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) +} + +func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { return err } victims = append(victims, filepath.Join(base, "dws")) if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err) + return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err) } return nil } +func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) { + if err := upgradeMkdirAll(destBase, dirPermShared); err != nil { + return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err) + } + stageRoot, err = upgradeMkdirTemp(destBase, prefix) + if err != nil { + return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err) + } + defer func() { + if err != nil { + _ = upgradeRemoveAll(stageRoot) + } + }() + for _, name := range names { + target := filepath.Join(canonicalBase, name) + relTarget, relErr := filepath.Rel(destBase, target) + if relErr != nil { + return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) + } + stagedPath := filepath.Join(stageRoot, name) + if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil { + return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr) + } + staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)}) + } + return stageRoot, staged, nil +} + +func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error { + needed := make([]string, 0, len(names)) + correct := make(map[string]bool, len(names)) + for _, name := range names { + dest := filepath.Join(destBase, name) + canonical := filepath.Join(canonicalBase, name) + if samePhysicalSkillRoot(dest, canonical) { + correct[filepath.Clean(dest)] = true + continue + } + needed = append(needed, name) + } + filteredVictims := victims[:0] + for _, victim := range victims { + if !correct[filepath.Clean(victim)] { + filteredVictims = append(filteredVictims, victim) + } + } + stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed) + if err != nil { + return err + } + defer func() { _ = upgradeRemoveAll(stageRoot) }() + return publishStagedSkillSet(homeDir, staged, filteredVictims) +} + // upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory // per agent home. func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) { result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + canonicalDest := filepath.Join(canonicalBase, "dws") + if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) for _, agentDir := range knownSkillDirs { - destDir := filepath.Join(homeDir, agentDir, "dws") - + if isGenericSkillRoot(agentDir) { + continue + } + destBase := filepath.Join(homeDir, agentDir) + destDir := filepath.Join(destBase, "dws") if isBlacklisted(agentDir) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetected(homeDir, agentDir) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + continue + } + if isUniversalSkillRoot(agentDir) { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil { + // Match the upstream installer: platforms/filesystems that reject + // links receive a direct copy rather than losing Agent support. + if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location. The multi - // leftovers under the primary base are the usual reason the primary - // install failed, so clean them first — failing loud like the multi - // fallback — instead of letting mono and multi co-exist marked OK. - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - dest := filepath.Join(destBase, "dws") - if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == dest { - result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level mono cache so that // `dws skill setup --mode mono` fallbacks stay on the upgraded version @@ -633,61 +698,51 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) for _, agentDir := range knownSkillDirs { + if isGenericSkillRoot(agentDir) { + continue + } destBase := filepath.Join(homeDir, agentDir) - if isBlacklisted(agentDir) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetected(homeDir, agentDir) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(destBase) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + continue + } + if isUniversalSkillRoot(agentDir) { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil { + if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == destBase { - result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level caches so that `dws skill setup` // fallbacks stay on the upgraded version. The release zip ships both @@ -789,7 +844,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str } victims := make([]string, 0) for _, e := range entries { - if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(baseDir, e.Name())) @@ -825,7 +880,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err) } for _, e := range entries { - if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(destBase, e.Name())) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ec55c21d..e9abb256 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -124,8 +124,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { home := withFakeHome(t) - // A concrete Agent root wins over the generic .agents fallback; .claude - // installs and .cursor is skipped. + // .agents is canonical; detected Claude receives links and Cursor is skipped. agentsBase := filepath.Join(home, ".agents", "skills") claudeBase := filepath.Join(home, ".claude", "skills") for _, base := range []string{agentsBase, claudeBase} { @@ -193,17 +192,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { // Succeeded entries report the agent home base in multi mode. succeeded := result.Succeeded() - if len(succeeded) != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results) + if len(succeeded) != 2 { + t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results) } - wantDirs := map[string]bool{claudeBase: true} + wantDirs := map[string]bool{agentsBase: true, claudeBase: true} for _, d := range succeeded { if !wantDirs[d.Dir] { t.Errorf("unexpected succeeded dir %q", d.Dir) } } - if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { - t.Fatalf("generic mono duplicate still visible: %v", err) + for _, name := range []string{"dingtalk-chat", "dws-shared"} { + if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } } // Multi cache refreshed under the fake home. @@ -216,7 +217,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { } } -func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) { +func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"}) @@ -241,12 +242,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err) } - want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md") + want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md") if _, err := os.Stat(want); err != nil { - t.Fatalf("canonical Codex Skill missing at %s: %v", want, err) + t.Fatalf("canonical Skill missing at %s: %v", want, err) } for _, duplicate := range []string{ - filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"), + filepath.Join(codexBase, "dingtalk-chat"), filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -526,21 +527,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure( } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical copy failure = %#v, %v", result, err) } data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md")) if err != nil { t.Fatalf("mono fallback install missing: %v", err) } - if string(data) != "# mono" { - t.Errorf("mono content = %q, want refreshed package", data) + if string(data) != "old" { + t.Errorf("mono content = %q, want original preserved", data) } } @@ -574,22 +569,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err) + if err == nil { + t.Fatal("canonical read failure must fail the upgrade") } failed := result.Failed() if len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) } - wantDir := agentsBase + wantDir := filepath.Join(agentsBase, "dws") if failed[0].Dir != wantDir || failed[0].Err == nil { t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir) } if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") { t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err) } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) + if got := len(result.Succeeded()); got != 0 { + t.Fatalf("Succeeded() len = %d, want 0", got) } // Mono must NOT have been laid down next to the unreadable multi state. if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { @@ -628,8 +623,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t if err == nil { t.Fatal("multi fallback cleanup failure must return an error") } - if !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("error should mention the fallback cleanup failure, got %v", err) + if !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("error should mention the canonical cleanup failure, got %v", err) } if failed := result.Failed(); len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) @@ -654,8 +649,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { // backup. Inject it through the path seam so this branch is portable to // Windows, where chmod-based permission failures are not reliable. statFailurePath := filepath.Join(home, "stat-failure") - origStat := upgradeStat - testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + origStat := upgradeLstat + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { if path == statFailurePath { return nil, errors.New("injected stat failure") } @@ -888,17 +883,12 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { // TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono // path's fail-loud backup semantics and every primary-fallback outcome. func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - mono := t.TempDir() if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil { t.Fatal(err) } - - // Per-home backup failure marks that home failed; another home still wins. home := withFakeHome(t) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) @@ -909,67 +899,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { return os.Rename(src, dst) }) result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical backup failure = %#v, %v", result, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("failed home must keep its original dws/: %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback cleanup failure: only a blacklisted home in the main loop, then - // the primary cleanup hits a stale dir whose backup fails. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".real/skills"} - stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale") - os.MkdirAll(stale, 0o755) - useUpgradeManagedNames(t, filepath.Base(stale)) - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback cleanup error = %v", err) - } - if _, err := os.Stat(stale); err != nil { - t.Fatalf("stale dir must survive failed cleanup: %v", err) - } - - // Fallback backup failure of the primary dws/ itself. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure: everything fails loud. - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append (no prior entry for the primary dir): a blacklisted-only - // main loop leaves no primary entry, so the fallback appends a fresh OK. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - result, err = UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary", got) - } - if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) + t.Fatalf("failed canonical home must keep its original dws/: %v", err) } } @@ -977,15 +911,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { // path's blacklisted branch, per-skill backup/copy failures, and the primary // fallback outcomes (cleanup OK + backup/copy failure, fresh append). func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - extract := t.TempDir() multiRoot := writeMultiBundle(t, extract, "dingtalk-chat") - - // Blacklisted entry is reported, non-blacklisted installs. home := withFakeHome(t) - knownSkillDirs = []string{".real/skills", ".agents/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"}) os.MkdirAll(filepath.Join(home, ".agents"), 0o755) result, err := UpgradeSkillLocations(multiRoot) if err != nil { @@ -1003,118 +932,8 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) { t.Fatalf("blacklisted home must never be touched, stat err=%v", err) } - - // A detected concrete Agent root wins over .agents. A failure while retiring - // the old generic copy is surfaced after the concrete root succeeds. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} - os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755) - os.MkdirAll(filepath.Join(home2, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, ".agents") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 backup-failed home", failed) - } - if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil { - t.Fatalf("failed home must keep its original skill: %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Per-skill copy failure on the concrete home fails that home; with no - // successful concrete target, the generic copy is left untouched. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".claude"), 0o755) - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - if strings.Contains(dst, ".claude") { - return errors.New("copy denied") - } - return copyDir(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 copy-failed home", failed) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback backup failure: the same-name bundle skill refresh is what - // fails (cleanup succeeds, the per-skill backup does not). - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - knownSkillDirs = []string{".real/skills"} - os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, "dingtalk-chat") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append: blacklisted-only main loop → fresh OK entry appended. - home6 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results) - } - if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) - } - - // Fallback replace: the main loop fails the primary home on a transient - // copy error, the fallback retry succeeds and replaces the failed entry. - home7 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil }) - knownSkillDirs = []string{".agents/skills"} - origCopy := upgradeCopyDir - attempts := 0 - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - attempts++ - if attempts == 1 { - return errors.New("transient copy failure") - } - return origCopy(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback replace error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("failed entry must be replaced by OK, got %v", failed) - } - if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback replace install missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("canonical install missing: %v", err) } } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index ad4eeb03..97a4b093 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -408,12 +408,27 @@ multi_tree_has_skills() { return 1 } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering duplicates. -retire_generic_skill_root() { +# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the +# canonical store. Universal Agents read it directly; other Agents receive +# relative links and fall back to copies only when links are unavailable. +is_universal_agent_dir() { + case "$1" in + ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + *) return 1 ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 + _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do @@ -430,25 +445,62 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +link_canonical_skills_to_base() { + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + _lcs_names="" + for _lcs_skill in "$_lcs_canonical"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + fi + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi + ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="$_lcs_publish_names $_lcs_name" + done + for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do + [ -d "$_lcs_victim" ] || continue + [ "$_lcs_victim" = "$_lcs_stage" ] && continue + same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue + if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi + if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then + restore_multi_skill_set /dev/null "$_lcs_backups" || true + rm -rf "$_lcs_stage"; return 1 + fi + done + for _lcs_name in $_lcs_publish_names; do + printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || { + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1; + } + if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name" + done + rm -rf "$_lcs_stage" +} + # Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_multi_skills_to_root() { multi_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -466,31 +518,22 @@ install_multi_skills_to_root() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if link_canonical_skills_to_base "$root" "$base_dir" multi; then installed=$((installed + 1)) else - failed=$((failed + 1)) - printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir" + printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 @@ -654,20 +697,11 @@ install_skills_to_root() { skill_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -685,44 +719,22 @@ install_skills_to_root() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if [ "$root" = "$HOME" ]; then - label="~/$agent_dir/$SKILL_NAME" - else - label="$root/$agent_dir/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - if [ "$root" = "$HOME" ]; then - flabel="~/.agents/skills/$SKILL_NAME" - else - flabel="$root/.agents/skills/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d9c62a1b..bab5a678 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -458,10 +458,20 @@ function Restore-MultiSkillSet { return $ok } -function Move-GenericSkillRootToBackup { - param([string]$Root) +function Test-UniversalAgentDir { + param([string]$AgentDir) + return @(".cursor\skills", ".gemini\skills", ".codex\skills", ".github\skills", ".cline\skills", ".amp\skills") -contains $AgentDir +} + +function Test-SamePhysicalSkillRoot { + param([string]$Left, [string]$Right) + if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } + try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false } +} + +function Move-AgentSkillRootToBackup { + param([string]$Root, [string]$BaseDir) - $baseDir = Join-Path $Root ".agents\skills" $victims = [System.Collections.Generic.List[string]]::new() $victims.Add((Join-Path $baseDir $SkillName)) foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) { @@ -476,7 +486,7 @@ function Move-GenericSkillRootToBackup { if (!$seen.Add($victim)) { continue } $backupPath = "" if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { - throw "通用 Skill 副本备份失败: $victim" + throw "Agent Skill 旧副本备份失败: $victim" } if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } @@ -485,11 +495,64 @@ function Move-GenericSkillRootToBackup { return $true } catch { Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null - Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_" + Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_" return $false } } +function Publish-CanonicalSkillLinks { + param([string]$Root, [string]$BaseDir, [string]$Mode) + $canonical = Join-Path $Root ".agents\skills" + if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } + if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } + $stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N")) + $backups = @() + $published = @() + try { + New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null + if ($Mode -eq "mono") { + $names = @($SkillName) + } else { + $names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) + } + $publishNames = @() + foreach ($name in $names) { + if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue } + New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target (Join-Path $canonical $name) -ErrorAction Stop | Out-Null + $publishNames += $name + } + $victims = [System.Collections.Generic.List[string]]::new() + $victims.Add((Join-Path $BaseDir $SkillName)) + foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -Force -ErrorAction SilentlyContinue) { + if ($existing.FullName -eq $stageRoot) { continue } + if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } + } + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($victim in $victims) { + if (!$seen.Add($victim)) { continue } + if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue } + $backupPath = "" + if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" } + if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } } + } + foreach ($name in $publishNames) { + $dest = Join-Path $BaseDir $name + $published += $dest + Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + Write-Say "↪ Skills → $dest" + } + return $true + } catch { + Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null + Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" + return $false + } finally { + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Copy-SkillToDir { param([string]$SkillSrc, [string]$Dest, [string]$Label) @@ -811,46 +874,27 @@ function Install-SkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } + $canonical = Join-Path $Root ".agents\skills" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $agentDir = $AgentDirs[$i] $baseDir = Join-Path $Root $agentDir $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + if (!(Test-Path $parentGate)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if (Test-UniversalAgentDir -AgentDir $agentDir) { + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } continue } - $attempted++ - if ($Root -eq $HOME) { - $label = "~\$agentDir\$SkillName" - } else { - $label = Join-Path $Root (Join-Path $agentDir $SkillName) - } - $copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label - if ($copied) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { $installed++ } else { - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - $fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName - if ($Root -eq $HOME) { - $flabel = "~\.agents\skills\$SkillName" - } else { - $flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName) - } - if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) { - $installed++ - } else { - $failed++ + Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { $installed++ } else { $failed++ } } } if ($installed -eq 0) { @@ -889,35 +933,27 @@ function Install-MultiSkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } + $canonical = Join-Path $Root ".agents\skills" + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $agentDir = $AgentDirs[$i] $baseDir = Join-Path $Root $agentDir $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + if (!(Test-Path $parentGate)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if (Test-UniversalAgentDir -AgentDir $agentDir) { + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } continue } - $attempted++ - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") { $installed++ } else { - Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)" - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") { - $installed++ - } else { - $failed++ + Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { $installed++ } else { $failed++ } } } if ($installed -eq 0) { diff --git a/scripts/install.sh b/scripts/install.sh index 016055fe..9fd1d289 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -643,13 +643,27 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering the same Skill -# through both ~/.agents/skills and ~/.codex/skills. -retire_generic_skill_root() { +# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the +# canonical store. Universal Agents read it directly; other Agents receive +# relative links and fall back to copies only when links are unavailable. +is_universal_agent_dir() { + case "$1" in + ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + *) return 1 ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 + _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do @@ -666,6 +680,64 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +link_canonical_skills_to_base() { + _lcs_root="$1" + _lcs_base="$2" + _lcs_mode="$3" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups" + _lcs_published="$_lcs_stage/.published" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + _lcs_names="" + for _lcs_skill in "$_lcs_canonical"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + fi + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then + continue + fi + ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="$_lcs_publish_names $_lcs_name" + done + for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do + [ -d "$_lcs_victim" ] || continue + [ "$_lcs_victim" = "$_lcs_stage" ] && continue + same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue + if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then + continue + fi + if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then + restore_multi_skill_set /dev/null "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + done + for _lcs_name in $_lcs_publish_names; do + printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || { + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_base/$_lcs_name" + done + rm -rf "$_lcs_stage" +} + # Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes). # Installing mono removes proven DWS-managed multi leftovers for mutual exclusion, # mirroring `dws skill setup --mode mono`. @@ -673,20 +745,11 @@ install_skills_to_homes() { skill_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -704,50 +767,22 @@ install_skills_to_homes() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - case "$root" in - "$HOME") - label="~/$agent_dir/$SKILL_NAME" - ;; - *) - label="$root/$agent_dir/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - case "$root" in - "$HOME") - flabel="~/.agents/skills/$SKILL_NAME" - ;; - *) - flabel="$root/.agents/skills/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 @@ -783,20 +818,11 @@ install_multi_skills_to_homes() { multi_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -814,31 +840,22 @@ install_multi_skills_to_homes() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if link_canonical_skills_to_base "$root" "$base_dir" multi; then installed=$((installed + 1)) else - failed=$((failed + 1)) - say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)" + say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 71b0d8c0..dfe37168 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -90,6 +90,14 @@ HOME_SPECIFIC_SKILL_BASES=" .openclaw/skills .hermes/skills " +HOME_UNIVERSAL_SKILL_BASES=" +.cursor/skills +.gemini/skills +.codex/skills +.github/skills +.cline/skills +.amp/skills +" cleanup() { if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -118,23 +126,28 @@ verify_skill_base() { verify_skill_targets() { home_root="$1" - found_specific=0 + # lark-cli/skills-compatible canonical store is always present, regardless + # of which concrete Agent homes existed at install time. + verify_skill_base "$home_root" ".agents/skills" for base in $HOME_SPECIFIC_SKILL_BASES; do parent="${base%/skills}" - if [ -d "$home_root/$parent" ]; then - verify_skill_base "$home_root" "$base" - found_specific=1 + [ -d "$home_root/$parent" ] || continue + universal=0 + for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do + [ "$base" = "$universal_base" ] && universal=1 + done + if [ "$universal" -eq 1 ]; then + for name in dingtalk-shared dingtalk-misc dws; do + [ ! -e "$home_root/$base/$name" ] || \ + err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" + done + continue fi - done - - if [ "$found_specific" -eq 0 ]; then - verify_skill_base "$home_root" ".agents/skills" - return - fi - - for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/.agents/skills/$name" ] || \ - err "unexpected generic Skill copy found in $home_root/.agents/skills/$name" + verify_skill_base "$home_root" "$base" + for name in dingtalk-shared dingtalk-misc; do + [ -L "$home_root/$base/$name" ] || \ + err "expected canonical Skill link not found at $home_root/$base/$name" + done done } diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index b94bb679..29be067a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -193,7 +193,7 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex uses its canonical root without a generic duplicate", () => { +scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -201,6 +201,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { }); try { writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); + writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n"); writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -209,12 +210,12 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { const res = runInstall(pkg, home, "multi"); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( - fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")), - "Codex canonical Skill installed", + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "universal canonical Skill installed", ); assert.ok( - !fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), - "generic flat duplicate not installed", + !fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")), + "beta.6 Codex duplicate migrated", ); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), @@ -225,7 +226,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { } }); -scenario("ZCode uses its canonical root without a generic duplicate", () => { +scenario("ZCode links its Agent root to the canonical store", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -241,8 +242,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")), - "ZCode canonical Skill installed", + "ZCode Skill resolves through canonical link", ); + assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink()); + assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"))); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy generic duplicate retired", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d6e299a5..ac685fb5 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -182,13 +182,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } - skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md") + skillPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output)) } - genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws") - if _, err := os.Stat(genericPath); !os.IsNotExist(err) { - t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output)) + duplicatePath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws") + if _, err := os.Lstat(duplicatePath); !os.IsNotExist(err) { + t.Fatalf("universal Cursor root must not duplicate canonical Skill: Lstat(%s) = %v\noutput:\n%s", duplicatePath, err, string(output)) } } @@ -1242,7 +1242,7 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh") } -func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1277,11 +1277,11 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } for _, duplicate := range []string{ - filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"), + filepath.Join(home, ".codex", "skills", "dingtalk-chat"), filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -1292,7 +1292,7 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1328,7 +1328,13 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err) + t.Fatalf("%s linked ZCode Skill missing: %v", scriptName, err) + } + if info, err := os.Lstat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s ZCode target is not a canonical link: %#v, %v", scriptName, info, err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("%s generic duplicate remains: %v", scriptName, err) @@ -1337,7 +1343,7 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1378,15 +1384,18 @@ exit 0 if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical Codex Skill missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) { + t.Fatalf("PowerShell Codex duplicate remains: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) } } -func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellLinksZCodeRootToCanonical(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1428,7 +1437,10 @@ exit 0 t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err) + t.Fatalf("PowerShell linked ZCode Skill missing: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) From fde37f78969e7afde7ad784a538ed1c915259031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 20:21:56 +0800 Subject: [PATCH 005/267] fix(skills): complete canonical agent compatibility --- .changes/996-canonical-agent-skills.md | 5 + build/npm/install.js | 174 +++++--- docs/rfc-skill-installation-and-upgrade.md | 24 +- internal/app/skill_command.go | 131 ++++-- internal/app/skill_command_test.go | 6 +- internal/app/skill_setup.go | 298 ++++++++++++-- .../skill_setup_canonical_failures_test.go | 115 ++++++ internal/app/skill_setup_canonical_test.go | 268 ++++++++++++- internal/app/skill_setup_plan_test.go | 4 +- internal/app/skill_setup_test.go | 3 +- internal/upgrade/canonical_skills_test.go | 374 +++++++++++++++++- internal/upgrade/coverage_edges_test.go | 5 +- internal/upgrade/paths.go | 258 ++++++++++-- internal/upgrade/paths_multi_test.go | 18 +- scripts/install-devapp.ps1 | 117 +++++- scripts/install-devapp.sh | 95 ++++- scripts/install-event.sh | 109 +++-- scripts/install-skills.sh | 132 ++++--- scripts/install.ps1 | 218 ++++++++-- scripts/install.sh | 136 ++++--- scripts/release/verify-package-managers.sh | 213 +++++++--- test/scripts/agent_registry_test.go | 258 ++++++++++++ test/scripts/install_js_smoke.mjs | 60 ++- test/scripts/install_script_test.go | 169 +++++++- test/scripts/package_script_test.go | 107 ++++- 25 files changed, 2875 insertions(+), 422 deletions(-) create mode 100644 .changes/996-canonical-agent-skills.md create mode 100644 internal/app/skill_setup_canonical_failures_test.go create mode 100644 test/scripts/agent_registry_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md new file mode 100644 index 00000000..c42462e8 --- /dev/null +++ b/.changes/996-canonical-agent-skills.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. diff --git a/build/npm/install.js b/build/npm/install.js index 8a7e94d4..e43d29fc 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -8,37 +8,106 @@ const os = require("os"); const path = require("path"); const childProcess = require("child_process"); -// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. -const AGENT_DIRS = [ - ".agents/skills", - ".claude/skills", - ".cursor/skills", - ".qoder/skills", - ".qoderwork/skills", - ".gemini/skills", - ".codex/skills", - ".zcode/skills", - ".github/skills", - ".windsurf/skills", - ".augment/skills", - ".cline/skills", - ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs, +// including canonical-direct and no-global-directory entries. The resolver +// below intentionally deduplicates shared effective roots only after this +// authoritative enumeration has been classified. +const UPSTREAM_AGENTS = [ + ["aider-desk", false, ".aider-desk/skills"], + ["amp", true, ".config/agents/skills"], + ["antigravity", true, ".gemini/antigravity/skills"], + ["antigravity-cli", true, ".gemini/antigravity-cli/skills"], + ["astrbot", false, ".astrbot/data/skills"], + ["autohand-code", false, ".autohand/skills"], + ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"], + ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"], + ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"], + ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"], + ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"], + ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"], + ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"], + ["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"], + ["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"], + ["droid", false, ".factory/skills"], ["eve", false, null], + ["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"], + ["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"], + ["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"], + ["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"], + ["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"], + ["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"], + ["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"], + ["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"], + ["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"], + ["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"], + ["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"], + ["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"], + ["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"], + ["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"], + ["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"], + ["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"], + ["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"], + ["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"], + ["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"], + ["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"], + ["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"], + ["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"], + ["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"], + ["pochi", false, ".pochi/skills"], ["promptscript", true, null], + ["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"], +].map(([id, universal, agentDir]) => ({ id, universal, agentDir })); + +// Paths emitted by beta.6 or older DWS installers that do not correspond to +// the upstream registry. They are cleanup-only migration targets. +const LEGACY_UNIVERSAL_AGENT_DIRS = [ + ".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills", ]; -// Same capability split as the `skills` CLI used by lark-cli. Universal -// Agents read ~/.agents/skills directly and must not receive a second copy. -const UNIVERSAL_AGENT_DIRS = new Set([ - ".cursor/skills", - ".gemini/skills", - ".codex/skills", - ".github/skills", - ".cline/skills", - ".amp/skills", -]); +function openClawSkillsDir(homeDir) { + for (const name of [".openclaw", ".clawdbot", ".moltbot"]) { + const root = path.join(homeDir, name); + if (fs.existsSync(root)) return path.join(root, "skills"); + } + return path.join(homeDir, ".openclaw", "skills"); +} + +function resolvedAgentTargets(homeDir) { + const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config"); + const targets = [ + ...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"), + // DWS compatibility target; qoderwork is not in upstream's registry. + { id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true }, + ...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })), + ].map((target) => { + const { agentDir } = target; + let baseDir = path.join(homeDir, agentDir); + if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) { + baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills"); + } else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) { + baseDir = path.join(process.env.CODEX_HOME.trim(), "skills"); + } else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) { + baseDir = path.join(process.env.HERMES_HOME.trim(), "skills"); + } else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) { + baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills"); + } else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) { + baseDir = path.join(process.env.GROK_HOME.trim(), "skills"); + } else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) { + baseDir = path.join(process.env.VIBE_HOME.trim(), "skills"); + } else if (agentDir === ".openclaw/skills") { + baseDir = openClawSkillsDir(homeDir); + } else if (agentDir.startsWith(".config/")) { + baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length)); + } + return { ...target, baseDir }; + }); + const seen = new Set(); + return targets.filter((target) => { + let key = path.resolve(target.baseDir); + if (process.platform === "win32") key = key.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", @@ -83,9 +152,6 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren if (err && err.code === "ENOENT") return true; throw err; } - if (!info.isDirectory() && !info.isSymbolicLink()) { - return true; - } const rel = path.relative(homeDir, dir); const name = rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) @@ -279,11 +345,11 @@ function installSkillsToHomes(skillRoot) { } if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -304,9 +370,10 @@ function installSkillsToHomes(skillRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -317,6 +384,10 @@ function installSkillsToHomes(skillRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); } fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true }); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and @@ -402,7 +473,9 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { function samePhysicalDir(left, right) { try { - return fs.realpathSync(left) === fs.realpathSync(right); + const leftReal = fs.realpathSync(left); + const rightReal = fs.realpathSync(right); + return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal; } catch (_) { return false; } @@ -410,6 +483,7 @@ function samePhysicalDir(left, right) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { fs.mkdirSync(baseDir, { recursive: true }); + const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); const staged = []; const backups = []; @@ -433,7 +507,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, continue; } const stagedPath = path.join(stageRoot, name); - const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target); + const realTarget = fs.realpathSync(target); + const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); } @@ -742,11 +817,11 @@ function installMultiSkillsToHomes(multiRoot) { else failed += 1; if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -770,9 +845,10 @@ function installMultiSkillsToHomes(multiRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -783,6 +859,10 @@ function installMultiSkillsToHomes(multiRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); } writeSkillsState(homeDir, multiRoot, skills); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi) @@ -897,6 +977,8 @@ if (require.main === module) { } module.exports = { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a33bc95f..bad9f3f9 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约 | 模式 | Agent 目录布局 | 选择方式 | |---|---|---| -| multi(默认) | `/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | -| mono(兼容) | `/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | +| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` | +| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in | 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 @@ -140,10 +140,21 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器对每个 Agent 目标执行: +升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的 +Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的 +非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical +的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 +自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 +`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 +`.moltbot` 必须按 Agent 实际优先级解析。 -- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback; -- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 +non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent +解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 +Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 +`.amp/skills`、`.cline/skills` 与 +`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; @@ -192,6 +203,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。 | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | +| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 | Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 @@ -209,6 +221,8 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; +- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; +- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8db71d07..e43484f6 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -117,38 +117,114 @@ type CliSkillDTO struct { // (skill_setup.go) MUST have a matching path value here — enforced by // TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ - // `agents` is the generic-agent sentinel: install scripts and `setup` - // special-case ~/.agents/skills as a no-checks-required fallback so a - // fresh machine without any IDE/agent registry still gets skills. - "agents": ".agents/skills", - "qoder": ".qoder/skills", - "qoderwork": ".qoderwork/skills", + // Universal agents. Those without an independent global directory map + // directly to the canonical ~/.agents/skills store. + "agents": ".agents/skills", + "amp": filepath.Join(".config", "agents", "skills"), + "antigravity": ".gemini/antigravity/skills", + "antigravity-cli": ".gemini/antigravity-cli/skills", + "codex": ".codex/skills", + "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", + "firebender": ".firebender/skills", + "gemini-cli": ".gemini/skills", + "github-copilot": ".copilot/skills", + "opencode": filepath.Join(".config", "opencode", "skills"), + "replit": filepath.Join(".config", "agents", "skills"), + "universal": filepath.Join(".config", "agents", "skills"), + "cline": ".agents/skills", + "dexto": ".agents/skills", + "kimi-code-cli": ".agents/skills", + "loaf": ".agents/skills", + "warp": ".agents/skills", + "zed": ".agents/skills", + + // Non-universal agents with global Skill directories. + "aider-desk": ".aider-desk/skills", + "astrbot": ".astrbot/data/skills", + "autohand-code": ".autohand/skills", + "augment": ".augment/skills", + "bob": ".bob/skills", + "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", + "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", + "codemaker": ".codemaker/skills", + "codestudio": ".codestudio/skills", + "command-code": ".commandcode/skills", + "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", + "crush": filepath.Join(".config", "crush", "skills"), + "devin": filepath.Join(".config", "devin", "skills"), + "droid": ".factory/skills", + "forgecode": ".forge/skills", + "goose": filepath.Join(".config", "goose", "skills"), + "grok": ".grok/skills", + "hermes-agent": ".hermes/skills", + "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", + "junie": ".junie/skills", + "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", + "kimchi": filepath.Join(".config", "kimchi", "harness", "skills"), + "kiro-cli": ".kiro/skills", + "kode": ".kode/skills", + "lingma": ".lingma/skills", + "mcpjam": ".mcpjam/skills", + "minimax-code": ".minimax/skills", + "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", + "mux": ".mux/skills", + "openhands": ".openhands/skills", + "ona": ".ona/skills", + "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", + "qoder-cn": ".qoder-cn/skills", + "qwen-code": ".qwen/skills", + "reasonix": ".reasonix/skills", + "rovodev": ".rovodev/skills", + "roo": ".roo/skills", + "tabnine-cli": ".tabnine/agent/skills", + "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", + "trae": ".trae/skills", + "trae-cn": ".trae-cn/skills", + "windsurf": ".codeium/windsurf/skills", + "zcode": ".zcode/skills", + "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", + "neovate": ".neovate/skills", + "pochi": ".pochi/skills", + "adal": ".adal/skills", + + // DWS compatibility aliases and DWS-only integrations. "claude": ".claude/skills", - "cursor": ".cursor/skills", - "codex": ".codex/skills", - "zcode": ".zcode/skills", - "opencode": filepath.Join(".config", "opencode", "skills"), - // IDE / agent registries also probed by `dws skill setup --target all`. - "gemini": ".gemini/skills", - "github": ".github/skills", - "windsurf": ".windsurf/skills", - "augment": ".augment/skills", - "cline": ".cline/skills", - "amp": ".amp/skills", - "kiro": ".kiro/skills", - "trae": ".trae/skills", - "openclaw": ".openclaw/skills", - "hermes": ".hermes/skills", + "gemini": ".gemini/skills", + "github": ".copilot/skills", + "hermes": ".hermes/skills", + "kiro": ".kiro/skills", + "qoderwork": ".qoderwork/skills", +} + +// Eve has project-scoped Skill directories but no upstream globalSkillsDir. +// Keep it in the advertised enumeration while failing explicitly instead of +// pretending that a global install configured Eve. +var unsupportedGlobalAgentTargets = map[string]string{ + "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills", + "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills", } // supportedTargets returns a sorted, comma-separated list of supported // targets. Sorted so help text and error messages stay stable across runs // (Go map iteration order is intentionally randomized). func supportedTargets() string { - targets := make([]string, 0, len(agentSkillPaths)+1) + targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1) for target := range agentSkillPaths { targets = append(targets, target) } + for target := range unsupportedGlobalAgentTargets { + targets = append(targets, target) + } sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") @@ -485,8 +561,13 @@ func resolveSkillTargetPath(target string) (string, error) { return os.Getwd() } - // Look up predefined agent paths - relPath, ok := agentSkillPaths[strings.ToLower(target)] + target = strings.ToLower(target) + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return "", errors.New(reason) + } + + // Look up predefined agent paths. + _, ok := agentSkillPaths[target] if !ok { return "", fmt.Errorf("unsupported target") } @@ -496,7 +577,7 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return filepath.Join(homeDir, relPath), nil + return resolveSkillSetupBase(homeDir, target), nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 381c87bc..4fcdb38d 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -477,8 +477,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { for _, p := range agentSkillPaths { paths[p] = true } + legacyCleanupOnly := map[string]bool{ + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } for _, home := range skillSetupAgentHomes { - if !paths[home] { + if !paths[home] && !legacyCleanupOnly[home] { t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ "add it to agentSkillPaths so users can address it via --target ", home) } diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 7f5fe2fb..94c8cbf5 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -23,22 +24,77 @@ import ( // agree on the install footprint. var skillSetupAgentHomes = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } const ( @@ -65,6 +121,8 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupLstat = os.Lstat + skillSetupGetenv = os.Getenv skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd @@ -85,6 +143,7 @@ var ( skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove skillSetupNow = time.Now + skillSetupFoldPathCase = runtime.GOOS == "windows" ) type skillSetupBackup struct { @@ -153,9 +212,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 -原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, -链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。 +DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装, +无需用户手动处理,也不会让同一个 Skill 重复出现。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -248,7 +307,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } } - // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。 @@ -355,7 +413,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) - fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") + fmt.Fprintln(out, " 统一安装位置:~/.agents/skills") + fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent") + fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。") return nil } @@ -934,17 +994,85 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { return detectExistingAgentHomes(home, mode), nil } - rel, ok := agentSkillPaths[target] + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return nil, errors.New(reason) + } + _, ok := agentSkillPaths[target] if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - dest := agentHomeForMode(filepath.Join(home, rel), mode) - if filepath.Clean(dest) == filepath.Clean(canonical) { + dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode) + if sameSkillSetupPath(dest, canonical) { return []string{canonical}, nil } return []string{canonical, dest}, nil } +func resolveOpenClawSetupBase(home string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(home, name) + if info, err := skillSetupStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(home, ".openclaw", "skills") +} + +func resolveSkillSetupBase(home, target string) string { + switch target { + case "claude", "claude-code": + if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + return filepath.Join(custom, "skills") + } + case "codex": + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "hermes", "hermes-agent": + if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "autohand-code": + if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "grok": + if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "mistral-vibe": + if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "openclaw": + return resolveOpenClawSetupBase(home) + case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi": + configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(home, ".config") + } + switch target { + case "opencode": + return filepath.Join(configHome, "opencode", "skills") + case "amp", "replit", "universal": + return filepath.Join(configHome, "agents", "skills") + case "crush": + return filepath.Join(configHome, "crush", "skills") + case "devin": + return filepath.Join(configHome, "devin", "skills") + case "goose": + return filepath.Join(configHome, "goose", "skills") + case "kimchi": + return filepath.Join(configHome, "kimchi", "harness", "skills") + } + case "github", "github-copilot": + return filepath.Join(home, ".copilot", "skills") + case "windsurf": + return filepath.Join(home, ".codeium", "windsurf", "skills") + } + return filepath.Join(home, agentSkillPaths[target]) +} + // agentHomeForMode appends the mode-specific tail segment to an agent home base. func agentHomeForMode(base, mode string) string { if mode == skillSetupModeMulti { @@ -956,16 +1084,57 @@ func agentHomeForMode(base, mode string) string { func detectExistingAgentHomes(home, mode string) []string { canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) dests := []string{canonical} + canonicalKey := skillSetupPathKey(canonical) + seen := map[string]bool{canonicalKey: true} + addDetected := func(base string) { + parent := filepath.Dir(base) + if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { + return + } + dest := agentHomeForMode(base, mode) + key := skillSetupPathKey(dest) + if !seen[key] { + seen[key] = true + dests = append(dests, dest) + } + } for i, rel := range skillSetupAgentHomes { if i == 0 { continue } base := filepath.Join(home, rel) - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { - continue + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".claude/skills": + base = resolveSkillSetupBase(home, "claude-code") + case ".codex/skills": + base = resolveSkillSetupBase(home, "codex") + case ".hermes/skills": + base = resolveSkillSetupBase(home, "hermes-agent") + case ".autohand/skills": + base = resolveSkillSetupBase(home, "autohand-code") + case ".grok/skills": + base = resolveSkillSetupBase(home, "grok") + case ".vibe/skills": + base = resolveSkillSetupBase(home, "mistral-vibe") + case ".openclaw/skills": + base = resolveSkillSetupBase(home, "openclaw") + case ".config/opencode/skills": + base = resolveSkillSetupBase(home, "opencode") + case ".config/agents/skills": + base = resolveSkillSetupBase(home, "amp") + case ".config/crush/skills": + base = resolveSkillSetupBase(home, "crush") + case ".config/devin/skills": + base = resolveSkillSetupBase(home, "devin") + case ".config/goose/skills": + base = resolveSkillSetupBase(home, "goose") + case ".config/kimchi/harness/skills": + base = resolveSkillSetupBase(home, "kimchi") } - dests = append(dests, agentHomeForMode(base, mode)) + addDetected(base) + } + for _, target := range []string{"github-copilot", "windsurf"} { + addDetected(resolveSkillSetupBase(home, target)) } return dests } @@ -978,10 +1147,26 @@ func skillSetupBaseForMode(dest, mode string) string { } func isUniversalSkillSetupBase(base string) bool { - base = filepath.ToSlash(filepath.Clean(base)) + cleanBase := filepath.Clean(base) + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) { + return true + } + if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" { + if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) || + sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) { + return true + } + } + base = filepath.ToSlash(cleanBase) for rel := range map[string]bool{ - ".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, - ".github/skills": true, ".cline/skills": true, ".amp/skills": true, + ".config/agents/skills": true, ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, ".codex/skills": true, + ".cursor/skills": true, ".deepagents/agent/skills": true, + ".firebender/skills": true, ".gemini/skills": true, + ".copilot/skills": true, ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only. + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, } { if strings.HasSuffix(base, "/"+rel) { return true @@ -990,6 +1175,18 @@ func isUniversalSkillSetupBase(base string) bool { return false } +func sameSkillSetupPath(left, right string) bool { + return skillSetupPathKey(left) == skillSetupPathKey(right) +} + +func skillSetupPathKey(path string) string { + clean := filepath.Clean(path) + if skillSetupFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + func canonicalSkillSetupBase(dests []string, mode string) string { for _, dest := range dests { base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) @@ -1003,7 +1200,7 @@ func canonicalSkillSetupBase(dests []string, mode string) string { func samePhysicalSkillSetupPath(left, right string) bool { leftReal, leftErr := skillSetupEvalSymlinks(left) rightReal, rightErr := skillSetupEvalSymlinks(right) - return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) + return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { @@ -1075,7 +1272,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } sort.Strings(plan.MultiSkillNames) sortedDests := append([]string(nil), dests...) - sort.Strings(sortedDests) + sort.Slice(sortedDests, func(i, j int) bool { + leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills") + rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills") + if leftCanonical != rightCanonical { + return leftCanonical + } + return sortedDests[i] < sortedDests[j] + }) managedNames := currentManagedSkillNames() canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { @@ -1134,16 +1338,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { continue } - info, statErr := skillSetupStat(path) + _, statErr := skillSetupLstat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue } return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr) } - if info.IsDir() { - add(path, skillSetupBackupReplace) - } + add(path, skillSetupBackupReplace) } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) if !target.CleanupOnly || len(target.Backups) > 0 { @@ -1200,14 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, " · %s\n", name) } } - fmt.Fprintln(out, " destinations:") + fmt.Fprintln(out, " 安装与适配位置:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination) } else if target.LinkCanonical { - fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) + fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination) } else { - fmt.Fprintf(out, " - %s\n", target.Destination) + fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination) } } fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):") @@ -1700,6 +1902,10 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } }() + realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent) + if realParentErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr) + } stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) @@ -1708,7 +1914,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s if samePhysicalSkillSetupPath(dest, canonicalTarget) { return nil } - relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget) + realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget) + if realTargetErr != nil { + return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr) + } + relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget) if relErr != nil { return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) } @@ -1819,7 +2029,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if plan.Mode == skillSetupModeMulti { perTarget = len(plan.MultiSkillNames) } + hasCanonicalDependents := false + for _, candidate := range plan.Targets { + if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { + hasCanonicalDependents = true + break + } + } for _, target := range plan.Targets { + isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) @@ -1833,6 +2051,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install continue } if len(target.Backups) > 0 && homeErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr) + } if plan.Mode == skillSetupModeMono { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) } else { @@ -1844,12 +2065,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) if stageErr != nil && target.LinkCanonical { - fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) + fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) fallback := target fallback.LinkCanonical = false stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) } if stageErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr) + } fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget continue @@ -1859,6 +2083,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr) + } fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) skipped += perTarget continue @@ -1869,6 +2096,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr != nil { publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) + } fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) skipped += perTarget continue diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go new file mode 100644 index 00000000..c1b82a19 --- /dev/null +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -0,0 +1,115 @@ +package app + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { + t.Helper() + home := t.TempDir() + src := t.TempDir() + skill := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skill, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + dependent := filepath.Join(home, ".claude", "skills") + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + return home, plan +} + +func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) { + t.Run("home", func(t *testing.T) { + _, plan := canonicalFailurePlan(t) + canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonical, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") { + t.Fatalf("home failure = %v", err) + } + }) + + t.Run("backup", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") { + t.Fatalf("backup failure = %v", err) + } + }) + + t.Run("publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { + t.Fatalf("publish failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { + home, plan := canonicalFailurePlan(t) + canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonicalTarget, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + linked := plan.Targets[1] + + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == linked.Destination { + return "", errors.New("parent denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical parent error = %v", err) + } + }) + + t.Run("canonical-target", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == canonicalTarget { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical target error = %v", err) + } + }) + + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative path error = %v", err) + } + }) + + _ = home +} diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 83a43d93..85f823a3 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -2,14 +2,16 @@ package app import ( "bytes" + "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { +func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupAgentHomes, []string{ @@ -47,6 +49,16 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { t.Fatal(err) } + claudeSkills := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeSkills, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil { + t.Fatal(err) + } plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) if err != nil { @@ -87,3 +99,257 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { } } } + +func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { + home := t.TempDir() + customClaude := filepath.Join(t.TempDir(), "claude") + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(name string) string { + switch name { + case "CLAUDE_CONFIG_DIR": + return customClaude + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".agents", "skills"), + filepath.Join(customClaude, "skills"), + filepath.Join(customCodex, "skills"), + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".moltbot", "skills"), + filepath.Join(home, ".copilot", "skills"), + filepath.Join(home, ".config", "opencode", "skills"), + filepath.Join(home, ".config", "agents", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + found := false + for _, got := range dests { + if sameSkillSetupPath(got, want) { + found = true + break + } + } + if !found { + t.Fatalf("resolved targets %v missing %s", dests, want) + } + } + if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) { + t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets") + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + oldClaude := filepath.Join(claude, "dingtalk-chat") + if err := os.MkdirAll(oldClaude, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil { + t.Fatal(err) + } + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("canonical failure = %v", err) + } + body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md")) + if readErr != nil || string(body) != "old remains" { + t.Fatalf("dependent Claude target changed: %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") }) + + plan, err := buildSkillSetupPlan( + skillSetupModeMulti, + src, + []string{canonical, claude}, + []string{"dingtalk-chat"}, + false, + ) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("human-readable fallback message missing: %s", errOut.String()) + } + info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat")) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } +} + +func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + + expected := map[string]string{ + "aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills", + "antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills", + "astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills", + "augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills", + "codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills", + "droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills", + "gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills", + "grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills", + "kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills", + "mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills", + "openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills", + "replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills", + "roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills", + "universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills", + "zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills", + } + if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 { + t.Fatalf("upstream agent enumeration = %d, want 76", got) + } + for target, rel := range expected { + mapped, ok := agentSkillPaths[target] + if !ok || filepath.Clean(mapped) != filepath.Clean(rel) { + t.Errorf("agent %s map = %q, want %q", target, mapped, rel) + } + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) { + t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel)) + } + } + for _, target := range []string{"eve", "promptscript"} { + if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil { + t.Errorf("%s unexpectedly resolved a global setup root", target) + } + if _, err := resolveSkillTargetPath(target); err == nil { + t.Errorf("%s unexpectedly resolved a marketplace install root", target) + } + } + if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") { + t.Fatalf("supported targets omit no-global upstream agents: %s", got) + } + + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"), + "CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"), + "HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"), + "XDG_CONFIG_HOME": filepath.Join(home, "xdg"), + } + testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] }) + customCases := map[string]string{ + "autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(custom["CODEX_HOME"], "skills"), + "grok": filepath.Join(custom["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"), + "amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + "devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + "goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + "kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + "opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for target, want := range customCases { + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) { + t.Errorf("custom %s root = %q, want %q", target, got, want) + } + } + for _, target := range []string{"codex", "amp", "opencode"} { + if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) { + t.Errorf("custom %s root not classified universal", target) + } + } +} + +func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) { + testseam.Swap(t, &skillSetupFoldPathCase, true) + if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) { + t.Fatal("case-insensitive platform path normalization failed") + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index e46339f2..8330d6e9 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -170,7 +170,7 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "universal Agent") { + if !strings.Contains(preview.String(), "改用统一安装位置") { t.Fatalf("universal cleanup preview missing: %s", preview.String()) } @@ -249,11 +249,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin t.Fatal(err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") { t.Fatalf("replacement stat error = %v", err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") { t.Fatalf("stale scan error = %v", err) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 1a37371e..f5e3eb28 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets( if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { t.Fatal(err) } - stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") if err != nil { t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) } - if !strings.Contains(stdout, "重新加载 Skills") { + if !strings.Contains(stdout, "请重启已打开的 Agent") { t.Fatalf("completion should tell the user to reload skills: %s", stdout) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 163598ad..c08aa96a 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -4,12 +4,13 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { +func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{ ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", @@ -67,7 +68,77 @@ func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testi } } -func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { +func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) { + home := withFakeHome(t) + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + externalClaude := filepath.Join(t.TempDir(), "claude") + for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { + switch name { + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeBase := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeBase, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("custom CODEX_HOME duplicate remains: %v", err) + } + for _, base := range []string{ + claudeBase, + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".clawdbot", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + path := filepath.Join(base, name) + info, statErr := os.Lstat(path) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr) + } + body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), name) { + t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr) + } + } + } +} + +func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { @@ -89,3 +160,302 @@ func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { t.Fatalf("copy fallback content missing: %v", err) } } + +func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { + home := t.TempDir() + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "custom-autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"), + "CODEX_HOME": filepath.Join(home, "custom-codex"), + "GROK_HOME": filepath.Join(home, "custom-grok"), + "HERMES_HOME": filepath.Join(home, "custom-hermes"), + "VIBE_HOME": filepath.Join(home, "custom-vibe"), + "XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"), + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] }) + roots := configuredSkillRoots(home) + if len(roots) != len(knownSkillDirs) { + t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs)) + } + byLabel := make(map[string]resolvedSkillRoot, len(roots)) + for _, root := range roots { + if _, duplicate := byLabel[root.label]; duplicate { + t.Fatalf("duplicate configured label %s", root.label) + } + byLabel[root.label] = root + } + wants := map[string]string{ + ".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + ".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + ".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"), + ".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"), + ".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"), + ".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"), + ".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + ".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + ".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + ".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + ".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + ".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for label, want := range wants { + if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) { + t.Errorf("configured %s = %q, want %q", label, got, want) + } + } + for _, label := range []string{ + ".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills", + ".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills", + ".gemini/skills", ".copilot/skills", ".config/opencode/skills", + } { + if !byLabel[label].universal { + t.Errorf("%s not classified universal", label) + } + } + if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal { + t.Fatal("canonical/non-universal classification drift") + } +} + +func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) + testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) + + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + } else { + src = writeMultiBundle(t, src, "dingtalk-chat") + } + result, err := UpgradeSkillLocations(src) + if err != nil { + t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err) + } + if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("result = %#v", result) + } + if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) { + t.Fatal("canonical-only configuration unexpectedly has a dependent root") + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + dest := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("mkdir", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") { + t.Fatalf("mkdir failure = %v", err) + } + }) + t.Run("temp", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") { + t.Fatalf("temp failure = %v", err) + } + }) + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == dest { + return "", errors.New("physical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical failure = %v", err) + } + }) + t.Run("canonical-target", func(t *testing.T) { + target := filepath.Join(canonical, "dingtalk-chat") + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == target { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical failure = %v", err) + } + }) + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + makeSource := func(t *testing.T) string { + t.Helper() + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + return src + } + return writeMultiBundle(t, src, "dingtalk-chat") + } + + t.Run("undetected-and-physical-alias", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped { + t.Fatalf("skip branches = %#v", result.Results) + } + }) + + t.Run("universal-retire-failure", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if mode == "multi" { + useUpgradeManagedNames(t, "dingtalk-chat") + } + origRename := upgradeRename + testseam.Swap(t, &upgradeRename, func(src, dst string) error { + if src == victim { + return errors.New("retire denied") + } + return origRename(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("retire failure = %#v, %v", result, err) + } + }) + + t.Run("universal-retire-success", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + useUpgradeManagedNames(t, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("retire success = %#v, %v", result, err) + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Fatalf("universal victim remains: %v", err) + } + }) + + t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) { + t.Run("victim-scan", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + origReadDir := upgradeReadDir + testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) { + if path == base { + return nil, errors.New("scan denied") + } + return origReadDir(path) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("victim scan = %#v, %v", result, err) + } + }) + + t.Run("fallback-copy", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") }) + origCopy := upgradeCopyDir + testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { + if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) { + return errors.New("copy denied") + } + return origCopy(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("fallback copy = %#v, %v", result, err) + } + }) + }) + }) + } +} + +func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) { + testseam.Swap(t, &upgradeFoldPathCase, true) + if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) { + t.Fatalf("case-folded path key = %q", got) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"}) + if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 { + t.Fatalf("deduplicated roots = %#v", roots) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 9794f290..061d7d68 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { }) home := t.TempDir() upgradeUserHomeDir = func() (string, error) { return home, nil } - knownSkillDirs = []string{".agents/skills"} + knownSkillDirs = []string{".agents/skills", ".claude/skills"} + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } calls := 0 upgradeCopyDir = func(src, dst string) error { calls++ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 12d89118..c922a428 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -42,22 +42,78 @@ const ( // copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", + // DWS-only target retained for product compatibility. ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only, never publish new copies. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } // universalSkillDirs are concrete Agent directories whose current clients @@ -66,12 +122,21 @@ var knownSkillDirs = []string{ // with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally // non-universal because it is a DWS-specific integration absent upstream. var universalSkillDirs = map[string]bool{ - ".cursor/skills": true, - ".gemini/skills": true, - ".codex/skills": true, - ".github/skills": true, - ".cline/skills": true, - ".amp/skills": true, + ".config/agents/skills": true, + ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, + ".codex/skills": true, + ".cursor/skills": true, + ".deepagents/agent/skills": true, + ".firebender/skills": true, + ".gemini/skills": true, + ".copilot/skills": true, + ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only as well. + ".github/skills": true, + ".windsurf/skills": true, + ".cline/skills": true, + ".amp/skills": true, } var ( @@ -87,11 +152,14 @@ var ( upgradeStat = os.Stat upgradeLstat = os.Lstat upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv upgradeBuildProvenance = skillprovenance.Build upgradeReadSkillState = skillstate.Read upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } upgradeWriteSkillState = skillstate.Write upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -102,21 +170,19 @@ const skillBackupSubdir = ".dws/skill-backups" // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it, and returns the backup path. It is -// fail-safe: a directory that cannot be backed up is NOT removed and the +// fail-safe: a path that cannot be backed up is NOT removed and the // error is returned so the caller can surface it (and never install the -// opposite layout next to it silently). Missing paths and regular files are -// no-ops ("", nil). +// opposite layout next to it silently). Missing paths are no-ops. Directories, +// links (including dangling links), and ordinary files are all preserved: +// an unexpected file at a managed destination must never be overwritten. func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeLstat(dir) + _, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { - return "", nil - } rel, relErr := filepath.Rel(homeDir, dir) if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { rel = filepath.Base(dir) @@ -544,8 +610,87 @@ func isUniversalSkillRoot(agentDir string) bool { return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] } -func skillRootDetected(homeDir, agentDir string) bool { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) +type resolvedSkillRoot struct { + base string + label string + universal bool + canonical bool +} + +func resolveOpenClawSkillBase(homeDir string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(homeDir, name) + if info, err := upgradeStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(homeDir, ".openclaw", "skills") +} + +func configuredSkillRoots(homeDir string) []resolvedSkillRoot { + var roots []resolvedSkillRoot + seen := map[string]bool{} + configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(homeDir, ".config") + } + add := func(base, label string, universal, canonical bool) { + key := skillRootPathKey(base) + if seen[key] { + return + } + seen[key] = true + roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical}) + } + for _, agentDir := range knownSkillDirs { + base := filepath.Join(homeDir, agentDir) + switch filepath.ToSlash(filepath.Clean(agentDir)) { + case ".claude/skills": + if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".codex/skills": + if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".hermes/skills": + if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".autohand/skills": + if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".grok/skills": + if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".vibe/skills": + if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".openclaw/skills": + base = resolveOpenClawSkillBase(homeDir) + case ".config/agents/skills": + base = filepath.Join(configHome, "agents", "skills") + case ".config/opencode/skills": + base = filepath.Join(configHome, "opencode", "skills") + case ".config/crush/skills": + base = filepath.Join(configHome, "crush", "skills") + case ".config/devin/skills": + base = filepath.Join(configHome, "devin", "skills") + case ".config/goose/skills": + base = filepath.Join(configHome, "goose", "skills") + case ".config/kimchi/harness/skills": + base = filepath.Join(configHome, "kimchi", "harness", "skills") + } + add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir)) + } + return roots +} + +func skillRootDetectedBase(base string) bool { + parentGate := filepath.Dir(base) info, err := upgradeStat(parentGate) return err == nil && info.IsDir() } @@ -553,7 +698,30 @@ func skillRootDetected(homeDir, agentDir string) bool { func samePhysicalSkillRoot(left, right string) bool { leftReal, leftErr := upgradeEvalSymlinks(left) rightReal, rightErr := upgradeEvalSymlinks(right) - return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) + if leftErr != nil || rightErr != nil { + return false + } + return skillRootPathKey(leftReal) == skillRootPathKey(rightReal) +} + +func skillRootPathKey(path string) string { + clean := filepath.Clean(path) + if upgradeFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + +func hasDependentSkillRoot(homeDir, canonicalBase string) bool { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) { + continue + } + if !samePhysicalSkillRoot(root.base, canonicalBase) { + return true + } + } + return false } func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { @@ -581,9 +749,17 @@ func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) _ = upgradeRemoveAll(stageRoot) } }() + realDestBase, realErr := upgradeEvalSymlinks(destBase) + if realErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr) + } for _, name := range names { target := filepath.Join(canonicalBase, name) - relTarget, relErr := filepath.Rel(destBase, target) + realTarget, targetErr := upgradeEvalSymlinks(target) + if targetErr != nil { + return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr) + } + relTarget, relErr := upgradeRel(realDestBase, realTarget) if relErr != nil { return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) } @@ -631,21 +807,24 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e canonicalDest := filepath.Join(canonicalBase, "dws") if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + if hasDependentSkillRoot(homeDir, canonicalBase) { + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + return result, nil } result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) + destBase := root.base destDir := filepath.Join(destBase, "dws") - if isBlacklisted(agentDir) { + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -653,7 +832,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) } else { @@ -701,20 +880,23 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk canonicalBase := filepath.Join(homeDir, ".agents", "skills") if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + if hasDependentSkillRoot(homeDir, canonicalBase) { + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + return result, nil } result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) - if isBlacklisted(agentDir) { + destBase := root.base + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } @@ -722,7 +904,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) } else { diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e9abb256..e238b0ca 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -503,6 +503,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) { // mono-only package path). func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { t.Fatal(err) @@ -598,6 +601,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * // installing multi next to the stale skills. func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") staleDir := filepath.Join(agentsBase, "dingtalk-stale") if err := os.MkdirAll(staleDir, 0o755); err != nil { @@ -639,7 +645,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe -// contract of the backup helper: non-dir paths are no-ops, a colliding backup +// contract of the backup helper: all lexical paths are preserved, a colliding backup // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable // collision) leaves the original directory untouched with an error. func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { @@ -660,16 +666,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err) } - // Regular file: no-op, no backup. + // Regular file: preserve it instead of allowing a later rename to overwrite it. filePath := filepath.Join(home, "not-a-dir") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatal(err) } - if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { - t.Fatalf("regular file = (%q, %v), want no-op", got, err) + if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil { + t.Fatalf("regular file = (%q, %v), want backup", got, err) } - if _, err := os.Stat(filePath); err != nil { - t.Fatalf("regular file must survive: %v", err) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("regular file must move into backup: %v", err) } // A skill directory outside home falls back to its basename instead of diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 894e4f5e..c5fdb3b4 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -22,9 +22,91 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat $NoSkills = $env:DWS_NO_SKILLS -eq "1" $SkillName = "dingtalk-misc" +# vercel-labs/skills agents.ts (c6f69c6). Each row is +# id|universal|effective global directory; '-' means no global directory. +$AgentRegistryRows = @( + "aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills", + "antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills", + "astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills", + "bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills", + "cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills", + "codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills", + "command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills", + "crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills", + "devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-", + "firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills", + "github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills", + "hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills", + "junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills", + "kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills", + "kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills", + "minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills", + "mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills", + "ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills", + "qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills", + "rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills", + "terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills", + "trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills", + "zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills", + "zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills", + "promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills" +) +$LegacyAgentCleanupRows = @( + "dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills", + "dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills", + "dws-legacy-windsurf|1|.windsurf\skills" +) + function Say($m) { Write-Host " $m" } function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } +function Resolve-AgentBase([string]$row) { + $parts = $row.Split('|') + $id = $parts[0] + if ($parts[2] -eq "-") { return $null } + switch ($id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } } + "codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } } + "grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } } + "hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } } + "mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } } + "openclaw" { + foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $HOME $legacy + if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" } + } + } + { $_ -in @("amp", "replit", "universal") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + return Join-Path $xdg "agents\skills" + } + { $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + $child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } } + return Join-Path $xdg $child + } + } + return Join-Path $HOME $parts[2] +} + +function Test-PathLexically([string]$path) { + try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} + $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf + if (!(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) +} + +function Backup-DevSkill([string]$path) { + if (!(Test-PathLexically $path)) { return } + $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" + New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null + $target = Join-Path $backupRoot (Split-Path $path -Leaf) + while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } + Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop +} + function Get-Arch { if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } try { @@ -99,22 +181,27 @@ if (-not $NoSkills) { New-Item -ItemType Directory -Path $cache -Force | Out-Null Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force - $agentDirs = @( - ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", - ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", - ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", - ".hermes\skills", ".config\opencode\skills" - ) - $installed = 0; $idx = 0 - foreach ($d in $agentDirs) { - $base = Join-Path $HOME $d - $parent = Split-Path $base -Parent - if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $canonicalBase = Join-Path $HOME ".agents\skills" + $canonical = Join-Path $canonicalBase $SkillName + Backup-DevSkill $canonical + New-Item -ItemType Directory -Path $canonical -Force | Out-Null + Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + $installed = 1 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { + $parts = $row.Split('|'); $universal = $parts[1] -eq "1" + $base = Resolve-AgentBase $row + if ([string]::IsNullOrWhiteSpace($base)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) + if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } + if (-not (Test-Path (Split-Path $base -Parent))) { continue } $dest = Join-Path $base $SkillName - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - New-Item -ItemType Directory -Path $dest -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force - $installed++; $idx++ + if ($universal) { Backup-DevSkill $dest; continue } + New-Item -ItemType Directory -Path $base -Force | Out-Null + Backup-DevSkill $dest + try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null } + catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force } + $installed++ } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 1e7c5d7f..4a360638 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,80 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +copy_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage" "$dest"; then return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 + while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"; done + mkdir -p "$backup_root" || return 1 + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + need_cmd curl detect_os() { @@ -83,17 +157,16 @@ install_skill() { cache="$HOME/.dws/skills/multi/$SKILL_NAME" rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" - installed=0; idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi - dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" - installed=$((installed + 1)); idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$SKILL_NAME" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 + installed=$((installed + 1)) done say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 3402a333..a45870e7 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -92,37 +92,98 @@ resolve_event_version() { copy_tree() { src="$1" dest="$2" - rm -rf "$dest" - mkdir -p "$dest" - cp -R "$src/." "$dest/" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage" "$dest"; then return 0; fi + rm -rf "$stage" + [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp" + backup="$backup_root/$name" + i=0 + while [ -e "$backup" ] || [ -L "$backup" ]; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + done + mkdir -p "$backup_root" || return 1 + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 } install_skill_to_homes() { src="$1" skill_name="$2" - - installed=0 - idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then - idx=$((idx + 1)) - continue - fi - copy_tree "$src" "$base/$skill_name" + canonical="$HOME/.agents/skills/$skill_name" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 installed=$((installed + 1)) - idx=$((idx + 1)) done - - if [ "$installed" -eq 0 ]; then - copy_tree "$src" "$HOME/.agents/skills/$skill_name" - installed=1 - fi printf '%s\n' "$installed" } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 97a4b093..563582ec 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -53,13 +53,13 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" _bed_target="$_bed_root/$_bed_stamp/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then @@ -206,7 +206,7 @@ restore_multi_skill_set() { if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi @@ -413,11 +413,62 @@ multi_tree_has_skills() { # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in - ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; *) return 1 ;; esac } +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +agent_skill_dirs() { + printf '%s\n' \ + ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ + ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ + ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ + ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ + ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ + ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ + ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ + ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ + ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ + ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ + ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ + ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ + ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ + ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ + ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ + ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ + ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -432,7 +483,7 @@ retire_agent_skill_root() { _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -450,6 +501,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } @@ -466,11 +518,13 @@ link_canonical_skills_to_base() { _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi - ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null && pwd)" || return 1 @@ -667,7 +722,7 @@ retire_agent_skill_root() { _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -687,6 +742,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" _lcs_published="$_lcs_stage/.published" @@ -706,11 +762,13 @@ link_canonical_skills_to_base() { if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue fi - ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -111,11 +153,41 @@ trap cleanup EXIT INT TERM seed_specific_agent_homes() { home_root="$1" - for parent in $HOME_SPECIFIC_AGENT_PARENTS; do - mkdir -p "$home_root/$parent" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + mkdir -p "${base%/skills}" done } +resolve_agent_base() { + home_root="$1"; id="$2"; default_path="$3" + case "$id" in + autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;; + claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;; + codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;; + grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;; + hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;; + mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;; + amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;; + crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;; + openclaw) + for alias in .openclaw .clawdbot .moltbot; do + if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi + done + printf '%s\n' "$home_root/.openclaw/skills" + ;; + *) printf '%s\n' "$home_root/$default_path" ;; + esac +} + verify_skill_base() { home_root="$1" base="$2" @@ -124,30 +196,50 @@ verify_skill_base() { [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" } +verify_compatible_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + target="$base/$name" + if [ -L "$target" ]; then + linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" + canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" + [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" + elif [ -d "$target" ]; then + diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target" + else + err "canonical Skill compatibility target missing at $target" + fi + done + [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" +} + verify_skill_targets() { home_root="$1" # lark-cli/skills-compatible canonical store is always present, regardless # of which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" - for base in $HOME_SPECIFIC_SKILL_BASES; do - parent="${base%/skills}" - [ -d "$home_root/$parent" ] || continue - universal=0 - for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do - [ "$base" = "$universal_base" ] && universal=1 - done + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + [ "$base" != "$home_root/.agents/skills" ] || continue + [ -d "${base%/skills}" ] || continue if [ "$universal" -eq 1 ]; then for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/$base/$name" ] || \ - err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" done continue fi - verify_skill_base "$home_root" "$base" - for name in dingtalk-shared dingtalk-misc; do - [ -L "$home_root/$base/$name" ] || \ - err "expected canonical Skill link not found at $home_root/$base/$name" - done + verify_compatible_skill_base "$home_root" "$base" done } @@ -189,6 +281,7 @@ verify_npm() { need_cmd node need_cmd tar need_cmd unzip + need_cmd diff need_file "$NPM_STAGE_DIR/package.json" tarball_name="$( diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go new file mode 100644 index 00000000..35a647b0 --- /dev/null +++ b/test/scripts/agent_registry_test.go @@ -0,0 +1,258 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +type packagedAgent struct { + universal bool + path string +} + +var upstreamAgentIDs = strings.Fields(` +aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob +claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex +command-code continue cortex crush cursor deepagents devin dexto droid eve +firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh +jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam +minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn +qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn +warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal +`) + +func TestAgentRegistriesMatchUpstream76(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1")) + verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh")) + + wantIDs := append([]string(nil), upstreamAgentIDs...) + sort.Strings(wantIDs) + registries := map[string]map[string]packagedAgent{ + "install.ps1": parseInstallPowerShellRegistry(t, installPS), + "install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")), + "verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")), + } + + for name, registry := range registries { + gotIDs := make([]string, 0, len(registry)) + universal := 0 + for id, agent := range registry { + gotIDs = append(gotIDs, id) + if agent.universal { + universal++ + } + } + sort.Strings(gotIDs) + if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") { + t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs) + } + if universal != 19 || len(registry)-universal != 57 { + t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal) + } + } + + canonical := registries["install.ps1"] + for name, got := range registries { + for id, want := range canonical { + if got[id] != want { + t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want) + } + } + } + + for id, wantPath := range map[string]string{ + "amp": ".config/agents/skills", + "github-copilot": ".copilot/skills", + "opencode": ".config/opencode/skills", + "windsurf": ".codeium/windsurf/skills", + "cline": ".agents/skills", + "eve": "-", + "promptscript": "-", + } { + if got := canonical[id].path; got != wantPath { + t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath) + } + } +} + +func TestDWSCompatibilityAgentTargets(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as") + if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) { + t.Error("DWS Qoderwork compatibility target must remain non-universal") + } + for _, id := range []string{ + "dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf", + } { + if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) { + t.Errorf("legacy cleanup registry missing cleanup-only target %s", id) + } + } + for _, id := range upstreamAgentIDs { + if strings.HasPrefix(id, "dws-") { + t.Fatalf("DWS compatibility target %s leaked into upstream registry", id) + } + } +} + +func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) { + t.Parallel() + installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1")) + for _, want := range []string{ + `$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`, + `New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`, + `Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`, + `Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`, + } { + if !strings.Contains(installPS, want) { + t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want) + } + } +} + +func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + cut := strings.LastIndex(installPS, "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + home := t.TempDir() + custom := filepath.Join(t.TempDir(), "custom roots") + xdg := filepath.Join(custom, "xdg") + clawdbot := filepath.Join(home, ".clawdbot") + if err := os.MkdirAll(clawdbot, 0o755); err != nil { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode") +foreach ($id in $ids) { + $agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent)) +} +exit 0 +` + harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1") + mustWriteFile(t, harness, []byte(prefix), 0o644) + + envRoots := map[string]string{ + "AUTOHAND_HOME": filepath.Join(custom, "autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"), + "CODEX_HOME": filepath.Join(custom, "codex"), + "GROK_HOME": filepath.Join(custom, "grok"), + "HERMES_HOME": filepath.Join(custom, "hermes"), + "VIBE_HOME": filepath.Join(custom, "vibe"), + "XDG_CONFIG_HOME": xdg, + } + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + for key, value := range envRoots { + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output) + } + want := map[string]string{ + "amp": filepath.Join(xdg, "agents", "skills"), + "autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(envRoots["CODEX_HOME"], "skills"), + "grok": filepath.Join(envRoots["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"), + "openclaw": filepath.Join(clawdbot, "skills"), + "goose": filepath.Join(xdg, "goose", "skills"), + "kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"), + "opencode": filepath.Join(xdg, "opencode", "skills"), + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "|", 2) + if len(parts) == 2 { + got[parts[0]] = filepath.Clean(parts[1]) + } + } + for id, path := range want { + if got[id] != filepath.Clean(path) { + t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output) + } + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(b) +} + +func between(t *testing.T, text, start, end string) string { + t.Helper() + i := strings.Index(text, start) + if i < 0 { + t.Fatalf("start marker %q missing", start) + } + i += len(start) + j := strings.Index(text[i:], end) + if j < 0 { + t.Fatalf("end marker %q missing", end) + } + return text[i : i+j] +} + +func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent { + t.Helper() + block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets") + re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if path == "" { + path = "-" + } + result[match[1]] = packagedAgent{universal: match[2] == "true", path: path} + } + return result +} + +func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent { + t.Helper() + re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if _, exists := result[match[1]]; exists { + t.Fatalf("duplicate agent %q in pipe registry", match[1]) + } + result[match[1]] = packagedAgent{universal: match[2] == "1", path: path} + } + return result +} diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 29be067a..945a8e61 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -62,10 +62,20 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), const installJsSource = path.join(repoRoot, "build", "npm", "install.js"); const require = createRequire(import.meta.url); const { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = require(installJsSource); + +assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned"); +assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); +assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -133,8 +143,8 @@ function stagePkg(zipEntries, emptyDirs = []) { return { tmp, pkg, home: path.join(tmp, "home") }; } -function runInstall(pkg, home, skillMode) { - const env = { ...process.env, HOME: home }; +function runInstall(pkg, home, skillMode, extraEnv = {}) { + const env = { ...process.env, HOME: home, ...extraEnv }; if (skillMode !== undefined) { env.DWS_SKILL_MODE = skillMode; } else { @@ -255,6 +265,52 @@ scenario("ZCode links its Agent root to the canonical store", () => { } }); +scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const xdg = path.join(tmp, "xdg config"); + const autohand = path.join(tmp, "autohand home"); + const codex = path.join(tmp, "codex home"); + try { + writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); + writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); + writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); + writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(codex, "config.toml"), "model=test\n"); + writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); + writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: xdg, + AUTOHAND_HOME: autohand, + CODEX_HOME: codex, + }); + assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); + + for (const linked of [ + path.join(xdg, "goose", "skills", "dingtalk-chat"), + path.join(autohand, "skills", "dingtalk-chat"), + path.join(home, ".qoderwork", "skills", "dingtalk-chat"), + ]) { + assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); + assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + } + for (const retired of [ + path.join(xdg, "agents", "skills", "dingtalk-chat"), + path.join(codex, "skills", "dingtalk-chat"), + path.join(home, ".amp", "skills", "dingtalk-chat"), + ]) { + assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => { const { tmp, pkg, home } = stagePkg({ "SKILL.md": "# mono root copy\n", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index ac685fb5..5ab08442 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -381,17 +381,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { } expectedSkills := map[string]string{ - ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", - ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", - ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", + ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for _, duplicate := range []string{ + filepath.Join(fakeHome, ".codex", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".config", "opencode", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".codex", "skills", "dws"), + } { + if _, err := os.Lstat(duplicate); !os.IsNotExist(err) { + t.Fatalf("universal Agent duplicate remains at %s: %v", duplicate, err) + } } for rel, marker := range expectedSkills { p := filepath.Join(fakeHome, filepath.FromSlash(rel)) @@ -1343,6 +1349,147 @@ func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { } } +func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + tmp := t.TempDir() + xdg := filepath.Join(tmp, "xdg config") + autohand := filepath.Join(tmp, "autohand home") + codex := filepath.Join(tmp, "codex home") + source := filepath.Join(tmp, "multi") + mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "goose", "config.yaml"), []byte("enabled: true\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("old amp copy\n"), 0o644) + mustWriteFile(t, filepath.Join(autohand, "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "config.toml"), []byte("model=test\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "skills", "dingtalk-chat", "SKILL.md"), []byte("old codex copy\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".qoderwork", "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), []byte("old DWS path\n"), 0o644) + + installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"` + if scriptName == "install-skills.sh" { + installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"` + } + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +`+installCall+` +`) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "XDG_CONFIG_HOME="+xdg, + "AUTOHAND_HOME="+autohand, + "CODEX_HOME="+codex, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s upstream-root harness failed: %v\n%s", scriptName, err, output) + } + + for _, linked := range []string{ + filepath.Join(xdg, "goose", "skills", "dingtalk-chat"), + filepath.Join(autohand, "skills", "dingtalk-chat"), + filepath.Join(home, ".qoderwork", "skills", "dingtalk-chat"), + } { + info, err := os.Lstat(linked) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a canonical symlink: %#v, %v", linked, info, err) + } + target, err := os.Readlink(linked) + if err != nil || filepath.IsAbs(target) { + t.Fatalf("%s link must be relative: target=%q err=%v", linked, target, err) + } + } + for _, retired := range []string{ + filepath.Join(xdg, "agents", "skills", "dingtalk-chat"), + filepath.Join(codex, "skills", "dingtalk-chat"), + filepath.Join(home, ".amp", "skills", "dingtalk-chat"), + } { + if _, err := os.Lstat(retired); !os.IsNotExist(err) { + t.Fatalf("%s universal/legacy duplicate remains: %v", retired, err) + } + } + }) + } +} + +func TestInstallerShellPinsCompleteUpstreamAgentRegistry(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +upstream_agent_registry +`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s registry failed: %v\n%s", scriptName, err, output) + } + lines := strings.Fields(strings.TrimSpace(string(output))) + if len(lines) != 76 { + t.Fatalf("%s registry has %d Agents, want 76", scriptName, len(lines)) + } + ids := map[string]bool{} + roots := map[string]bool{} + universal, nonUniversal, noGlobal, canonicalDirect := 0, 0, 0, 0 + for _, line := range lines { + parts := strings.Split(line, "|") + if len(parts) != 3 || ids[parts[0]] { + t.Fatalf("%s invalid or duplicate registry record %q", scriptName, line) + } + ids[parts[0]] = true + switch parts[1] { + case "U": + universal++ + case "N": + nonUniversal++ + default: + t.Fatalf("%s invalid classification in %q", scriptName, line) + } + switch parts[2] { + case "-": + noGlobal++ + case ".agents/skills": + canonicalDirect++ + default: + roots[parts[2]] = true + } + } + if universal != 19 || nonUniversal != 57 || noGlobal != 2 || canonicalDirect != 6 || len(roots) != 65 { + t.Fatalf("%s registry classification U=%d N=%d no-global=%d canonical=%d roots=%d", scriptName, universal, nonUniversal, noGlobal, canonicalDirect, len(roots)) + } + }) + } +} + func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 62b51c6c..f0f640f8 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{ ".augment/skills/dingtalk-shared", ".cline/skills/dingtalk-shared", ".amp/skills/dingtalk-shared", + ".copilot/skills/dingtalk-shared", + ".config/opencode/skills/dingtalk-shared", + ".config/agents/skills/dingtalk-shared", + ".codeium/windsurf/skills/dingtalk-shared", ".kiro/skills/dingtalk-shared", ".trae/skills/dingtalk-shared", ".openclaw/skills/dingtalk-shared", @@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") } for _, want := range []string{ - "HOME_SPECIFIC_SKILL_BASES=", - `$base/dingtalk-shared/SKILL.md`, - `$base/dingtalk-misc/SKILL.md`, + "UPSTREAM_AGENT_REGISTRY=", + "verify_compatible_skill_base", + `diff -qr "$canonical/$name" "$target"`, + "broken canonical Skill link", + "Skill link does not resolve to canonical source", + "Skill copy fallback differs from canonical source", "unexpected mono Skill layout", `verify_npm_install "$tarball_path" "specific-agent-roots"`, `verify_npm_install "$tarball_path" "generic-fallback"`, @@ -140,6 +147,100 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } } +func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + text := string(data) + cut := strings.Index(text, "\nverify_npm_install()") + if cut < 0 { + t.Fatal("verify-package-managers.sh helper boundary missing") + } + library := filepath.Join(t.TempDir(), "verifier-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + for _, tc := range []struct { + name string + prepare func(t *testing.T, home, base string) + wantOK bool + }{ + { + name: "correct-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: true, + }, + { + name: "complete-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + }, + wantOK: true, + }, + { + name: "wrong-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + wrong := filepath.Join(home, "wrong", "dingtalk-shared") + mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "broken-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "different-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644) + }, + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".claude", "skills") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + tc.prepare(t, home, base) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base) + output, err := cmd.CombinedOutput() + if tc.wantOK && err != nil { + t.Fatalf("verifier rejected valid target: %v\n%s", err, output) + } + if !tc.wantOK && err == nil { + t.Fatalf("verifier accepted invalid target\n%s", output) + } + }) + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) From de8df0fa6fc9dae830373dd9bf8b1dd6b917ee5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 21:22:57 +0800 Subject: [PATCH 006/267] fix(skills): harden canonical agent installation --- README.md | 4 +- build/npm/install.js | 20 ++- docs/rfc-skill-installation-and-upgrade.md | 9 +- internal/app/skill_command.go | 25 +++- internal/app/skill_command_test.go | 35 ++++- internal/app/skill_setup.go | 122 +++++++++++++++--- internal/app/skill_setup_canonical_test.go | 36 ++++++ ...ll_setup_event_migration_universal_test.go | 87 +++++++++++++ .../app/skill_setup_full_coverage_test.go | 20 ++- internal/upgrade/canonical_skills_test.go | 42 ++++++ internal/upgrade/paths.go | 35 ++++- internal/upgrade/paths_multi_test.go | 10 ++ scripts/install-devapp.ps1 | 13 +- scripts/install-devapp.sh | 12 +- scripts/install-event.sh | 12 +- scripts/install-skills.sh | 20 ++- scripts/install.ps1 | 18 ++- scripts/install.sh | 20 ++- scripts/release/verify-package-managers.sh | 34 ++++- test/scripts/agent_registry_test.go | 21 +++ test/scripts/install_js_smoke.mjs | 42 +++++- test/scripts/package_script_test.go | 9 +- 22 files changed, 571 insertions(+), 75 deletions(-) create mode 100644 internal/app/skill_setup_event_migration_universal_test.go diff --git a/README.md b/README.md index ada28247..ece03923 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index e43d29fc..f091b4a2 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -109,6 +109,21 @@ function resolvedAgentTargets(homeDir) { }); } +function agentTargetDetected(target) { + const parent = path.dirname(target.baseDir); + switch (target.id) { + case "kimchi": + case "tabnine-cli": + return fs.existsSync(path.dirname(parent)); + case "zcode": + return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app"); + case "minimax-code": + return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app"); + default: + return fs.existsSync(parent); + } +} + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -347,7 +362,7 @@ function installSkillsToHomes(skillRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -819,7 +834,7 @@ function installMultiSkillsToHomes(multiRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -979,6 +994,7 @@ if (require.main === module) { module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index bad9f3f9..0f530c5d 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -140,21 +140,24 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的 -Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的 +升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 +universal 的 Agent 不再保留 Agent 私有副本;检测到的 非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical 的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 `GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 `.moltbot` 必须按 Agent 实际优先级解析。 -Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交 `c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent 解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 `.amp/skills`、`.cline/skills` 与 `.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 +对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过 +Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native +路径,不作为 universal symlink 模式的发布目标。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index e43484f6..e0087209 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -258,11 +258,28 @@ func formatAgentSkillPathsForHelp() string { sort.Strings(names) var b strings.Builder for _, n := range names { - fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + installPath := agentSkillPaths[n] + if isUniversalSkillInstallTarget(n) { + installPath = ".agents/skills" + } + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath) } return b.String() } +// Universal Agents discover the shared ~/.agents/skills store directly. A +// marketplace install addressed to one of those Agent IDs must therefore +// publish to canonical instead of recreating an Agent-private duplicate. +func isUniversalSkillInstallTarget(target string) bool { + rel, ok := agentSkillPaths[target] + if !ok { + return false + } + base := filepath.Join("__home__", rel) + canonical := filepath.Join("__home__", ".agents", "skills") + return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base) +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -577,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return resolveSkillSetupBase(homeDir, target), nil + destination := resolveSkillSetupBase(homeDir, target) + if isUniversalSkillInstallTarget(target) { + destination = filepath.Join(homeDir, ".agents", "skills") + } + return destination, nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 4fcdb38d..20288bae 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -28,6 +28,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestResolveSkillTargetPath(t *testing.T) { @@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) { { name: "cursor target", target: "cursor", - wantSuffix: filepath.Join(".cursor", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "codex target", target: "codex", - wantSuffix: filepath.Join(".codex", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "opencode target", target: "opencode", - wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { @@ -118,6 +119,34 @@ func TestResolveSkillTargetPath(t *testing.T) { } } +func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + for _, target := range []string{ + "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", + "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", + "github-copilot", "kimi-code-cli", "loaf", "opencode", "replit", + "universal", "warp", "zed", + } { + got, err := resolveSkillTargetPath(target) + if err != nil { + t.Fatalf("resolve %s: %v", target, err) + } + if want := filepath.Join(home, ".agents", "skills"); got != want { + t.Errorf("resolve %s = %s, want canonical %s", target, got, want) + } + } + for target, want := range map[string]string{ + "claude": filepath.Join(home, ".claude", "skills"), + "qoder": filepath.Join(home, ".qoder", "skills"), + "zcode": filepath.Join(home, ".zcode", "skills"), + } { + if got, err := resolveSkillTargetPath(target); err != nil || got != want { + t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want) + } + } +} + func TestResolveSkillTargetPathCurrentDir(t *testing.T) { // Test "." target returns current working directory cwd, err := os.Getwd() diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 94c8cbf5..ef728093 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,6 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir + skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupMkdirAll = os.MkdirAll @@ -379,6 +380,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 { + retiredNames := append([]string(nil), multiSkillNames...) + if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) { + retiredNames = append(retiredNames, multiMiscSkill) + } + if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { + return retireErr + } + } if skipped > 0 { return fmt.Errorf( "Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill", @@ -1086,9 +1096,32 @@ func detectExistingAgentHomes(home, mode string) []string { dests := []string{canonical} canonicalKey := skillSetupPathKey(canonical) seen := map[string]bool{canonicalKey: true} - addDetected := func(base string) { - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { + systemHome, _ := skillSetupSystemHomeDir() + allowSystemApps := sameSkillSetupPath(home, systemHome) + addDetected := func(rel, base string) { + detectedDir := filepath.Dir(base) + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".config/kimchi/harness/skills", ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + } + if detectedDir != "" { + if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() { + return + } + } + if base == "" { return } dest := agentHomeForMode(base, mode) @@ -1131,10 +1164,10 @@ func detectExistingAgentHomes(home, mode string) []string { case ".config/kimchi/harness/skills": base = resolveSkillSetupBase(home, "kimchi") } - addDetected(base) + addDetected(rel, base) } for _, target := range []string{"github-copilot", "windsurf"} { - addDetected(resolveSkillSetupBase(home, target)) + addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target)) } return dests } @@ -1394,6 +1427,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins } } +func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error { + home, err := skillSetupUserHomeDir() + if err != nil { + return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err) + } + seen := map[string]bool{} + var victims []skillSetupBackup + for _, target := range targets { + if !isUniversalSkillSetupBase(target) { + continue + } + for _, name := range names { + path := filepath.Join(target, name) + if seen[path] { + continue + } + seen[path] = true + victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + } + sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path }) + if _, err := backupSkillSetupTarget(home, victims, out); err != nil { + return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err) + } + return nil +} + func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source) if plan.Mode == skillSetupModeMulti { @@ -1580,8 +1640,12 @@ func installMultiSkillsWithEventMigration( } migrationSet := make(map[string]struct{}, len(migrationTargets)) + physicalMigrationTargets := make([]string, 0, len(migrationTargets)) for _, dest := range migrationTargets { migrationSet[dest] = struct{}{} + if !isUniversalSkillSetupBase(dest) { + physicalMigrationTargets = append(physicalMigrationTargets, dest) + } } var ordinaryTargets []string for _, dest := range dests { @@ -1590,23 +1654,47 @@ func installMultiSkillsWithEventMigration( } } - if len(ordinaryTargets) > 0 { + var canonicalTargets, otherOrdinaryTargets []string + for _, dest := range ordinaryTargets { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti))) + if strings.HasSuffix(base, "/.agents/skills") { + canonicalTargets = append(canonicalTargets, dest) + } else { + otherOrdinaryTargets = append(otherOrdinaryTargets, dest) + } + } + installOrdinary := func(names, targets []string) error { + if len(targets) == 0 { + return nil + } var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered) + n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered) installed += n skipped += nSkipped if err != nil { - return installed, skipped, err + return err } if nSkipped > 0 { - return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) + return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) } + return nil + } + canonicalNames := append([]string(nil), skillNames...) + if !containsSkillName(canonicalNames, multiMiscSkill) { + canonicalNames = append(canonicalNames, multiMiscSkill) + sort.Strings(canonicalNames) + } + if err := installOrdinary(canonicalNames, canonicalTargets); err != nil { + return installed, skipped, err + } + if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil { + return installed, skipped, err } // The folded pair is excluded from the ordinary best-effort installer. All // other selected skills (especially dingtalk-shared) must succeed before the // old Event route is touched. - for _, dest := range migrationTargets { + for _, dest := range physicalMigrationTargets { if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil { return installed, skipped + len(skillNames), cleanupErr } @@ -1617,9 +1705,9 @@ func installMultiSkillsWithEventMigration( prerequisiteNames = append(prerequisiteNames, name) } } - if len(prerequisiteNames) > 0 { + if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 { var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true) + n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true) installed += n skipped += nSkipped if err != nil { @@ -1630,7 +1718,7 @@ func installMultiSkillsWithEventMigration( } } - migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut) + migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut) installed += migrated if migrationErr != nil { return installed, skipped, migrationErr @@ -2025,10 +2113,6 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() - perTarget := 1 - if plan.Mode == skillSetupModeMulti { - perTarget = len(plan.MultiSkillNames) - } hasCanonicalDependents := false for _, candidate := range plan.Targets { if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { @@ -2037,6 +2121,10 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } } for _, target := range plan.Targets { + perTarget := 1 + if plan.Mode == skillSetupModeMulti { + perTarget = len(plan.MultiSkillNames) + } isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 85f823a3..45f9aff4 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -100,6 +100,42 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * } } +func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := skillSetupStat + testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + dests := detectExistingAgentHomes(home, skillSetupModeMulti) + for _, target := range []string{ + filepath.Join(home, ".config", "kimchi", "harness", "skills"), + filepath.Join(home, ".tabnine", "agent", "skills"), + filepath.Join(home, ".zcode", "skills"), + filepath.Join(home, ".minimax", "skills"), + } { + if !containsSkillName(dests, target) { + t.Errorf("detected targets %v missing %s", dests, target) + } + } +} + func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { home := t.TempDir() customClaude := filepath.Join(t.TempDir(), "claude") diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go new file mode 100644 index 00000000..7800bb74 --- /dev/null +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies +// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal +// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the +// Event/misc migration must not leave physical duplicates there. Universal +// homes read ~/.agents/skills directly, so their old folded copies are retired +// (backed up) and the split standalone event + clean misc land in canonical. +func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + // beta.6 physical copies alongside the folded misc. + for _, name := range []string{multiEventSkill, multiSharedSkill} { + if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + canonical := filepath.Join(home, ".agents", "skills") + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + + // P0: the universal home must not retain any physical dingtalk-* copy. + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) { + t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err) + } + } + // canonical owns the new standalone event + clean misc (+ shared). + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical missing %s: %v", name, err) + } + } + if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil { + t.Fatalf("canonical misc still contains folded Event content: %v", err) + } + if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil { + t.Fatalf("idempotent rerun failed: %v", err) + } +} + +func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + canonical := filepath.Join(home, ".agents", "skills") + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + writeOldStandaloneEvent(t, codexSkills) + chat := filepath.Join(codexSkills, "dingtalk-chat") + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil { + t.Fatal(err) + } + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"}) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes") + if err != nil { + t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if strings.Contains(stdout, "Event 原子迁移") { + t.Fatalf("unrelated selection migrated Event/misc: %s", stdout) + } + assertOldEventMiscPair(t, codexSkills) + if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" { + t.Fatalf("unselected chat changed: body=%q err=%v", body, err) + } +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index 02da441f..b5e38e9c 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -523,15 +523,25 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) - t.Run("ordinary and prerequisite install errors", func(t *testing.T) { + t.Run("ordinary install error", func(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill}) + copyCalls := 0 + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail }) + migration := filepath.Join(t.TempDir(), "migration") + ordinary := filepath.Join(t.TempDir(), "ordinary") + if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") { + t.Fatalf("ordinary install failure = %v", err) + } + if copyCalls == 0 { + t.Fatal("ordinary staging failure seam was not exercised") + } + }) + + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }) migration := filepath.Join(t.TempDir(), "migration") - ordinary := filepath.Join(t.TempDir(), "ordinary") - if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { - t.Fatalf("ordinary install failure = %v", err) - } if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { t.Fatalf("prerequisite install failure = %v", err) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index c08aa96a..79c0bbbe 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -161,6 +161,48 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) } } +func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := upgradeStat + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + want := map[string]bool{ + ".config/kimchi/harness/skills": false, + ".tabnine/agent/skills": false, + ".zcode/skills": false, + ".minimax/skills": false, + } + for _, root := range configuredSkillRoots(home) { + label := filepath.ToSlash(root.label) + if _, ok := want[label]; ok { + want[label] = skillRootDetectedBase(home, root) + } + } + for label, detected := range want { + if !detected { + t.Errorf("root %s was not detected", label) + } + } +} + func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { home := t.TempDir() custom := map[string]string{ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index c922a428..644b3f96 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -36,7 +36,7 @@ const ( // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // // The first entry (.agents/skills) is the canonical global Skill directory, -// matching the mechanism used by lark-cli's upstream `skills` installer. +// following the universal .agents/skills convention. // Agents that understand the universal .agents convention read it directly; // detected non-universal Agents receive relative links to this canonical // copy (with a direct-copy fallback when links are unavailable). @@ -141,6 +141,7 @@ var universalSkillDirs = map[string]bool{ var ( upgradeUserHomeDir = os.UserHomeDir + upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir @@ -689,9 +690,29 @@ func configuredSkillRoots(homeDir string) []resolvedSkillRoot { return roots } -func skillRootDetectedBase(base string) bool { - parentGate := filepath.Dir(base) - info, err := upgradeStat(parentGate) +func skillRootDetectedBase(homeDir string, root resolvedSkillRoot) bool { + detectedDir := filepath.Dir(root.base) + systemHome, _ := upgradeSystemHomeDir() + allowSystemApps := skillRootPathKey(homeDir) == skillRootPathKey(systemHome) + switch filepath.ToSlash(filepath.Clean(root.label)) { + case ".config/kimchi/harness/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true + } + } + } + info, err := upgradeStat(detectedDir) return err == nil && info.IsDir() } @@ -714,7 +735,7 @@ func skillRootPathKey(path string) string { func hasDependentSkillRoot(homeDir, canonicalBase string) bool { for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(homeDir, root) { continue } if !samePhysicalSkillRoot(root.base, canonicalBase) { @@ -824,7 +845,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -896,7 +917,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e238b0ca..dfee1cae 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -22,6 +22,16 @@ func withFakeHome(t *testing.T) string { t.Helper() home := t.TempDir() testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil }) + // Host-installed macOS apps must not make a fake HOME appear to have extra + // detected Agents. Tests that exercise application-based detection can + // override this seam explicitly. + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + applications := filepath.Join(string(filepath.Separator), "Applications") + if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") { + return nil, os.ErrNotExist + } + return os.Stat(path) + }) return home } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index c5fdb3b4..2ed13bee 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -90,6 +90,17 @@ function Resolve-AgentBase([string]$row) { return Join-Path $HOME $parts[2] } +function Test-AgentBaseDetected([string]$id, [string]$base) { + $parent = Split-Path $base -Parent + switch ($id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-PathLexically([string]$path) { try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf @@ -194,7 +205,7 @@ if (-not $NoSkills) { if ([string]::IsNullOrWhiteSpace($base)) { continue } $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } - if (-not (Test-Path (Split-Path $base -Parent))) { continue } + if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 4a360638..cb4c6802 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -85,6 +85,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -162,7 +172,7 @@ install_skill() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a45870e7..a046b865 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -155,6 +155,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -178,7 +188,7 @@ install_skill_to_homes() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 563582ec..2659cd60 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -408,8 +408,8 @@ multi_tree_has_skills() { return 1 } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -469,6 +469,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -557,8 +567,7 @@ install_multi_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -750,8 +759,7 @@ install_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/install.ps1 b/scripts/install.ps1 index eb90752f..7d6cb4c5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -582,6 +582,18 @@ function Resolve-AgentSkillBase { return (Join-Path $Root $Agent.Dir) } +function Test-AgentSkillBaseDetected { + param([string]$BaseDir, $Agent) + $parent = Split-Path $BaseDir -Parent + switch ($Agent.Id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-SamePhysicalSkillRoot { param([string]$Left, [string]$Right) if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } @@ -1005,8 +1017,7 @@ function Install-SkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { @@ -1073,8 +1084,7 @@ function Install-MultiSkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { diff --git a/scripts/install.sh b/scripts/install.sh index 3475b866..84df3ea5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -643,8 +643,8 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -708,6 +708,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -810,8 +820,7 @@ install_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -875,8 +884,7 @@ install_multi_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 4bfd2fb3..3e2cd6b4 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -188,6 +188,26 @@ resolve_agent_base() { esac } +# Package verification must never inherit the runner's real Agent roots. In +# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two +# parallel verification jobs would then publish links into the same directory +# and make one another's links appear broken. Keep every supported override +# inside the scenario-specific temporary HOME instead. +with_isolated_agent_env() ( + isolated_home=$1 + shift + HOME="$isolated_home" + AUTOHAND_HOME="$isolated_home/.autohand" + CLAUDE_CONFIG_DIR="$isolated_home/.claude" + CODEX_HOME="$isolated_home/.codex" + GROK_HOME="$isolated_home/.grok" + HERMES_HOME="$isolated_home/.hermes" + VIBE_HOME="$isolated_home/.vibe" + XDG_CONFIG_HOME="$isolated_home/.config" + export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME + "$@" +) + verify_skill_base() { home_root="$1" base="$2" @@ -217,8 +237,8 @@ verify_compatible_skill_base() { verify_skill_targets() { home_root="$1" - # lark-cli/skills-compatible canonical store is always present, regardless - # of which concrete Agent homes existed at install time. + # The universal-convention canonical store is always present, regardless of + # which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" seen='' for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do @@ -251,15 +271,15 @@ verify_npm_install() { npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" if [ "$scenario" = "specific-agent-roots" ]; then - seed_specific_agent_homes "$npm_home" + with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home" fi say "==> verifying npm package install ($scenario)" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" @@ -270,9 +290,9 @@ verify_npm_install() { if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" fi - [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null } diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go index 35a647b0..4b414380 100644 --- a/test/scripts/agent_registry_test.go +++ b/test/scripts/agent_registry_test.go @@ -123,6 +123,27 @@ func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing. } } +func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) { + t.Parallel() + checks := map[string][]string{ + "build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + } + for rel, wants := range checks { + body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel))) + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s missing detection contract %q", rel, want) + } + } + } +} + func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 945a8e61..04533c5a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -64,6 +64,7 @@ const require = createRequire(import.meta.url); const { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, @@ -76,6 +77,14 @@ assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, " assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); +const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-")); +fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true }); +fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true }); +const detectionTargets = resolvedAgentTargets(detectionHome); +for (const id of ["kimchi", "tabnine-cli"]) { + assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`); +} +fs.rmSync(detectionHome, { recursive: true, force: true }); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -203,15 +212,32 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => { +scenario("pinned universal global topology installs canonical and retires private copies", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", "multi/dws-shared/SKILL.md": "# dws-shared\n", }); try { - writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); - writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n"); + // The pinned upstream installer treats global universal Agents as + // canonical-only even when their registry also retains a distinct + // globalSkillsDir for legacy discovery/removal. Exercise every unique + // non-canonical universal root so this distinction cannot drift again. + const retiredUniversalRoots = [ + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".codex/skills", + ".cursor/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".gemini/skills", + ".copilot/skills", + ".config/opencode/skills", + ]; + for (const root of retiredUniversalRoots) { + writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`); + } writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -223,10 +249,12 @@ scenario("Codex reads the universal canonical root and beta.6 copies are migrate fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), "universal canonical Skill installed", ); - assert.ok( - !fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")), - "beta.6 Codex duplicate migrated", - ); + for (const root of retiredUniversalRoots) { + assert.ok( + !fs.existsSync(path.join(home, root, "dingtalk-chat")), + `beta.6 universal duplicate migrated from ${root}`, + ); + } assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy nested duplicate retired", diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index cc43e8cf..97ee3235 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -135,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } verifyCmd := exec.Command("sh", verifierPath, "--npm-only") - verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + hostXDG := filepath.Join(t.TempDir(), "host-xdg") + verifyCmd.Env = replaceTestEnv(os.Environ(), + "DWS_PACKAGE_DIST_DIR", distDir, + "XDG_CONFIG_HOME", hostXDG, + ) output, err := verifyCmd.CombinedOutput() if err != nil { t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) } + if _, err := os.Stat(hostXDG); !os.IsNotExist(err) { + t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err) + } for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output) From d211b79a80163a0e6184641fdd460e22b720ae91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 21:49:35 +0800 Subject: [PATCH 007/267] test(ci): cover canonical skill platform branches --- internal/app/skill_command_test.go | 3 ++ internal/app/skill_setup.go | 3 -- internal/app/skill_setup_canonical_test.go | 5 +- ...ll_setup_event_migration_universal_test.go | 47 +++++++++++++++++++ .../app/skill_setup_full_coverage_test.go | 20 ++++++++ internal/upgrade/canonical_skills_test.go | 5 +- 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 20288bae..b8406cb0 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -122,6 +122,9 @@ func TestResolveSkillTargetPath(t *testing.T) { func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + if isUniversalSkillInstallTarget("missing-agent") { + t.Fatal("unknown Agent target classified as universal") + } for _, target := range []string{ "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index ef728093..92c52f12 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -1121,9 +1121,6 @@ func detectExistingAgentHomes(home, mode string) []string { return } } - if base == "" { - return - } dest := agentHomeForMode(base, mode) key := skillSetupPathKey(dest) if !seen[key] { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 45f9aff4..c308298b 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -103,6 +103,7 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -116,9 +117,11 @@ func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *te if err != nil { t.Fatal(err) } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") originalStat := skillSetupStat testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { - if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + if path == zcodeApp || path == minimaxApp { return appInfo, nil } return originalStat(path) diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go index 7800bb74..902aec6a 100644 --- a/internal/app/skill_setup_event_migration_universal_test.go +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -1,6 +1,8 @@ package app import ( + "errors" + "io" "os" "path/filepath" "strings" @@ -85,3 +87,48 @@ func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFold t.Fatalf("unselected chat changed: body=%q err=%v", body, err) } } + +func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) { + failure := errors.New("retirement denied") + + t.Run("home", func(t *testing.T) { + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) + codex := filepath.Join(t.TempDir(), ".codex", "skills") + if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) { + t.Fatalf("home failure = %v, want %v", err, failure) + } + }) + + t.Run("deduplicate", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + if err := retireMigratedUniversalSkills( + []string{codex, codex}, + []string{multiEventSkill, multiEventSkill}, + io.Discard, + ); err != nil { + t.Fatalf("deduplicated retirement failed: %v", err) + } + }) + + t.Run("backup propagates through command", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codex) + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { + return "", failure + }) + _, _, err := executeMultiSkillSetupTest( + t, + src, + []string{filepath.Join(home, ".agents", "skills"), codex}, + "--skill", "event", "--yes", + ) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "退役 universal Agent") { + t.Fatalf("retirement backup failure = %v, want wrapped %v", err, failure) + } + }) +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index b5e38e9c..8ead0740 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -537,6 +537,26 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) + t.Run("canonical ordinary install error", func(t *testing.T) { + testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { + return 0, 0, fail + }) + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + universalMigration := filepath.Join(home, ".codex", "skills") + if _, _, err := installMultiSkillsWithEventMigration( + "src", + []string{multiEventSkill}, + []string{canonical, universalMigration}, + []string{universalMigration}, + true, + io.Discard, + io.Discard, + ); !errors.Is(err, fail) { + t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail) + } + }) + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 79c0bbbe..17f27a27 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -164,6 +164,7 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { home := t.TempDir() testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -177,9 +178,11 @@ func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t if err != nil { t.Fatal(err) } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") originalStat := upgradeStat testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { - if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + if path == zcodeApp || path == minimaxApp { return appInfo, nil } return originalStat(path) From c674366aeaaa7ed102878b936226a2e80e6fad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:42:54 +0800 Subject: [PATCH 008/267] fix(skills): make backups cross-filesystem safe --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 286 +++++++++-- build/npm/package.json.tmpl | 2 +- docs/rfc-skill-installation-and-upgrade.md | 21 +- internal/app/skill_setup.go | 5 +- .../app/skill_setup_cleanup_coverage_test.go | 15 +- internal/upgrade/paths.go | 11 +- internal/upgrade/paths_multi_test.go | 41 ++ internal/upgrade/skill_path_move.go | 342 +++++++++++++ internal/upgrade/skill_path_move_test.go | 449 +++++++++++++++++ internal/upgrade/skill_path_move_unix.go | 12 + internal/upgrade/skill_path_move_unix_test.go | 12 + internal/upgrade/skill_path_move_windows.go | 13 + .../upgrade/skill_path_move_windows_test.go | 20 + scripts/install-devapp.ps1 | 229 ++++++++- scripts/install-devapp.sh | 34 +- scripts/install-event.sh | 24 + scripts/install-skills.sh | 24 + scripts/install.ps1 | 205 +++++++- scripts/install.sh | 46 +- test/scripts/install_js_smoke.mjs | 212 ++++++++ test/scripts/install_script_test.go | 465 ++++++++++++++++++ 22 files changed, 2356 insertions(+), 114 deletions(-) create mode 100644 internal/upgrade/skill_path_move.go create mode 100644 internal/upgrade/skill_path_move_test.go create mode 100644 internal/upgrade/skill_path_move_unix.go create mode 100644 internal/upgrade/skill_path_move_unix_test.go create mode 100644 internal/upgrade/skill_path_move_windows.go create mode 100644 internal/upgrade/skill_path_move_windows_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index c42462e8..2608ccd6 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index f091b4a2..5550d449 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -153,18 +153,222 @@ function backupStamp() { ); } +function pathExistsLexicallySync(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function copyPathLexicallySync(src, dest) { + const info = fs.lstatSync(src); + if (info.isDirectory()) { + fs.mkdirSync(dest, { mode: 0o700 }); + for (const name of fs.readdirSync(src)) { + copyPathLexicallySync(path.join(src, name), path.join(dest, name)); + } + fs.chmodSync(dest, info.mode & 0o777); + return; + } + if (info.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + return; + } + if (!info.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, info.mode & 0o777); +} + +function restoreDirectoryModesSync(modes, chmodFn) { + const failures = []; + for (let i = modes.length - 1; i >= 0; i -= 1) { + const item = modes[i]; + try { + if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode); + } catch (err) { + failures.push(`restore source directory mode ${item.path}: ${err.message}`); + } + } + if (failures.length > 0) throw new Error(failures.join("; ")); +} + +function prepareTreeRemovalSync(root, chmodFn) { + const modes = []; + const visit = (target) => { + const info = fs.lstatSync(target); + if (!info.isDirectory()) return; + const mode = info.mode & 0o777; + if ((mode & 0o700) !== 0o700) { + chmodFn(target, mode | 0o700); + modes.push({ path: target, mode }); + } + for (const name of fs.readdirSync(target)) visit(path.join(target, name)); + }; + try { + visit(root); + return modes; + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function makeTreeWritableBestEffortSync(root) { + try { + const info = fs.lstatSync(root); + if (!info.isDirectory()) return; + try { fs.chmodSync(root, 0o700); } catch {} + let names = []; + try { names = fs.readdirSync(root); } catch {} + for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name)); + } catch {} +} + +function removePublishedSourceSync(src, removeFn, chmodFn) { + const modes = prepareTreeRemovalSync(src, chmodFn); + try { + removeFn(src); + if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal"); + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function fileDigestSync(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function verifyPathCopySync(src, dest) { + const srcInfo = fs.lstatSync(src); + const destInfo = fs.lstatSync(dest); + const srcType = srcInfo.mode & fs.constants.S_IFMT; + const destType = destInfo.mode & fs.constants.S_IFMT; + if (srcType !== destType) { + throw new Error(`Skill path type mismatch: ${src} != ${dest}`); + } + if (srcInfo.isDirectory()) { + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`); + } + const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right)); + const srcNames = fs.readdirSync(src).sort(bytewise); + const destNames = fs.readdirSync(dest).sort(bytewise); + if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) { + throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`); + } + for (const name of srcNames) { + verifyPathCopySync(path.join(src, name), path.join(dest, name)); + } + return; + } + if (srcInfo.isSymbolicLink()) { + if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) { + throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`); + } + return; + } + if (!srcInfo.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`); + } + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill file mode mismatch: ${src} != ${dest}`); + } + if (srcInfo.size !== destInfo.size) { + throw new Error(`Skill file size mismatch: ${src} != ${dest}`); + } + if (fileDigestSync(src) !== fileDigestSync(dest)) { + throw new Error(`Skill file digest mismatch: ${src} != ${dest}`); + } +} + +function movePathRecoverablySync(src, dest, options = {}) { + const renameFn = options.renameFn || fs.renameSync; + const copyFn = options.copyFn || copyPathLexicallySync; + const verifyFn = options.verifyFn || verifyPathCopySync; + const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync; + const chmodFn = options.chmodFn || fs.chmodSync; + if (pathExistsLexicallySync(dest)) { + throw new Error(`move destination already exists: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + try { + renameFn(src, dest); + return; + } catch (err) { + if (!err || err.code !== "EXDEV") throw err; + } + + const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); + const stage = path.join(stageRoot, "payload"); + let stageCleaned = false; + try { + copyFn(src, stage); + verifyFn(src, stage); + const stageInfo = fs.lstatSync(stage); + const stageMode = stageInfo.mode & 0o777; + if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); + renameFn(stage, dest); + if (stageInfo.isDirectory()) chmodFn(dest, stageMode); + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + try { + removePublishedSourceSync(src, removeFn, chmodFn); + } catch (err) { + throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`); + } + if (pathExistsLexicallySync(src)) { + throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`); + } + } catch (err) { + if (!stageCleaned) { + try { + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + } catch (cleanupErr) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } + } + throw err; + } +} + +function skillMoveOptions(options = {}) { + return { + renameFn: options.renameFn, + copyFn: options.backupCopyFn, + verifyFn: options.backupVerifyFn, + removeFn: options.backupRemoveFn, + mkdirTempFn: options.backupMkdirTempFn, + }; +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it (non-interactive // installs cannot confirm, so removals must stay reversible). Missing paths // are a no-op success. On any backup failure the directory is left in place -// and false is returned so callers skip that target rather than silently -// deleting data. -function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - let info; +// and an error is returned so callers cannot silently delete data. +function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { try { - info = fs.lstatSync(dir); + fs.lstatSync(dir); } catch (err) { - if (err && err.code === "ENOENT") return true; + if (err && err.code === "ENOENT") return ""; throw err; } const rel = path.relative(homeDir, dir); @@ -172,30 +376,29 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("-") : path.basename(dir); - const stamp = backupStamp(); + const stamp = (options.backupStampFn || backupStamp)(); const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; fs.existsSync(target); i++) { + for (let i = 1; pathExistsLexicallySync(target); i++) { if (i > 1000) { - console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`); - return false; + throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } targetRoot = path.join(backupRoot, `${stamp}-${i}`); target = path.join(targetRoot, name); } try { fs.mkdirSync(targetRoot, { recursive: true }); - renameFn(dir, target); + movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); - return false; + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); } if (backups) { backups.push({ original: dir, backup: target }); } console.log(` × 已备份并移除 ${dir} → ${target}`); - return true; + return target; } function findBinary(root) { @@ -453,7 +656,8 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireManagedSkillRoot(homeDir, baseDir, managedNames) { +function retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) { + const moveOptions = skillMoveOptions(options); const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { @@ -465,16 +669,14 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { const backups = []; try { for (const victim of victims) { - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } } catch (err) { const restoreErrors = []; for (let i = backups.length - 1; i >= 0; i -= 1) { try { fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); } catch (restoreErr) { restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`); } @@ -496,7 +698,10 @@ function samePhysicalDir(left, right) { } } -function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { + const moveOptions = skillMoveOptions(options); + const publishRenameFn = options.publishRenameFn || fs.renameSync; + const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -505,12 +710,24 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const published = []; const correctLinks = new Set(); const restore = () => { + const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { - fs.rmSync(published[i], { recursive: true, force: true }); + try { + publishRemoveFn(published[i]); + } catch (err) { + restoreErrors.push(`remove ${published[i]}: ${err.message}`); + } } for (let i = backups.length - 1; i >= 0; i -= 1) { - fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + try { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); + } catch (err) { + restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`); + } + } + if (restoreErrors.length > 0) { + throw new Error(restoreErrors.join("; ")); } }; try { @@ -532,12 +749,10 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const normalized = path.resolve(victim); if (seen.has(normalized) || correctLinks.has(normalized)) continue; seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - fs.renameSync(item.staged, item.dest); + publishRenameFn(item.staged, item.dest); published.push(item.dest); } } catch (err) { @@ -592,6 +807,7 @@ function publishManagedMultiSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const staged = []; @@ -611,7 +827,7 @@ function publishManagedMultiSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -635,9 +851,7 @@ function publishManagedMultiSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { @@ -669,6 +883,7 @@ function publishManagedMonoSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stagedDir = path.join(stageRoot, "dws"); @@ -689,7 +904,7 @@ function publishManagedMonoSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -709,9 +924,7 @@ function publishManagedMonoSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } published.push(destDir); @@ -995,7 +1208,12 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + copyPathLexicallySync, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + verifyPathCopySync, }; diff --git a/build/npm/package.json.tmpl b/build/npm/package.json.tmpl index f1d70d18..b7d34154 100644 --- a/build/npm/package.json.tmpl +++ b/build/npm/package.json.tmpl @@ -32,6 +32,6 @@ "README.md" ], "engines": { - "node": ">=16" + "node": ">=16.7.0" } } diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index 0f530c5d..56e70758 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -165,6 +165,14 @@ Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别 4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录; 5. 仅在没有目标失败且至少一个目标成功时更新状态快照。 +旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入 +`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子 +rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并 +保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、 +文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标 +再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除 +失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。 + Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前; 备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同 Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与 @@ -173,11 +181,19 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 ## 9. 备份合同 - 路径:`~/.dws/skill-backups//...`; -- 主要操作:同一文件系统内使用 rename 移动; +- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的 + copy → verify → publish → remove 回退; - 失败语义:备份失败时原目录保持不变,目标安装失败; +- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在, + 但恢复必须失败并明确提示两份均被保留; - 可见性:计划和执行日志显示原路径与备份路径; - 保留策略:自动修剪,仅保留最近 5 批。 +跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统 +原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell +入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 +上述验证和失败合同。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 @@ -226,6 +242,9 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - Windows、macOS、Linux 的路径和覆盖率门禁; - symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; - Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; +- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 + 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; +- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 92c52f12..6c0becf4 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -130,6 +130,7 @@ var ( skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir + skillSetupRestoreBackup = upgrade.RestoreSkillPath skillSetupMkdirAll = os.MkdirAll skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel @@ -2048,7 +2049,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir } for i := len(backups) - 1; i >= 0; i-- { item := backups[i] - if _, err := skillSetupStat(item.original); err == nil { + if _, err := skillSetupLstat(item.original); err == nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup)) continue } else if !errors.Is(err, os.ErrNotExist) { @@ -2059,7 +2060,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup)) continue } - if err := skillSetupPublishRename(item.backup, item.original); err != nil { + if err := skillSetupRestoreBackup(item.backup, item.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup)) } } diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 957496d4..6fea3de5 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -445,14 +445,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("stat", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") { t.Fatalf("restore stat error = %v", err) } }) t.Run("mkdir", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") { @@ -460,9 +460,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("rename", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") { t.Fatalf("restore rename error = %v", err) @@ -479,10 +479,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } return "", failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) restoreErr := errors.New("restore failure") - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr }) _, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard) if !errors.Is(err, failure) || !errors.Is(err, restoreErr) { t.Fatalf("backup rollback error = %v", err) @@ -491,7 +491,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("publish rollback failure", func(t *testing.T) { testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) err := publishSkillSetupTarget( []skillSetupStagedDir{{staged: "staged", dest: "dest"}}, diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 644b3f96..f0424ae6 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -193,8 +193,11 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) target := filepath.Join(backupRoot, name) for i := 1; ; i++ { - if _, err := os.Stat(target); os.IsNotExist(err) { - break + if _, err := upgradeLstat(target); err != nil { + if os.IsNotExist(err) { + break + } + return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) } backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) target = filepath.Join(backupRoot, name) @@ -205,7 +208,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } - if err := upgradeRename(dir, target); err != nil { + if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } // Keep the backup directory bounded; a prune failure must not fail the @@ -503,7 +506,7 @@ func restoreSkillSet(published []string, backups []backedUpSkillDir) error { restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err)) continue } - if err := upgradeRename(backup.backup, backup.original); err != nil { + if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err)) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index dfee1cae..a0d67135 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -733,6 +734,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("victim must be gone after backup, stat err=%v", err) } + // A dangling link at the first backup target is still an occupied lexical + // path and must select a numbered target rather than being overwritten. + danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision") + if err := os.MkdirAll(danglingVictim, 0o755); err != nil { + t.Fatal(err) + } + danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision") + if err := os.Symlink("missing-backup", danglingTaken); err == nil { + got, err = backupAndRemoveSkillDir(home, danglingVictim) + if err != nil { + t.Fatalf("backup with dangling collision error = %v", err) + } + want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision") + if got != want { + t.Fatalf("dangling collision backup path = %q, want %q", got, want) + } + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + // An unreadable target probe is not equivalent to a free path. + probeVictim := filepath.Join(home, "probe-victim") + if err := os.MkdirAll(probeVictim, 0o755); err != nil { + t.Fatal(err) + } + probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim") + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { + if path == probeTarget { + return nil, errors.New("target probe denied") + } + return os.Lstat(path) + }) + if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") { + t.Fatalf("target probe error = %v", err) + } + if _, err := os.Stat(probeVictim); err != nil { + t.Fatalf("probe victim must survive target check failure: %v", err) + } + testseam.Swap(t, &upgradeLstat, os.Lstat) + // Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir. victim2 := filepath.Join(home, "victim2") if err := os.MkdirAll(victim2, 0o755); err != nil { diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go new file mode 100644 index 00000000..605f9056 --- /dev/null +++ b/internal/upgrade/skill_path_move.go @@ -0,0 +1,342 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +var ( + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod +) + +// moveSkillPathRecoverably moves one managed Skill path without weakening the +// backup contract when source and destination are on different filesystems. +// The cross-device path publishes a fully copied and verified sibling staging +// path before removing the source. Any failure before source removal leaves the +// source intact; a removal failure deliberately leaves both verified copies. +func moveSkillPathRecoverably(src, dst string) (err error) { + if _, statErr := os.Lstat(dst); statErr == nil { + return fmt.Errorf("目标已存在: %s", dst) + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) + } + if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { + return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) + } + if renameErr := skillPathRename(src, dst); renameErr == nil { + return nil + } else if !isCrossDeviceError(renameErr) { + return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) + } + + stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-") + if err != nil { + return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err) + } + stage := filepath.Join(stageRoot, "payload") + stageCleaned := false + defer func() { + if stageCleaned { + return + } + makeSkillPathTreeWritable(stageRoot) + if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + }() + + if err := skillPathCopy(src, stage); err != nil { + return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err) + } + if err := skillPathVerify(src, stage); err != nil { + return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) + } + stageInfo, err := os.Lstat(stage) + if err != nil { + return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) + } + if stageInfo.IsDir() { + // Darwin refuses to rename a read-only directory even when both parent + // directories are writable. The exact source mode was verified above; + // temporarily add owner access for publication, then restore it before + // the final verification and before the source is removed. + if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil { + return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) + } + } + if err := skillPathRename(stage, dst); err != nil { + return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) + } + if stageInfo.IsDir() { + if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + } + } + if err := skillPathVerify(src, dst); err != nil { + return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + } + if err := skillPathRemoveAll(stageRoot); err != nil { + return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + } + stageCleaned = true + if err := removePublishedSkillSource(src); err != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err) + } + return nil +} + +type skillPathDirMode struct { + path string + mode os.FileMode +} + +func removePublishedSkillSource(src string) error { + dirModes, err := prepareSkillPathTreeRemoval(src) + if err != nil { + return err + } + removeErr := skillPathRemoveAll(src) + if removeErr == nil { + if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + return nil + } else if statErr == nil { + removeErr = errors.New("源路径仍存在") + } else { + removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr) + } + } + return errors.Join(removeErr, restoreSkillPathDirModes(dirModes)) +} + +func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { + var modes []skillPathDirMode + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0o700 == 0o700 { + return nil + } + if err := skillPathChmod(path, mode|0o700); err != nil { + return err + } + modes = append(modes, skillPathDirMode{path: path, mode: mode}) + return nil + }) + if err != nil { + return nil, errors.Join(err, restoreSkillPathDirModes(modes)) + } + return modes, nil +} + +func restoreSkillPathDirModes(modes []skillPathDirMode) error { + var restoreErr error + for i := len(modes) - 1; i >= 0; i-- { + item := modes[i] + if _, err := os.Lstat(item.path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := skillPathChmod(item.path, item.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err)) + } + } + return restoreErr +} + +func makeSkillPathTreeWritable(root string) { + // Best effort only: RemoveAll below remains the source of truth and its + // error is returned. This preparation lets it traverse read-only staging + // directories left by a copy or verification failure. + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr == nil && entry.IsDir() { + _ = os.Chmod(path, 0o700) + } + return nil + }) +} + +// RestoreSkillPath restores a previously recorded backup using the same +// rename-first, verified cross-device fallback as backup publication. +func RestoreSkillPath(backup, original string) error { + return moveSkillPathRecoverably(backup, original) +} + +func copySkillPathLexically(src, dst string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + switch { + case info.IsDir(): + // Staging must remain writable while children are copied. Restore the + // source mode only after the directory is complete so read-only Skill + // directories (for example 0555) can still be migrated lexically. + if err := os.Mkdir(dst, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(src) + if err != nil { + return err + } + return os.Symlink(target, dst) + case info.Mode().IsRegular(): + return copyRegularSkillFile(src, dst, info.Mode().Perm()) + default: + return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode()) + } +} + +func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, in.Close()) }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { err = errors.Join(err, out.Close()) }() + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Sync(); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func verifySkillPathCopy(src, dst string) error { + srcInfo, err := os.Lstat(src) + if err != nil { + return err + } + dstInfo, err := os.Lstat(dst) + if err != nil { + return err + } + if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType { + return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode()) + } + switch { + case srcInfo.IsDir(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + srcNames, err := skillPathEntryNames(src) + if err != nil { + return err + } + dstNames, err := skillPathEntryNames(dst) + if err != nil { + return err + } + if len(srcNames) != len(dstNames) { + return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames)) + } + for i, name := range srcNames { + if name != dstNames[i] { + return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i]) + } + if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil { + return err + } + } + return nil + case srcInfo.Mode()&os.ModeSymlink != 0: + srcTarget, err := os.Readlink(src) + if err != nil { + return err + } + dstTarget, err := os.Readlink(dst) + if err != nil { + return err + } + if srcTarget != dstTarget { + return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget) + } + return nil + case srcInfo.Mode().IsRegular(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size()) + } + srcDigest, err := skillPathFileDigest(src) + if err != nil { + return err + } + dstDigest, err := skillPathFileDigest(dst) + if err != nil { + return err + } + if srcDigest != dstDigest { + return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst) + } + return nil + default: + return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode()) + } +} + +func skillPathEntryNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func skillPathFileDigest(path string) ([sha256.Size]byte, error) { + f, err := os.Open(path) + if err != nil { + return [sha256.Size]byte{}, err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return [sha256.Size]byte{}, err + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go new file mode 100644 index 00000000..5567d1c2 --- /dev/null +++ b/internal/upgrade/skill_path_move_test.go @@ -0,0 +1,449 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { + t.Run("directory file and lexical links", func(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + linksCreated := true + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + linksCreated = false + } + if linksCreated { + if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil { + t.Fatal(err) + } + } + + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { + t.Fatalf("copied file = %q, %v", got, err) + } + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil || info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v, %v", info, err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(dst, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("regular file root", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source-file") + dst := filepath.Join(root, "backup", "source-file") + if err := os.WriteFile(src, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "state" { + t.Fatalf("backup file = %q, %v", got, err) + } + }) + + t.Run("read-only directory mode is restored after copying children", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "backup", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("copied directory mode = %v, %v", info, err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" { + t.Fatalf("copied read-only file = %q, %v", got, err) + } + }) + + t.Run("rollback uses the same cross-device contract", func(t *testing.T) { + root := t.TempDir() + backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, backup, original) + if err := RestoreSkillPath(backup, original); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed after verified restore: %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { + t.Run("non cross-device rename never copies", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + copied := false + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + if copied { + t.Fatal("permission failure must not enter cross-device copy fallback") + } + assertSkillSourcePreserved(t, src) + }) + + for _, tc := range []struct { + name string + copy func(string, string) error + verify func(string, string) error + }{ + {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }}, + {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + if tc.copy != nil { + testseam.Swap(t, &skillPathCopy, tc.copy) + } + if tc.verify != nil { + testseam.Swap(t, &skillPathVerify, tc.verify) + } + if err := moveSkillPathRecoverably(src, dst); err == nil { + t.Fatal("expected failure") + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("formal backup must not publish: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + } + + t.Run("staging publish failure", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + if to == dst { + return errors.New("publish failed") + } + return originalRename(from, to) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("published verification failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("verified backup candidate must remain: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("source removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("formal backup must remain: %v", err) + } + }) + + t.Run("read-only source mode is restored after removal failure", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("source directory mode after failed removal = %v, %v", info, err) + } + if err := os.Chmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("source permission preparation failure preserves both", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == src { + return errors.New("chmod failed") + } + return originalChmod(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + if err := originalChmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("lying remover is detected", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return nil + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) + + t.Run("same-filesystem fast path", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source still exists: %v", err) + } + }) + + t.Run("existing destination is never overwritten", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) +} + +func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { + t.Run("type", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") { + t.Fatalf("error = %v", err) + } + }) + t.Run("file size and digest", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") { + t.Fatalf("error = %v", err) + } + }) + t.Run("symlink target", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Symlink("one", src); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation unavailable") + } + t.Fatal(err) + } + if err := os.Symlink("two", dst); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") { + t.Fatalf("error = %v", err) + } + }) +} + +func forceCrossDeviceRename(t *testing.T, src, dst string) { + t.Helper() + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + return originalRename(from, to) + }) +} + +func makeSkillMoveFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + return src, dst +} + +func assertSkillSourcePreserved(t *testing.T, src string) { + t.Helper() + if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("source content = %q, %v", got, err) + } +} + +func assertNoCrossDeviceStage(t *testing.T, dst string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(dst)) + if err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } + prefix := "." + filepath.Base(dst) + ".cross-device-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("cross-device staging leaked: %s", entry.Name()) + } + } +} diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go new file mode 100644 index 00000000..587c8217 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go new file mode 100644 index 00000000..e5ccde16 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "os" + "syscall" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} +} diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go new file mode 100644 index 00000000..f43fe688 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package upgrade + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go new file mode 100644 index 00000000..401e4ed0 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -0,0 +1,20 @@ +//go:build windows + +package upgrade + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} +} + +func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { + if !isCrossDeviceError(testCrossDeviceError()) { + t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") + } +} diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 2ed13bee..e8cc3bce 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -109,13 +109,201 @@ function Test-PathLexically([string]$path) { Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) } -function Backup-DevSkill([string]$path) { +function Move-DevSkillPath([string]$Source, [string]$Destination) { + Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} + +function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { + $exception = $Record.Exception + while ($null -ne $exception) { + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-DevSkillMetadata($SourceItem, [string]$Destination) { + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-DevSkillPermissionFingerprint([string]$Path) { + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) { + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name) + } + Copy-DevSkillMetadata $item $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-DevSkillMetadata $item $Destination +} + +function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) { + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill path type mismatch: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill link target mismatch: $Source != $Destination" + } + return + } + if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) { + throw "Skill path permissions mismatch: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" } + Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" } + if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne + (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) { + throw "Skill file digest mismatch: $Source != $Destination" + } +} + +function Remove-DevSkillPathLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-DevSkillPath $Source $Destination + return + } catch { + if (!(Test-DevCrossDeviceMoveError $_)) { throw } + } + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { Remove-DevSkillPathLexically $Source } catch { + throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" + } + if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop } catch { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot`: $_" + } + } + throw $failure + } +} + +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + if ($null -ne $BackupPath) { $BackupPath.Value = "" } if (!(Test-PathLexically $path)) { return } $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null $target = Join-Path $backupRoot (Split-Path $path -Leaf) while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } - Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop + Move-DevSkillPathRecoverably $path $target + if ($null -ne $BackupPath) { $BackupPath.Value = $target } +} + +function Restore-DevSkillBackup([string]$Destination, [string]$Backup) { + if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } + if (![string]::IsNullOrWhiteSpace($Backup)) { + Move-DevSkillPathRecoverably $Backup $Destination + } +} + +function Publish-DevSkillCopy([string]$Source, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +function Publish-DevSkillJunction([string]$Target, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } } function Get-Arch { @@ -133,6 +321,26 @@ function Get-Arch { } } +function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) { + $checksums = Join-Path $TempDir "checksums.txt" + if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) { + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" ` + -OutFile $checksums -UseBasicParsing -ErrorAction Stop + } catch { + Die "Could not download checksums.txt; refusing unverified release assets." + } + } + $line = Get-Content -LiteralPath $checksums | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $line) { Die "$AssetName is missing from checksums.txt." } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." } + Say "SHA256 checksum verified: $AssetName" +} + # Read the releases list (newest first) and take the top tag, so this also works # if a release is ever published as a prerelease (which /releases/latest skips). if (-not $Version) { @@ -163,6 +371,7 @@ try { Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` -OutFile $zip -UseBasicParsing } catch { Die "Binary download failed - does release $Version have $asset?" } +Assert-DevReleaseAssetChecksum $zip $asset $tmp Expand-Archive -Path $zip -DestinationPath $tmp -Force $exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 @@ -177,6 +386,7 @@ if (-not $NoSkills) { $skzip = Join-Path $tmp "dws-skills.zip" Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` -OutFile $skzip -UseBasicParsing + Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp $skdir = Join-Path $tmp "sk" Expand-Archive -Path $skzip -DestinationPath $skdir -Force @@ -188,15 +398,11 @@ if (-not $NoSkills) { if ($src) { # cache so `dws skill setup --mode multi` can find a source later $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" - if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } - New-Item -ItemType Directory -Path $cache -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + Publish-DevSkillCopy $src $cache $canonicalBase = Join-Path $HOME ".agents\skills" $canonical = Join-Path $canonicalBase $SkillName - Backup-DevSkill $canonical - New-Item -ItemType Directory -Path $canonical -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + Publish-DevSkillCopy $src $canonical $installed = 1 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { @@ -209,9 +415,8 @@ if (-not $NoSkills) { $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null - Backup-DevSkill $dest - try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null } - catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force } + try { Publish-DevSkillJunction $canonical $dest } + catch { Publish-DevSkillCopy $canonical $dest } $installed++ } Say "Skill dingtalk-misc -> $installed agent dir(s)" @@ -219,7 +424,7 @@ if (-not $NoSkills) { Say "(dingtalk-misc not found in skills bundle; skipped)" } } catch { - Say "(skill install skipped: $($_.Exception.Message))" + Die "Skill install failed: $($_.Exception.Message)" } } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index cb4c6802..70a631ad 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "✅ SHA256 checksum verified: ${name}" +} + copy_tree() { src="$1"; dest="$2"; parent="$(dirname "$dest")" mkdir -p "$parent" || return 1 @@ -33,7 +55,10 @@ copy_tree() { if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage" "$dest"; then return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -106,7 +131,10 @@ link_or_copy_skill() { if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -202,6 +230,7 @@ main() { say "⬇ Downloading ${asset} ..." curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" else @@ -215,6 +244,7 @@ main() { # 2) dev skill from the release's skills bundle if [ "$NO_SKILLS" != "1" ]; then if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + verify_release_asset "dws-skills.zip" "$tmp/skills.zip" mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a046b865..58c56cda 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "SHA256 checksum verified: ${name}" +} + detect_os() { case "$(uname -s)" in Linux*) echo linux ;; @@ -283,6 +305,7 @@ install_binary() { say "Downloading ${asset} ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then extract_zip "$tmp/$asset" "$tmp/bin" @@ -311,6 +334,7 @@ install_skills() { say "Downloading dws-skills.zip ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip" mkdir -p "$tmp/skills" extract_zip "$tmp/dws-skills.zip" "$tmp/skills" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 2659cd60..4a7c486b 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -123,6 +123,29 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } + curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { + printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 + return 1 + } + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { + printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 + return 1 + } + [ "$_checksum_actual" = "$_checksum_expected" ] || { + printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 + return 1 + } + printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -813,6 +836,7 @@ main() { [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" + verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7d6cb4c5..795ee1be 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -261,6 +261,161 @@ function Move-SkillPath { Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop } +function Test-CrossDeviceMoveError { + param([System.Management.Automation.ErrorRecord]$Record) + $exception = $Record.Exception + while ($null -ne $exception) { + # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as + # the low word of an IOException HRESULT. + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-SkillPathMetadata { + param($SourceItem, [string]$Destination) + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-SkillPathPermissionFingerprint { + param([string]$Path) + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl + } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name) + } + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { + throw "不支持复制特殊 Skill 路径 $Source" + } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination +} + +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill 路径类型不一致: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill 链接目标不一致: $Source != $Destination" + } + return + } + if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne + (Get-SkillPathPermissionFingerprint -Path $Destination)) { + throw "Skill 路径权限不一致: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { + throw "Skill 目录项数量不一致: $Source != $Destination" + } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { + throw "Skill 目录项不一致: $Source != $Destination" + } + Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { + throw "Skill 文件大小不一致: $Source != $Destination" + } + $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash + $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash + if ($sourceHash -ne $destinationHash) { + throw "Skill 文件内容摘要不一致: $Source != $Destination" + } +} + +function Remove-SkillPathLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on +# the destination filesystem, copy links lexically, verify, publish, then +# remove the source. Before source removal every failure keeps the original; +# a removal failure deliberately leaves both verified copies and fails loud. +function Move-SkillPathRecoverably { + param([string]$Source, [string]$Destination) + if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-SkillPath -Source $Source -Destination $Destination + return + } catch { + if (!(Test-CrossDeviceMoveError -Record $_)) { throw } + } + + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-SkillPathLexically -Source $Source -Destination $stage + Assert-SkillPathCopy -Source $Source -Destination $stage + Move-SkillPath -Source $stage -Destination $Destination + Assert-SkillPathCopy -Source $Source -Destination $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { + Remove-SkillPathLexically -Path $Source + } catch { + throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_" + } + if (Test-SkillPathLexically -Path $Source) { + throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)" + } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + } catch { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot`: $_" + } + } + throw $failure + } +} + function Get-Arch { # Allow manual override via environment variable if ($env:DWS_ARCH) { @@ -340,6 +495,26 @@ function Get-GiteeAssetUrl { return "" } +function Assert-ReleaseAssetChecksum { + param([string]$AssetPath, [string]$AssetName, [string]$TempDir) + if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } + if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." } + $checksumPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop + } catch { + Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName." + } + $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." } + $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." } + Write-Say "✅ SHA256 checksum verified: $AssetName" +} + function Resolve-Source { # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. if ($GiteeRepo -ne "") { return } @@ -482,7 +657,7 @@ function Backup-SkillDir { $name = Split-Path $Dir -Leaf $target = Join-Path (Join-Path $backupRoot $stamp) $name $i = 1 - while (Test-Path $target) { + while (Test-SkillPathLexically -Path $target) { $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name $i++ if ($i -gt 1000) { @@ -492,9 +667,9 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $Dir -Destination $target + Move-SkillPathRecoverably -Source $Dir -Destination $target } catch { - Write-Say "⚠️ 备份失败,保留原目录 $Dir" + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } @@ -525,7 +700,7 @@ function Restore-MultiSkillSet { throw "恢复目标仍存在" } New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $item.Backup -Destination $item.Original + Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original } catch { Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_" $ok = $false @@ -811,26 +986,7 @@ function Install-Binary { $archivePath = Join-Path $tmpDir $archiveName Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing - # Download and verify SHA256 checksum - if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } - try { - $checksumPath = Join-Path $tmpDir "checksums.txt" - Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing - $checksumContent = Get-Content $checksumPath - $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) } - if ($expectedLine) { - $expected = ($expectedLine -split '\s+')[0] - $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting." - } - Write-Say "✅ SHA256 checksum verified" - } else { - Write-Say "⚠️ Archive not found in checksums.txt; skipping verification" - } - } catch { - Write-Say "⚠️ Could not download checksums.txt; skipping verification" - } + Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir Write-Say "📦 Extracting..." Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force @@ -1256,6 +1412,7 @@ function Install-Skills { Write-Err "Cannot download skills from GitHub and no local source checkout found." } } + Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force diff --git a/scripts/install.sh b/scripts/install.sh index 84df3ea5..b494a402 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -141,6 +141,23 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}." + download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \ + err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}." + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt." + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \ + err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl." + [ "$_checksum_actual" = "$_checksum_expected" ] || \ + err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}." + say "✅ SHA256 checksum verified: ${_checksum_asset}" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -1085,32 +1102,7 @@ install_binary() { download "$download_url" "$tmpdir/$archive_name" - # Download and verify SHA256 checksum - checksum_url="$(asset_url checksums.txt)" - if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then - expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")" - if [ -n "$expected" ]; then - if need_cmd sha256sum; then - actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')" - elif need_cmd shasum; then - actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')" - else - actual="" - fi - if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then - err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting." - fi - if [ -n "$actual" ]; then - say "✅ SHA256 checksum verified" - else - say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification" - fi - else - say "⚠️ Archive not found in checksums.txt; skipping verification" - fi - else - say "⚠️ Could not download checksums.txt; skipping verification" - fi + verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir" say "📦 Extracting..." tar xzf "$tmpdir/$archive_name" -C "$tmpdir" @@ -1175,6 +1167,8 @@ install_skills() { fi fi + verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills" + extract_root="$tmpdir_skills/skills" mkdir -p "$extract_root" if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 04533c5a..ba0ad970 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -65,7 +65,10 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = require(installJsSource); @@ -126,6 +129,175 @@ function writeManagedState(home, names) { writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`); } +function crossDeviceError() { + return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); +} + +function runCrossDeviceMoveContract() { + const roots = []; + const fixture = (name) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`)); + roots.push(root); + const src = path.join(root, "external", "skill"); + const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill"); + writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640); + return { root, src, dest }; + }; + const injectedRename = (source, target) => { + if (path.basename(source) === "skill" && path.basename(target) === "skill") { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }; + try { + { + const { src, dest } = fixture("success"); + fs.symlinkSync("SKILL.md", path.join(src, "skill-link")); + fs.symlinkSync("missing.md", path.join(src, "dangling-link")); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.lstatSync(dest).isDirectory(), true); + assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640); + assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md"); + assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md"); + assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last"); + + const restored = path.join(path.dirname(src), "restored-skill"); + movePathRecoverablySync(dest, restored, { + renameFn(source, target) { + if (source === dest && target === restored) throw crossDeviceError(); + fs.renameSync(source, target); + }, + }); + assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup"); + } + + for (const [name, options] of [ + ["copy", { copyFn() { throw new Error("copy failed"); } }], + ["verify", { verifyFn() { throw new Error("verify failed"); } }], + ]) { + const { root, src, dest } = fixture(name); + assert.throws( + () => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }), + new RegExp(`${name} failed`), + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false); + const backupParent = path.dirname(dest); + if (fs.existsSync(backupParent)) { + assert.equal( + fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")), + false, + `${name} failure cleans destination-filesystem staging`, + ); + } + assert.ok(root); + } + + { + const { src, dest } = fixture("remove"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("read-only"); + fs.chmodSync(src, 0o555); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("read-only-remove-failure"); + fs.chmodSync(src, 0o555); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode"); + fs.chmodSync(src, 0o755); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("permission"); + let copied = false; + const permission = Object.assign(new Error("permission denied"), { code: "EACCES" }); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn() { throw permission; }, + copyFn() { copied = true; }, + }), + /permission denied/, + ); + assert.equal(copied, false, "EACCES must not be mistaken for EXDEV"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); + roots.push(root); + const home = path.join(root, "home"); + const victim = path.join(home, ".agents", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + const stamp = "20260813-150000"; + const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat"); + fs.mkdirSync(path.dirname(occupied), { recursive: true }); + fs.symlinkSync("missing-backup", occupied); + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }); + assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling"); + assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-")); + roots.push(root); + const home = path.join(root, "home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const victim = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], { + renameFn(source, target) { + if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }, + publishRenameFn() { throw new Error("link publish failed"); }, + }), + /link publish failed/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n"); + } + } finally { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + } +} + // stagePkg builds a fake npm package whose assets/dws-skills.zip contains // exactly the given zip entries ({ "relative/path": "content" }) plus any // listed empty directories. Returns { tmp, pkg, home }. @@ -300,33 +472,54 @@ scenario("upstream Agent roots honor XDG and custom homes with relative links", }); const xdg = path.join(tmp, "xdg config"); const autohand = path.join(tmp, "autohand home"); + const claude = path.join(tmp, "claude config"); const codex = path.join(tmp, "codex home"); + const hermes = path.join(tmp, "hermes home"); try { writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(claude, "config.json"), "{}\n"); + writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n"); writeFile(path.join(codex, "config.toml"), "model=test\n"); writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(hermes, "config.json"), "{}\n"); + writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n"); writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); const res = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: xdg, AUTOHAND_HOME: autohand, + CLAUDE_CONFIG_DIR: claude, CODEX_HOME: codex, + HERMES_HOME: hermes, }); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); for (const linked of [ path.join(xdg, "goose", "skills", "dingtalk-chat"), path.join(autohand, "skills", "dingtalk-chat"), + path.join(claude, "skills", "dingtalk-chat"), + path.join(hermes, "skills", "dingtalk-chat"), path.join(home, ".qoderwork", "skills", "dingtalk-chat"), ]) { assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); } + const backupText = []; + const collectBackupText = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) collectBackupText(full); + else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + } + }; + collectBackupText(path.join(home, ".dws", "skill-backups")); + assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); + assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); for (const retired of [ path.join(xdg, "agents", "skills", "dingtalk-chat"), path.join(codex, "skills", "dingtalk-chat"), @@ -518,6 +711,13 @@ scenario("multi set publish failure restores the complete previous set", () => { [first, second], { renameFn(src, dest) { + if ( + src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second)) + ) { + throw crossDeviceError(); + } if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { throw new Error("injected second publish failure"); } @@ -601,6 +801,14 @@ for (const failureKind of ["backup", "publish"]) { if (failureKind === "backup" && src === second) { throw new Error("injected second backup failure"); } + if ( + failureKind === "publish" && + (src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second))) + ) { + throw crossDeviceError(); + } if ( failureKind === "publish" && src.includes(".dws-mono-set.tmp-") && @@ -626,6 +834,10 @@ for (const failureKind of ["backup", "publish"]) { }); } +process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); +runCrossDeviceMoveContract(); +process.stdout.write("ok\n"); + for (const [name, fn] of scenarios) { process.stdout.write(`• ${name} ... `); fn(); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 5ab08442..44620d0a 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" @@ -214,13 +216,41 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) { } } +func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required string + forbidden string + }{ + {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + if !strings.Contains(text, check.required) { + t.Errorf("%s missing explicit failure contract %q", check.path, check.required) + } + if strings.Contains(text, check.forbidden) { + t.Errorf("%s still contains silent failure path %q", check.path, check.forbidden) + } + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), filepath.Join("..", "..", "scripts", "install-event.sh"), + filepath.Join("..", "..", "scripts", "install-devapp.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { scriptPath, err := filepath.Abs(rel) @@ -237,6 +267,9 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { if !strings.Contains(text, "releases/download") || !strings.Contains(text, "dws-skills.zip") { t.Fatalf("%s should download dws-skills.zip from GitHub Releases", scriptPath) } + if !strings.Contains(text, "checksums.txt") || !strings.Contains(strings.ToLower(text), "sha256") { + t.Fatalf("%s should verify dws-skills.zip against release checksums", scriptPath) + } if strings.Contains(text, "archive/refs/heads/main.tar.gz") || strings.Contains(text, "archive/refs/tags/") { t.Fatalf("%s should not download skills from repository archive refs", scriptPath) } @@ -319,6 +352,7 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", "SKILL.md": "legacy mono root\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, dir := range []string{ @@ -439,6 +473,7 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) @@ -484,6 +519,7 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { "multi/dingtalk-shared/SKILL.md": "new shared\n", "mono/SKILL.md": "new mono\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") @@ -518,6 +554,46 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { } } +func TestInstallEventChecksumMismatchPreservesExistingSkills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + root := t.TempDir() + home := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "multi/dingtalk-misc/SKILL.md": "new misc\n", + "mono/SKILL.md": "new mono\n", + }) + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Repeat("0", 64)+" dws-skills.zip\n"), 0o644) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + existing := filepath.Join(home, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, runErr := cmd.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum mismatch") { + t.Fatalf("checksum mismatch result = %v\n%s", runErr, output) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "old event stays\n" { + t.Fatalf("existing Skill after checksum mismatch = %q, %v", got, err) + } +} + func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -540,6 +616,7 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) @@ -596,6 +673,7 @@ func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") @@ -1195,6 +1273,82 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" } } +func TestInstallSkillsShellMvCrossFilesystemBackupAndRestore(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real cross-filesystem mv fixture uses Linux /dev/shm") + } + sharedMemoryRoot, err := os.MkdirTemp("/dev/shm", "dws-shell-exdev-") + if err != nil { + t.Skipf("/dev/shm is unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sharedMemoryRoot) }) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-skills.sh final main invocation not found") + } + library := filepath.Join(t.TempDir(), "install-skills-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + source := filepath.Join(t.TempDir(), "dingtalk-chat") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + probe := filepath.Join(sharedMemoryRoot, "rename-probe") + if err := os.Rename(source, probe); err == nil { + if restoreErr := os.Rename(probe, source); restoreErr != nil { + t.Fatal(restoreErr) + } + t.Skip("/dev/shm and the test temp directory share a filesystem") + } else if !strings.Contains(strings.ToLower(err.Error()), "cross-device") { + t.Skipf("could not establish a cross-filesystem fixture: %v", err) + } + + home := filepath.Join(sharedMemoryRoot, "home") + manifest := filepath.Join(sharedMemoryRoot, "backups.manifest") + harness := `. "$DWS_TEST_LIBRARY" +backup_and_remove_skill_dir "$DWS_TEST_SOURCE" +backup=$DWS_LAST_SKILL_BACKUP +[ -n "$backup" ] && [ -d "$backup" ] && [ ! -e "$DWS_TEST_SOURCE" ] +printf '%s\n%s\n' "$DWS_TEST_SOURCE" "$backup" > "$DWS_TEST_MANIFEST" +restore_multi_skill_set /dev/null "$DWS_TEST_MANIFEST" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_MANIFEST="+manifest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-filesystem shell backup/restore failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored shell content = %q, %v", got, err) + } + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(source, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("shell %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(source, name)); err != nil || got != want { + t.Fatalf("shell %s target = %q, %v; want %q", name, got, err, want) + } + } +} + func TestInstallSkillsShellPreservesUnregisteredDingtalkSkill(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) if err != nil { @@ -2196,6 +2350,295 @@ exit 0 } } +func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + + for _, phase := range []string{"success", "copy", "verify", "remove", "permission"} { + phase := phase + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "skill") + destination := filepath.Join(home, ".dws", "skill-backups", "stamp", "skill") + restored := filepath.Join(root, "external", "restored-skill") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + linksCreated := false + if phase == "success" { + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + } + + harness := prefix + ` +$script:OriginalCopySkillPathLexically = ${function:Copy-SkillPathLexically} +$script:OriginalAssertSkillPathCopy = ${function:Assert-SkillPathCopy} +$script:OriginalRemoveSkillPathLexically = ${function:Remove-SkillPathLexically} +function New-CrossDeviceError { return [System.IO.IOException]::new("injected cross-device rename", -2147024879) } +function Move-SkillPath { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "permission" -and $Source -eq $env:DWS_TEST_SOURCE) { + throw [System.UnauthorizedAccessException]::new("permission denied") + } + if (($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) -or + ($Source -eq $env:DWS_TEST_DESTINATION -and $Destination -eq $env:DWS_TEST_RESTORED)) { + throw (New-CrossDeviceError) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "copy") { throw "copy failed" } + if ($env:DWS_TEST_PHASE -eq "permission") { + [System.IO.File]::WriteAllText($env:DWS_TEST_COPY_MARKER, "called") + } + & $script:OriginalCopySkillPathLexically -Source $Source -Destination $Destination +} +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination +} +function Remove-SkillPathLexically { + param([string]$Path) + if ($env:DWS_TEST_PHASE -eq "remove" -and $Path -eq $env:DWS_TEST_SOURCE) { throw "remove failed" } + & $script:OriginalRemoveSkillPathLexically -Path $Path +} +try { + Move-SkillPathRecoverably -Source $env:DWS_TEST_SOURCE -Destination $env:DWS_TEST_DESTINATION + if ($env:DWS_TEST_PHASE -ne "success") { exit 2 } + Move-SkillPathRecoverably -Source $env:DWS_TEST_DESTINATION -Destination $env:DWS_TEST_RESTORED + exit 0 +} catch { + if ($env:DWS_TEST_PHASE -eq "success") { Write-Error $_; exit 3 } + if ($env:DWS_TEST_PHASE -eq "copy" -and $_ -notmatch "copy failed") { Write-Error $_; exit 4 } + if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } + if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } + if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + exit 0 +} +` + harnessPath := filepath.Join(t.TempDir(), "install-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + copyMarker := filepath.Join(root, "copy-called") + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + "DWS_TEST_RESTORED="+restored, + "DWS_TEST_COPY_MARKER="+copyMarker, + "DWS_TEST_PHASE="+phase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell %s EXDEV harness failed: %v\n%s", phase, err, output) + } + + if phase == "success" { + if got, err := os.ReadFile(filepath.Join(restored, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed by rollback: %v", err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(restored, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + return + } + + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("source after %s failure = %q, %v", phase, got, err) + } + if phase == "remove" { + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("published backup after remove failure = %q, %v", got, err) + } + } else if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination after %s failure = %v", phase, err) + } + if phase == "permission" { + if _, err := os.Stat(copyMarker); !os.IsNotExist(err) { + t.Fatalf("permission error must not trigger copy fallback: %v", err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".skill.cross-device-*")); err != nil || len(matches) != 0 { + t.Fatalf("PowerShell %s staging leftovers = %v, %v", phase, matches, err) + } + }) + } +} + +func TestInstallDevappPowerShellCrossDeviceBackup(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old dev skill\n"), 0o640) + linksCreated := false + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +Backup-DevSkill $env:DWS_TEST_SOURCE +if (Test-PathLexically $env:DWS_TEST_SOURCE) { exit 2 } +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_SOURCE="+source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp EXDEV backup failed: %v\n%s", err, output) + } + backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n") + if backup == "" { + t.Fatal("PowerShell devapp backup missing") + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(backup, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("devapp %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(backup, name)); err != nil || got != want { + t.Fatalf("devapp %s target = %q, %v; want %q", name, got, err, want) + } + } + } +} + +func TestInstallDevappPowerShellPublishFailureRestoresAcrossDevice(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -match "[\\/].dws-dev-copy-[^\\/]+[\\/]payload$") { throw "injected publish failure" } + if ($Source -match "[\\/]skill-backups[\\/]" -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rollback", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_ -notmatch "injected publish failure") { Write-Error $_; exit 3 } +} +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publish-rollback-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publish rollback failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old dev skill\n" { + t.Fatalf("restored devapp content = %q, %v", got, err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".dws-dev-copy-*")); err != nil || len(matches) != 0 { + t.Fatalf("devapp publish staging leftovers = %v, %v", matches, err) + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { @@ -2416,12 +2859,34 @@ done case "$url" in *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *"/checksums.txt") cp "$FAKE_RELEASE_DIR/checksums.txt" "$out" ;; *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; esac ` mustWriteFile(t, path, []byte(script), 0o755) } +func writeInstallerFixtureChecksums(t *testing.T, releaseDir string) { + t.Helper() + entries, err := os.ReadDir(releaseDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", releaseDir, err) + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "checksums.txt" { + continue + } + data, err := os.ReadFile(filepath.Join(releaseDir, entry.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", entry.Name(), err) + } + digest := sha256.Sum256(data) + lines = append(lines, hex.EncodeToString(digest[:])+" "+entry.Name()) + } + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + func writeFakeGH(t *testing.T, path, version string) { t.Helper() script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" From 7664f04fdad60e680d6deeba3c29124dc46fb563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:50:14 +0800 Subject: [PATCH 009/267] fix(skills): preserve read-only backup trees --- internal/upgrade/skill_path_move.go | 56 +- .../upgrade/skill_path_move_errors_test.go | 485 ++++++++++++++++++ 2 files changed, 518 insertions(+), 23 deletions(-) create mode 100644 internal/upgrade/skill_path_move_errors_test.go diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 605f9056..2c28be90 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -18,6 +18,16 @@ var ( skillPathMkdirAll = os.MkdirAll skillPathMkdirTemp = os.MkdirTemp skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathReadDir = os.ReadDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the @@ -26,7 +36,7 @@ var ( // path before removing the source. Any failure before source removal leaves the // source intact; a removal failure deliberately leaves both verified copies. func moveSkillPathRecoverably(src, dst string) (err error) { - if _, statErr := os.Lstat(dst); statErr == nil { + if _, statErr := skillPathLstat(dst); statErr == nil { return fmt.Errorf("目标已存在: %s", dst) } else if !os.IsNotExist(statErr) { return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) @@ -62,7 +72,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathVerify(src, stage); err != nil { return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) } - stageInfo, err := os.Lstat(stage) + stageInfo, err := skillPathLstat(stage) if err != nil { return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) } @@ -108,7 +118,7 @@ func removePublishedSkillSource(src string) error { } removeErr := skillPathRemoveAll(src) if removeErr == nil { - if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) { return nil } else if statErr == nil { removeErr = errors.New("源路径仍存在") @@ -121,7 +131,7 @@ func removePublishedSkillSource(src string) error { func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { var modes []skillPathDirMode - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -152,7 +162,7 @@ func restoreSkillPathDirModes(modes []skillPathDirMode) error { var restoreErr error for i := len(modes) - 1; i >= 0; i-- { item := modes[i] - if _, err := os.Lstat(item.path); os.IsNotExist(err) { + if _, err := skillPathLstat(item.path); os.IsNotExist(err) { continue } else if err != nil { restoreErr = errors.Join(restoreErr, err) @@ -169,9 +179,9 @@ func makeSkillPathTreeWritable(root string) { // Best effort only: RemoveAll below remains the source of truth and its // error is returned. This preparation lets it traverse read-only staging // directories left by a copy or verification failure. - _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr == nil && entry.IsDir() { - _ = os.Chmod(path, 0o700) + _ = skillPathChmod(path, 0o700) } return nil }) @@ -184,7 +194,7 @@ func RestoreSkillPath(backup, original string) error { } func copySkillPathLexically(src, dst string) error { - info, err := os.Lstat(src) + info, err := skillPathLstat(src) if err != nil { return err } @@ -193,10 +203,10 @@ func copySkillPathLexically(src, dst string) error { // Staging must remain writable while children are copied. Restore the // source mode only after the directory is complete so read-only Skill // directories (for example 0555) can still be migrated lexically. - if err := os.Mkdir(dst, 0o700); err != nil { + if err := skillPathMkdir(dst, 0o700); err != nil { return err } - entries, err := os.ReadDir(src) + entries, err := skillPathReadDir(src) if err != nil { return err } @@ -207,11 +217,11 @@ func copySkillPathLexically(src, dst string) error { } return os.Chmod(dst, info.Mode().Perm()) case info.Mode()&os.ModeSymlink != 0: - target, err := os.Readlink(src) + target, err := skillPathReadlink(src) if err != nil { return err } - return os.Symlink(target, dst) + return skillPathSymlink(target, dst) case info.Mode().IsRegular(): return copyRegularSkillFile(src, dst, info.Mode().Perm()) default: @@ -220,31 +230,31 @@ func copySkillPathLexically(src, dst string) error { } func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { - in, err := os.Open(src) + in, err := skillPathOpen(src) if err != nil { return err } defer func() { err = errors.Join(err, in.Close()) }() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } defer func() { err = errors.Join(err, out.Close()) }() - if _, err := io.Copy(out, in); err != nil { + if _, err := skillPathCopyBytes(out, in); err != nil { return err } - if err := out.Sync(); err != nil { + if err := skillPathSync(out); err != nil { return err } return os.Chmod(dst, mode) } func verifySkillPathCopy(src, dst string) error { - srcInfo, err := os.Lstat(src) + srcInfo, err := skillPathLstat(src) if err != nil { return err } - dstInfo, err := os.Lstat(dst) + dstInfo, err := skillPathLstat(dst) if err != nil { return err } @@ -277,11 +287,11 @@ func verifySkillPathCopy(src, dst string) error { } return nil case srcInfo.Mode()&os.ModeSymlink != 0: - srcTarget, err := os.Readlink(src) + srcTarget, err := skillPathReadlink(src) if err != nil { return err } - dstTarget, err := os.Readlink(dst) + dstTarget, err := skillPathReadlink(dst) if err != nil { return err } @@ -314,7 +324,7 @@ func verifySkillPathCopy(src, dst string) error { } func skillPathEntryNames(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) + entries, err := skillPathReadDir(dir) if err != nil { return nil, err } @@ -327,13 +337,13 @@ func skillPathEntryNames(dir string) ([]string, error) { } func skillPathFileDigest(path string) ([sha256.Size]byte, error) { - f, err := os.Open(path) + f, err := skillPathOpen(path) if err != nil { return [sha256.Size]byte{}, err } defer f.Close() hash := sha256.New() - if _, err := io.Copy(hash, f); err != nil { + if _, err := skillPathCopyBytes(hash, f); err != nil { return [sha256.Size]byte{}, err } var digest [sha256.Size]byte diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go new file mode 100644 index 00000000..5c151934 --- /dev/null +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -0,0 +1,485 @@ +package upgrade + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { + t.Run("destination probe", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst { + return nil, os.ErrPermission + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination parent", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("staging creation", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("failed staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") }) + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing copied staging", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil }) + testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") { + t.Fatalf("error = %v", err) + } + }) + + for _, tc := range []struct { + name string + path func(src, dst string) string + want string + }{ + {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"}, + {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") { + return errors.New("chmod failed") + } + if tc.name == "restore published mode" && path == dst { + return errors.New("chmod failed") + } + return original(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v", err) + } + }) + } + + t.Run("published staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".cross-device-") { + return errors.New("cleanup failed") + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source deletion cannot be confirmed", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + removalAttempted := false + originalLstat := skillPathLstat + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + removalAttempted = true + return nil + } + return originalRemove(path) + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && removalAttempted { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("walk error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, nil, errors.New("walk failed")) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry info error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, skillPathErrorDirEntry{}, nil) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing restored directory", func(t *testing.T) { + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("restore probe error", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("restore chmod error", func(t *testing.T) { + path := t.TempDir() + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) { + t.Run("source lstat", func(t *testing.T) { + if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination mkdir", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copySkillPathLexically(src, dst); err == nil { + t.Fatal("expected mkdir failure") + } + }) + + t.Run("source readdir", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("nested copy", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "child" { + return nil, os.ErrPermission + } + return original(path) + }) + if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("readlink", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "link") + if err := os.Symlink("target", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("special path", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular open", func(t *testing.T) { + if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular destination open", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyRegularSkillFile(src, dst, 0o600); err == nil { + t.Fatal("expected exclusive create failure") + } + }) + + for _, tc := range []struct { + name string + swap func(*testing.T) + }{ + {name: "copy", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") }) + }}, + {name: "sync", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tc.swap(t) + if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { + root := t.TempDir() + + t.Run("source and destination lstat", func(t *testing.T) { + if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) { + t.Fatalf("source error = %v", err) + } + src := filepath.Join(root, "source") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) { + t.Fatalf("destination error = %v", err) + } + }) + + t.Run("directory and file modes", func(t *testing.T) { + for _, tc := range []struct { + name string + dir bool + }{ + {name: "directory", dir: true}, + {name: "file"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst") + if tc.dir { + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o755); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("directory read errors", func(t *testing.T) { + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("nested mismatch", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("symlink reads", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Symlink("one", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := os.Symlink("one", dst); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathReadlink + testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) { + if (path == dst) == failDest { + return "", os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("file digest opens", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathOpen + testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("special path", func(t *testing.T) { + fake := skillPathFakeInfo{mode: os.ModeNamedPipe} + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil }) + if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry names", func(t *testing.T) { + if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("digest open and read", func(t *testing.T) { + if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) { + t.Fatalf("open error = %v", err) + } + file := filepath.Join(root, "digest-file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") }) + if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + }) +} + +type skillPathFakeInfo struct { + mode os.FileMode +} + +func (skillPathFakeInfo) Name() string { return "fake" } +func (skillPathFakeInfo) Size() int64 { return 0 } +func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode } +func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} } +func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() } +func (skillPathFakeInfo) Sys() any { return nil } + +type skillPathErrorDirEntry struct{} + +func (skillPathErrorDirEntry) Name() string { return "broken" } +func (skillPathErrorDirEntry) IsDir() bool { return true } +func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } +func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } From 1dee02d900c776bc5e32b1adec74b2284008f1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:08:40 +0800 Subject: [PATCH 010/267] test(skills): make mode checks portable on Windows --- .../upgrade/skill_path_move_errors_test.go | 28 +++++++------------ internal/upgrade/skill_path_move_test.go | 6 ++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 5c151934..46b3a60c 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -313,28 +313,20 @@ func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { t.Run("directory and file modes", func(t *testing.T) { for _, tc := range []struct { name string - dir bool + src os.FileMode + dst os.FileMode }{ - {name: "directory", dir: true}, - {name: "file"}, + {name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755}, + {name: "file", src: 0o600, dst: 0o644}, } { t.Run(tc.name, func(t *testing.T) { - src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst") - if tc.dir { - if err := os.Mkdir(src, 0o700); err != nil { - t.Fatal(err) + src, dst := "src", "dst" + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src { + return skillPathFakeInfo{mode: tc.src}, nil } - if err := os.Mkdir(dst, 0o755); err != nil { - t.Fatal(err) - } - } else { - if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - } + return skillPathFakeInfo{mode: tc.dst}, nil + }) if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { t.Fatalf("error = %v", err) } diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 5567d1c2..4140fd6a 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -49,8 +49,10 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { t.Fatalf("copied file = %q, %v", got, err) } - if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil || info.Mode().Perm() != 0o640 { - t.Fatalf("copied file mode = %v, %v", info, err) + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil { + t.Fatalf("copied file stat = %v, %v", info, err) + } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm()) } if linksCreated { for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { From 7d16c9693f43e2e140fa04f24f80a8fcdac4915f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:22:03 +0800 Subject: [PATCH 011/267] test(skills): cover Windows permission preparation seams --- .../upgrade/skill_path_move_errors_test.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 46b3a60c..ca3c959d 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -134,6 +134,15 @@ func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { } func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("source preparation error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error { + return errors.New("prepare failed") + }) + if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") { + t.Fatalf("error = %v", err) + } + }) + t.Run("walk error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, nil, errors.New("walk failed")) @@ -143,6 +152,25 @@ func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) } }) + t.Run("writable mode preparation", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + var chmodMode os.FileMode + testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error { + chmodMode = mode + return nil + }) + modes, err := prepareSkillPathTreeRemoval("source") + if err != nil { + t.Fatal(err) + } + if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 { + t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes) + } + }) + t.Run("entry info error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, skillPathErrorDirEntry{}, nil) @@ -475,3 +503,14 @@ func (skillPathErrorDirEntry) Name() string { return "broken" } func (skillPathErrorDirEntry) IsDir() bool { return true } func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } + +type skillPathModeDirEntry struct { + mode os.FileMode +} + +func (skillPathModeDirEntry) Name() string { return "directory" } +func (skillPathModeDirEntry) IsDir() bool { return true } +func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() } +func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) { + return skillPathFakeInfo{mode: entry.mode}, nil +} From 0c0e2b3ce15aa9ab8acb7bc413c375269ee7a4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:32:20 +0800 Subject: [PATCH 012/267] test(skills): cover Windows chmod failure branch --- internal/upgrade/skill_path_move_errors_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index ca3c959d..b9d00850 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -171,6 +171,19 @@ func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) } }) + t.Run("writable mode preparation chmod error", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { + return errors.New("chmod failed") + }) + if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + }) + t.Run("entry info error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, skillPathErrorDirEntry{}, nil) From 9703a21a2d9ba7a2461be77c9e043952be7221b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Fri, 14 Aug 2026 10:43:50 +0800 Subject: [PATCH 013/267] fix(recruit): normalize connector list response --- internal/helpers/recruit.go | 17 +++++++++++++++++ internal/helpers/recruit_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 73997371..527615b2 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -169,6 +169,17 @@ func recruitListResultData(data any) (any, *output.Meta, error) { if !ok { return nil, nil, fmt.Errorf("list_jobs 返回值必须是 JSON 对象") } + // The recruit Connector returns the ATS service envelope unchanged: + // {success:true,result:{list:[...],hasMore:false,nextCursor:null}}. + // Keep accepting the historical flattened shape as well, but normalize the + // fixed Connector shape before enforcing the public CLI result contract. + if nested, exists := object["result"]; exists { + var nestedOK bool + object, nestedOK = nested.(map[string]any) + if !nestedOK { + return nil, nil, fmt.Errorf("list_jobs 返回值的 result 必须是 JSON 对象") + } + } hasMore, ok := object["hasMore"].(bool) if !ok { return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") @@ -196,6 +207,12 @@ func recruitListResultData(data any) (any, *output.Meta, error) { clean[key] = value } } + if jobs, exists := clean["list"]; exists { + if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized { + clean["jobs"] = jobs + } + delete(clean, "list") + } pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1} meta := &output.Meta{Pagination: pagination} if jobs, exists := clean["jobs"].([]any); exists { diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index bfcb3936..d290de21 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -336,6 +336,35 @@ func TestRecruitListResultData(t *testing.T) { if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { t.Fatalf("terminal meta = %#v, err = %v", terminal, err) } + + wrapped, wrappedMeta, err := recruitListResultData(map[string]any{ + "success": true, + "result": map[string]any{ + "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}}, + "hasMore": false, + "nextCursor": nil, + "totalCount": float64(1), + }, + }) + if err != nil { + t.Fatal(err) + } + wrappedData := wrapped.(map[string]any) + jobs, ok := wrappedData["jobs"].([]any) + if !ok || len(jobs) != 1 || wrappedData["totalCount"] != float64(1) { + t.Fatalf("wrapped data = %#v", wrappedData) + } + if _, exists := wrappedData["list"]; exists { + t.Fatalf("wrapped data retained Connector list field: %#v", wrappedData) + } + if wrappedMeta.Count == nil || *wrappedMeta.Count != 1 || !wrappedMeta.Pagination.EndpointExhausted { + t.Fatalf("wrapped meta = %#v", wrappedMeta) + } + + if _, _, err := recruitListResultData(map[string]any{"result": nil}); err == nil || + !strings.Contains(err.Error(), "result 必须是 JSON 对象") { + t.Fatalf("nil result error = %v", err) + } } func TestRecruitResultCallPropagatesMCPError(t *testing.T) { From 37cd6293352b6928da6ecd095dbd31e500be3338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:33:55 +0800 Subject: [PATCH 014/267] fix(skills): make app-bundle detection gate HOME-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in production: systemHome came from os.UserHomeDir, which honors the $HOME env override just like homeDir, so the two were always equal and the gate never fired when $HOME was overridden. ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix), which is independent of $HOME, falling back to $HOME only when the user record cannot be resolved. Production behavior is unchanged (a real $HOME still matches); an isolated/overridden HOME now correctly skips machine-wide /Applications discovery for zcode/minimax. The app surface references the same shared resolver. This is the correct fix for the hermeticity concern (machine-wide state leaking into an isolated HOME): there is no cross-surface production inconsistency to port — script installers always operate on the real user HOME in practice, so they need no gate. --- internal/app/skill_setup.go | 2 +- internal/upgrade/paths.go | 20 +++++++++++- internal/upgrade/system_home_test.go | 49 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 internal/upgrade/system_home_test.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 6c0becf4..2d2abf24 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = os.UserHomeDir + skillSetupSystemHomeDir = upgrade.ResolveSystemHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f0424ae6..d0edb495 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "os/user" "path/filepath" "runtime" "sort" @@ -139,9 +140,26 @@ var universalSkillDirs = map[string]bool{ ".amp/skills": true, } +// upgradeCurrentUser is the test seam backing ResolveSystemHomeDir. +var upgradeCurrentUser = user.Current + +// ResolveSystemHomeDir returns the operating-system user's real home directory +// independent of the $HOME environment variable. $HOME may be overridden in +// isolated or verifier contexts, so the app-bundle detection gate +// (allowSystemApps = homeDir == systemHome) must compare against a HOME- +// independent source to actually mean "operating on the real user profile". +// It prefers the OS user database (getpwuid on Unix) and falls back to $HOME +// only when the user record cannot be resolved. +func ResolveSystemHomeDir() (string, error) { + if u, err := upgradeCurrentUser(); err == nil && u.HomeDir != "" { + return u.HomeDir, nil + } + return os.UserHomeDir() +} + var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = os.UserHomeDir + upgradeSystemHomeDir = ResolveSystemHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir diff --git a/internal/upgrade/system_home_test.go b/internal/upgrade/system_home_test.go new file mode 100644 index 00000000..7870742f --- /dev/null +++ b/internal/upgrade/system_home_test.go @@ -0,0 +1,49 @@ +package upgrade + +import ( + "errors" + "os/user" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSystemHomeIndependentOfHOME pins that the app-bundle +// detection gate compares against a HOME-independent system home: ResolveSystemHomeDir +// prefers the OS user record (getpwuid on Unix) and only falls back to $HOME when that +// record cannot be resolved. Without this, allowSystemApps = homeDir == systemHome is a +// no-op whenever $HOME is overridden, because both sides would honor the same override. +func TestCrossPlatformCoverageSystemHomeIndependentOfHOME(t *testing.T) { + // Success path: the OS user database home is returned as-is. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return &user.User{HomeDir: "/tmp/dws-real-system-home"}, nil + }) + got, err := ResolveSystemHomeDir() + if err != nil || got != "/tmp/dws-real-system-home" { + t.Fatalf("ResolveSystemHomeDir success = (%q, %v), want /tmp/dws-real-system-home", got, err) + } + + // Fallback path: when the user record is unavailable, fall back to $HOME. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return nil, errors.New("user record unavailable") + }) + got, err = ResolveSystemHomeDir() + if err != nil { + t.Fatalf("ResolveSystemHomeDir fallback returned error: %v", err) + } + if got == "" { + t.Fatalf("ResolveSystemHomeDir fallback returned empty home") + } + + // Empty HomeDir is also treated as "unresolved" and falls back. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return &user.User{HomeDir: ""}, nil + }) + got, err = ResolveSystemHomeDir() + if err != nil { + t.Fatalf("ResolveSystemHomeDir empty-home fallback returned error: %v", err) + } + if got == "" { + t.Fatalf("ResolveSystemHomeDir empty-home fallback returned empty home") + } +} From 4f754133a5577453c43c81a7f216cfd12a455832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Fri, 14 Aug 2026 11:43:39 +0800 Subject: [PATCH 015/267] fix(recruit): align pagination and size contracts --- internal/app/schema_recruit_contract_test.go | 12 +++++++++ .../framework2_changed_lines_coverage_test.go | 7 +++++ internal/helpers/helpers.go | 11 +++++++- internal/helpers/recruit.go | 6 +++-- internal/helpers/recruit_test.go | 27 +++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/internal/app/schema_recruit_contract_test.go b/internal/app/schema_recruit_contract_test.go index 6c6c4a2c..ece8ceb9 100644 --- a/internal/app/schema_recruit_contract_test.go +++ b/internal/app/schema_recruit_contract_test.go @@ -54,6 +54,18 @@ func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) { if cursor["type"] != "string" || cursor["interface_type"] != "number" { t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor) } + size, _ := parameters["size"].(map[string]any) + if required, _ := size["required"].(bool); required { + t.Fatalf("size required = true, want false: %#v", size) + } + if size["default"] != "20" { + t.Fatalf("size default = %#v, want 20", size["default"]) + } + compactParameters, _ := compact["parameters"].(map[string]any) + compactSize, _ := compactParameters["size"].(map[string]any) + if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" { + t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize) + } } else if full["pagination"] != nil || compact["pagination"] != nil { t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"]) } diff --git a/internal/helpers/framework2_changed_lines_coverage_test.go b/internal/helpers/framework2_changed_lines_coverage_test.go index 00053300..b03fdc32 100644 --- a/internal/helpers/framework2_changed_lines_coverage_test.go +++ b/internal/helpers/framework2_changed_lines_coverage_test.go @@ -6,6 +6,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" @@ -58,6 +59,12 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { t.Fatalf("decoded data=%#v err=%v", data, err) } + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"cursor":9223372036854775807}`}}}}) + data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil) + if err != nil || data.(map[string]any)["cursor"] != json.Number("9223372036854775807") { + t.Fatalf("lossless number data=%#v err=%v", data, err) + } + InitDeps(&coverageErrorCaller{err: errors.New("transport failed")}) if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected transport error") diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 384ddf32..14435768 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -284,7 +285,15 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg return map[string]any{}, nil } var data any - if err := json.Unmarshal([]byte(text), &data); err != nil { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) } return data, nil diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 527615b2..ea0c95e0 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -100,7 +100,7 @@ func newRecruitJobListCommand() *cobra.Command { {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor}, - {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", ArgDefault: "20"}, + {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"}, }, Validate: validateRecruitList, ResultCall: recruitResultCall, @@ -189,6 +189,8 @@ func recruitListResultData(data any) (any, *output.Meta, error) { switch value := raw.(type) { case string: nextToken = strings.TrimSpace(value) + case json.Number: + nextToken = string(value) case float64: nextToken = strconv.FormatFloat(value, 'f', -1, 64) default: @@ -299,7 +301,7 @@ func recruitListParamDecls() []contract.ParamDecl { {Name: "keyword", Property: "param.keyword", InterfaceType: "string"}, {Name: "category", Property: "param.category", InterfaceType: "string"}, {Name: "cursor", Property: "cursor", InterfaceType: "number"}, - {Name: "size", Property: "size", Required: boolPtr(true), InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, } } diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index d290de21..e81d882e 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -132,6 +132,33 @@ func TestRecruitJobListCursorRoundTrip(t *testing.T) { } } +func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` + + data, err := CallMCPToolDataOnServer(context.Background(), recruitServerID, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + _, meta, err := recruitListResultData(data) + if err != nil { + t.Fatal(err) + } + if got := meta.Pagination.NextToken; got != "9223372036854775807" { + t.Fatalf("next token = %q, want lossless max int64", got) + } + + caller.text = `{"jobs":[],"hasMore":false}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("replayed cursor = %#v (%T), want max int64", got, got) + } +} + func TestRecruitJobListDefaultsAndValidation(t *testing.T) { caller := withRecruitCaller(t) cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) From 580c4d201b7443d8c07c8c751b7e20cffb0349e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:43:57 +0800 Subject: [PATCH 016/267] Revert "fix(skills): make app-bundle detection gate HOME-independent" This reverts commit 37cd6293352b6928da6ecd095dbd31e500be3338. --- internal/app/skill_setup.go | 2 +- internal/upgrade/paths.go | 20 +----------- internal/upgrade/system_home_test.go | 49 ---------------------------- 3 files changed, 2 insertions(+), 69 deletions(-) delete mode 100644 internal/upgrade/system_home_test.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 2d2abf24..6c0becf4 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = upgrade.ResolveSystemHomeDir + skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index d0edb495..f0424ae6 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "os" - "os/user" "path/filepath" "runtime" "sort" @@ -140,26 +139,9 @@ var universalSkillDirs = map[string]bool{ ".amp/skills": true, } -// upgradeCurrentUser is the test seam backing ResolveSystemHomeDir. -var upgradeCurrentUser = user.Current - -// ResolveSystemHomeDir returns the operating-system user's real home directory -// independent of the $HOME environment variable. $HOME may be overridden in -// isolated or verifier contexts, so the app-bundle detection gate -// (allowSystemApps = homeDir == systemHome) must compare against a HOME- -// independent source to actually mean "operating on the real user profile". -// It prefers the OS user database (getpwuid on Unix) and falls back to $HOME -// only when the user record cannot be resolved. -func ResolveSystemHomeDir() (string, error) { - if u, err := upgradeCurrentUser(); err == nil && u.HomeDir != "" { - return u.HomeDir, nil - } - return os.UserHomeDir() -} - var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = ResolveSystemHomeDir + upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir diff --git a/internal/upgrade/system_home_test.go b/internal/upgrade/system_home_test.go deleted file mode 100644 index 7870742f..00000000 --- a/internal/upgrade/system_home_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package upgrade - -import ( - "errors" - "os/user" - "testing" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" -) - -// TestCrossPlatformCoverageSystemHomeIndependentOfHOME pins that the app-bundle -// detection gate compares against a HOME-independent system home: ResolveSystemHomeDir -// prefers the OS user record (getpwuid on Unix) and only falls back to $HOME when that -// record cannot be resolved. Without this, allowSystemApps = homeDir == systemHome is a -// no-op whenever $HOME is overridden, because both sides would honor the same override. -func TestCrossPlatformCoverageSystemHomeIndependentOfHOME(t *testing.T) { - // Success path: the OS user database home is returned as-is. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return &user.User{HomeDir: "/tmp/dws-real-system-home"}, nil - }) - got, err := ResolveSystemHomeDir() - if err != nil || got != "/tmp/dws-real-system-home" { - t.Fatalf("ResolveSystemHomeDir success = (%q, %v), want /tmp/dws-real-system-home", got, err) - } - - // Fallback path: when the user record is unavailable, fall back to $HOME. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return nil, errors.New("user record unavailable") - }) - got, err = ResolveSystemHomeDir() - if err != nil { - t.Fatalf("ResolveSystemHomeDir fallback returned error: %v", err) - } - if got == "" { - t.Fatalf("ResolveSystemHomeDir fallback returned empty home") - } - - // Empty HomeDir is also treated as "unresolved" and falls back. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return &user.User{HomeDir: ""}, nil - }) - got, err = ResolveSystemHomeDir() - if err != nil { - t.Fatalf("ResolveSystemHomeDir empty-home fallback returned error: %v", err) - } - if got == "" { - t.Fatalf("ResolveSystemHomeDir empty-home fallback returned empty home") - } -} From bb6fd2f256d52537a12b0ac3ac765c3abe387f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:58:27 +0800 Subject: [PATCH 017/267] fix(skills): remove ineffective app detection gate --- internal/app/skill_setup.go | 17 ++++++--------- internal/app/skill_setup_canonical_test.go | 3 ++- internal/upgrade/canonical_skills_test.go | 5 +++-- internal/upgrade/paths.go | 25 +++++++++------------- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 6c0becf4..9a4ebfb8 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,6 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath @@ -1097,24 +1096,20 @@ func detectExistingAgentHomes(home, mode string) []string { dests := []string{canonical} canonicalKey := skillSetupPathKey(canonical) seen := map[string]bool{canonicalKey: true} - systemHome, _ := skillSetupSystemHomeDir() - allowSystemApps := sameSkillSetupPath(home, systemHome) addDetected := func(rel, base string) { detectedDir := filepath.Dir(base) switch filepath.ToSlash(filepath.Clean(rel)) { case ".config/kimchi/harness/skills", ".tabnine/agent/skills": detectedDir = filepath.Dir(filepath.Dir(base)) case ".zcode/skills": - if allowSystemApps { - if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { - detectedDir = "" - } + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so setup matches npm, Shell, and PowerShell. + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" } case ".minimax/skills": - if allowSystemApps { - if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { - detectedDir = "" - } + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" } } if detectedDir != "" { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index c308298b..1932e55b 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -101,8 +101,9 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * } func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. home := t.TempDir() - testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 17f27a27..56d22520 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -162,8 +162,9 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) } func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. home := t.TempDir() - testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -196,7 +197,7 @@ func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t for _, root := range configuredSkillRoots(home) { label := filepath.ToSlash(root.label) if _, ok := want[label]; ok { - want[label] = skillRootDetectedBase(home, root) + want[label] = skillRootDetectedBase(root) } } for label, detected := range want { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f0424ae6..7b0ecc49 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -141,7 +141,6 @@ var universalSkillDirs = map[string]bool{ var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir @@ -693,26 +692,22 @@ func configuredSkillRoots(homeDir string) []resolvedSkillRoot { return roots } -func skillRootDetectedBase(homeDir string, root resolvedSkillRoot) bool { +func skillRootDetectedBase(root resolvedSkillRoot) bool { detectedDir := filepath.Dir(root.base) - systemHome, _ := upgradeSystemHomeDir() - allowSystemApps := skillRootPathKey(homeDir) == skillRootPathKey(systemHome) switch filepath.ToSlash(filepath.Clean(root.label)) { case ".config/kimchi/harness/skills": detectedDir = filepath.Dir(filepath.Dir(root.base)) case ".tabnine/agent/skills": detectedDir = filepath.Dir(filepath.Dir(root.base)) case ".zcode/skills": - if allowSystemApps { - if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { - return true - } + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so upgrade matches npm, Shell, and PowerShell. + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true } case ".minimax/skills": - if allowSystemApps { - if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { - return true - } + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true } } info, err := upgradeStat(detectedDir) @@ -738,7 +733,7 @@ func skillRootPathKey(path string) string { func hasDependentSkillRoot(homeDir, canonicalBase string) bool { for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(homeDir, root) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root) { continue } if !samePhysicalSkillRoot(root.base, canonicalBase) { @@ -848,7 +843,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(homeDir, root) { + if !skillRootDetectedBase(root) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -920,7 +915,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(homeDir, root) { + if !skillRootDetectedBase(root) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } From d8f8f29062a4fe5a349cf9322cd554f744750f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Fri, 14 Aug 2026 14:06:08 +0800 Subject: [PATCH 018/267] fix(recruit): unwrap connector result envelopes --- internal/helpers/recruit.go | 70 +++++++++++----- internal/helpers/recruit_test.go | 139 ++++++++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 21 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index ea0c95e0..fae5c5b1 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -151,35 +151,67 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou if err != nil { return nil, err } - if tool != recruitListJobsTool { - return output.Success(data), nil - } - clean, meta, err := recruitListResultData(data) + clean, err := recruitBusinessResultData(data, tool) if err != nil { - return output.Failure(&output.ErrorInfo{ - Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(), - Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。", - }), nil + return recruitInvalidResponse(err), nil } - return output.Success(clean, output.WithMeta(meta)), nil + if tool != recruitListJobsTool { + return output.Success(clean), nil + } + listData, meta, err := recruitListResultData(clean) + if err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(listData, output.WithMeta(meta)), nil +} + +func recruitBusinessResultData(data any, tool string) (map[string]any, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool) + } + successValue, hasSuccess := object["success"] + resultValue, hasResult := object["result"] + if !hasSuccess && !hasResult { + return object, nil + } + if !hasSuccess || !hasResult { + return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool) + } + success, ok := successValue.(bool) + if !ok { + return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool) + } + if !success { + message, _ := object["message"].(string) + if strings.TrimSpace(message) == "" { + message = "Connector 返回 success=false" + } + return nil, fmt.Errorf("%s 调用失败: %s", tool, message) + } + result, ok := resultValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool) + } + return result, nil +} + +func recruitInvalidResponse(err error) output.CommandResult { + return output.Failure(&output.ErrorInfo{ + Type: "api", Subtype: "invalid_response", Message: err.Error(), + Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。", + }) } func recruitListResultData(data any) (any, *output.Meta, error) { - object, ok := data.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("list_jobs 返回值必须是 JSON 对象") + object, err := recruitBusinessResultData(data, recruitListJobsTool) + if err != nil { + return nil, nil, err } // The recruit Connector returns the ATS service envelope unchanged: // {success:true,result:{list:[...],hasMore:false,nextCursor:null}}. // Keep accepting the historical flattened shape as well, but normalize the // fixed Connector shape before enforcing the public CLI result contract. - if nested, exists := object["result"]; exists { - var nestedOK bool - object, nestedOK = nested.(map[string]any) - if !nestedOK { - return nil, nil, fmt.Errorf("list_jobs 返回值的 result 必须是 JSON 对象") - } - } hasMore, ok := object["hasMore"].(bool) if !ok { return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index e81d882e..a59b4f75 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -4,6 +4,7 @@ package helpers import ( + "bytes" "context" "encoding/json" stderrors "errors" @@ -134,7 +135,7 @@ func TestRecruitJobListCursorRoundTrip(t *testing.T) { func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) { caller := withRecruitCaller(t) - caller.text = `{"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` + caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` data, err := CallMCPToolDataOnServer(context.Background(), recruitServerID, recruitListJobsTool, map[string]any{"size": 20}) if err != nil { @@ -388,7 +389,7 @@ func TestRecruitListResultData(t *testing.T) { t.Fatalf("wrapped meta = %#v", wrappedMeta) } - if _, _, err := recruitListResultData(map[string]any{"result": nil}); err == nil || + if _, _, err := recruitListResultData(map[string]any{"success": true, "result": nil}); err == nil || !strings.Contains(err.Error(), "result 必须是 JSON 对象") { t.Fatalf("nil result error = %v", err) } @@ -403,6 +404,140 @@ func TestRecruitResultCallPropagatesMCPError(t *testing.T) { } } +func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) { + for _, test := range []struct { + name string + tool string + text string + want map[string]any + }{ + { + name: "get detail", + tool: recruitGetJobTool, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + want: map[string]any{"jobId": "job-detail", "name": "Java 工程师", "status": json.Number("1")}, + }, + { + name: "create job", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + want: map[string]any{"jobId": "job-created"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := CallMCPToolDataOnServer(context.Background(), recruitServerID, test.tool, map[string]any{}) + if err != nil { + t.Fatal(err) + } + clean, err := recruitBusinessResultData(data, test.tool) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(clean, test.want) { + t.Fatalf("normalized data = %#v, want %#v", clean, test.want) + } + if _, hasSuccess := clean["success"]; hasSuccess { + t.Fatalf("normalized data retained Connector envelope: %#v", clean) + } + if _, hasResult := clean["result"]; hasResult { + t.Fatalf("normalized data retained nested result: %#v", clean) + } + }) + } +} + +func TestRecruitCommandsPublishUnwrappedConnectorResult(t *testing.T) { + for _, test := range []struct { + name string + leaf func() *cobra.Command + args func(*testing.T) []string + text string + wantID string + }{ + { + name: "get detail", + leaf: newRecruitJobGetCommand, + args: func(*testing.T) []string { return []string{"--job-id", "job-detail"} }, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + wantID: "job-detail", + }, + { + name: "create job", + leaf: newRecruitJobCreateCommand, + args: func(t *testing.T) []string { + path, _ := writeRecruitJobFixture(t) + return []string{"--from", path, "--yes"} + }, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + wantID: "job-created", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("fields", "", "") + root.PersistentFlags().String("jq", "", "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(cmd) + return err + } + leaf := test.leaf() + root.AddCommand(leaf) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs(append([]string{leaf.Name()}, test.args(t)...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output %q: %v", stdout.String(), err) + } + if envelope.Data["jobId"] != test.wantID { + t.Fatalf("data = %#v, want top-level jobId %q", envelope.Data, test.wantID) + } + if _, exists := envelope.Data["success"]; exists { + t.Fatalf("public data retained Connector success: %#v", envelope.Data) + } + if _, exists := envelope.Data["result"]; exists { + t.Fatalf("public data retained Connector result: %#v", envelope.Data) + } + }) + } +} + +func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing result", data: map[string]any{"success": true}, want: "同时包含 success 和 result"}, + {name: "missing success", data: map[string]any{"result": map[string]any{}}, want: "同时包含 success 和 result"}, + {name: "invalid success", data: map[string]any{"success": "true", "result": map[string]any{}}, want: "success 必须是布尔值"}, + {name: "business failure", data: map[string]any{"success": false, "message": "职位不存在", "result": map[string]any{}}, want: "职位不存在"}, + {name: "business failure without message", data: map[string]any{"success": false, "result": map[string]any{}}, want: "Connector 返回 success=false"}, + {name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := recruitBusinessResultData(test.data, recruitGetJobTool); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { caller := withRecruitCaller(t) caller.text = `{"jobs":[],"hasMore":true}` From 7cfaa1ca7414c529a0825e0f3ea67f84d36e4823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 14:27:57 +0800 Subject: [PATCH 019/267] fix(skills): fail upgrade unconditionally when canonical publish fails A failed canonical publish only failed the upgrade when hasDependentSkillRoot reported a non-universal link target; that helper explicitly skipped universal agents, which are exactly the direct consumers of ~/.agents/skills. On a universal-only machine (e.g. only Codex installed), UpgradeSkillLocations* returned a nil error with nothing installed, contradicting the documented "canonical publication is mandatory and fails the upgrade loudly" contract. Canonical publish failures now return an error unconditionally in both the mono and multi branches, and hasDependentSkillRoot is removed. The test that pinned the old standalone-does-not-fail-fast behavior now asserts error propagation in both modes. --- internal/upgrade/canonical_skills_test.go | 17 ++++++------- internal/upgrade/paths.go | 29 +++++++---------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 56d22520..eee92fa4 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -263,10 +263,14 @@ func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t } } -func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) { +func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) { for _, mode := range []string{"mono", "multi"} { t.Run(mode, func(t *testing.T) { - home := withFakeHome(t) + withFakeHome(t) + // Canonical-only configuration (no non-universal dependents): a failed + // canonical publish must still fail the whole upgrade. Universal agents + // read canonical directly, so silently succeeding here would leave the + // machine without any usable Skill while reporting success. testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) @@ -279,15 +283,12 @@ func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents src = writeMultiBundle(t, src, "dingtalk-chat") } result, err := UpgradeSkillLocations(src) - if err != nil { - t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("canonical publish failure must propagate an error, got (%v)", err) } - if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { t.Fatalf("result = %#v", result) } - if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) { - t.Fatal("canonical-only configuration unexpectedly has a dependent root") - } }) } } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 7b0ecc49..1eab7cf1 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -731,18 +731,6 @@ func skillRootPathKey(path string) string { return clean } -func hasDependentSkillRoot(homeDir, canonicalBase string) bool { - for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root) { - continue - } - if !samePhysicalSkillRoot(root.base, canonicalBase) { - return true - } - } - return false -} - func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { @@ -826,10 +814,11 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e canonicalDest := filepath.Join(canonicalBase, "dws") if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) - if hasDependentSkillRoot(homeDir, canonicalBase) { - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) - } - return result, nil + // Canonical is the mandatory store: universal agents read it directly, so a + // failed canonical publish means nobody received the upgrade. Always fail + // loud — including universal-only machines — instead of reporting success + // with nothing installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) } result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) @@ -899,10 +888,10 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk canonicalBase := filepath.Join(homeDir, ".agents", "skills") if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) - if hasDependentSkillRoot(homeDir, canonicalBase) { - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) - } - return result, nil + // Canonical is the mandatory store (see the mono branch): fail loud even + // on universal-only machines instead of reporting success with nothing + // installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) } result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) From ccbacf84b37d1a29e516716e01d0d40a872e18a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Fri, 14 Aug 2026 14:36:49 +0800 Subject: [PATCH 020/267] test(helpers): cover trailing MCP JSON responses --- .../helpers/framework2_changed_lines_coverage_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/helpers/framework2_changed_lines_coverage_test.go b/internal/helpers/framework2_changed_lines_coverage_test.go index b03fdc32..b9488467 100644 --- a/internal/helpers/framework2_changed_lines_coverage_test.go +++ b/internal/helpers/framework2_changed_lines_coverage_test.go @@ -80,6 +80,16 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected invalid JSON error") } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {}`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "存在多个 JSON 值") { + t.Fatalf("multiple JSON values error = %v", err) + } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "解析 get_thing 返回失败") { + t.Fatalf("trailing invalid JSON error = %v", err) + } } func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) { From 53a71b08c7819e48510d2b8300fa0762b3dd509e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 15:35:25 +0800 Subject: [PATCH 021/267] fix(skills): protect shell link rollback from races --- scripts/install-skills.sh | 77 +++++++++++++++++++++-- scripts/install.sh | 83 +++++++++++++++++++++++-- test/scripts/install_script_test.go | 95 +++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 12 deletions(-) diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 4a7c486b..33f80429 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -238,6 +238,49 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then + rm -f "$_rls_dest" || _rls_ok=0 + else + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest" + _rls_ok=0 + fi + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + rm -f "$_cnsl_nested" + fi +} + # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir @@ -537,6 +580,7 @@ link_canonical_skills_to_base() { _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } if [ "$_lcs_mode" = "mono" ]; then @@ -553,7 +597,8 @@ link_canonical_skills_to_base() { if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j> "$_lcs_published" || { - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1; + _lcs_dest="$_lcs_base/$_lcs_name" + _lcs_stage_name="${_lcs_stage_token}.${_lcs_name}" + _lcs_staged="$_lcs_stage/$_lcs_stage_name" + _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi - printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name" + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + if ! rm -f "$_lcs_staged"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_dest" done rm -rf "$_lcs_stage" } diff --git a/scripts/install.sh b/scripts/install.sh index b494a402..a6586676 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -257,6 +257,49 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then + rm -f "$_rls_dest" || _rls_ok=0 + else + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rls_dest" + _rls_ok=0 + fi + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + rm -f "$_cnsl_nested" + fi +} + # publish_skill_cache # Copies a complete cache into a sibling staging directory, then publishes it # with rename. Copy/publish failures retain the previous cache; if restoration @@ -773,6 +816,7 @@ link_canonical_skills_to_base() { _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } if [ "$_lcs_mode" = "mono" ]; then @@ -791,7 +835,8 @@ link_canonical_skills_to_base() { fi _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j> "$_lcs_published" || { - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + _lcs_dest="$_lcs_base/$_lcs_name" + _lcs_stage_name="${_lcs_stage_token}.${_lcs_name}" + _lcs_staged="$_lcs_stage/$_lcs_stage_name" + _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 fi - say " ↪ Skills → $_lcs_base/$_lcs_name" + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! rm -f "$_lcs_staged"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_dest" done rm -rf "$_lcs_stage" } diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 44620d0a..d23f5e44 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1503,6 +1503,101 @@ func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { } } +func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +ln() { + if [ "$1" = -P ] && [ "$3" = "$DWS_TEST_SECOND" ]; then + rm -f "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" + printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" + fi + command ln "$@" +} +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then + exit 2 +fi +identity_dest="$DWS_TEST_BASE/identity-link" +identity_manifest="$HOME/identity.manifest" +command ln -s ../same-target "$identity_dest" +identity_inode="$(skill_link_inode "$identity_dest")" +printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" +rm -f "$identity_dest" +command ln -s ../same-target "$identity_dest" +if restore_linked_skill_set "$identity_manifest" /dev/null; then + exit 3 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not report identity-protected rollback:\n%s", scriptName, output) + } + identityDest := filepath.Join(base, "identity-link") + if target, err := os.Readlink(identityDest); err != nil || target != "../same-target" { + t.Fatalf("%s replacement link was removed: target=%q, err=%v", scriptName, target, err) + } + for path, want := range map[string]string{ + filepath.Join(first, "user.txt"): "first-user-data\n", + filepath.Join(second, "user.txt"): "second-user-data\n", + } { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s concurrent data at %s = %q, err=%v", scriptName, path, got, err) + } + } + for _, dir := range []string{first, second} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("%s read concurrent directory %s: %v", scriptName, dir, err) + } + if len(entries) != 1 || entries[0].Name() != "user.txt" { + t.Fatalf("%s transaction artifacts remain in %s: %v", scriptName, dir, entries) + } + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-link-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { From 175b51cec018e93be85e3a02cfd27ad78cfabfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 15:45:51 +0800 Subject: [PATCH 022/267] fix(skills): retain link identity anchors through rollback --- scripts/install-skills.sh | 21 ++++++++++----------- scripts/install.sh | 27 ++++++++++++--------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 33f80429..c5b9a276 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -276,7 +276,8 @@ restore_linked_skill_set() { cleanup_nested_staged_link() { _cnsl_nested="$1/$2" _cnsl_target="$3" - if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then rm -f "$_cnsl_nested" fi } @@ -618,20 +619,18 @@ link_canonical_skills_to_base() { _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } + # Keep the staged hardlink alive until the whole transaction finishes. It + # pins the inode, so a concurrently recreated link cannot reuse the + # identity recorded in the publication manifest. + _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } # Hard-link the staged symlink itself. This never overwrites a # concurrently-created destination. If that destination became a # directory, the unique staged basename can only be linked inside it; # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 - fi - _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { - if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 - } - if ! rm -f "$_lcs_staged"; then - if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then diff --git a/scripts/install.sh b/scripts/install.sh index a6586676..45e5a688 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -295,7 +295,8 @@ restore_linked_skill_set() { cleanup_nested_staged_link() { _cnsl_nested="$1/$2" _cnsl_target="$3" - if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then rm -f "$_cnsl_nested" fi } @@ -861,24 +862,20 @@ link_canonical_skills_to_base() { rm -rf "$_lcs_stage" return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true - rm -rf "$_lcs_stage" - return 1 - fi - _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { - if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + # Keep the staged hardlink alive until the whole transaction finishes. It + # pins the inode, so a concurrently recreated link cannot reuse the + # identity recorded in the publication manifest. + _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - if ! rm -f "$_lcs_staged"; then - if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 From ce73a5b4529c0df1de34b6397e6eb146fa35f8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:09:24 +0800 Subject: [PATCH 023/267] test(skills): retain rollback identity anchor --- test/scripts/install_script_test.go | 212 +++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 2 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d23f5e44..fdbe8b5d 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,6 +226,7 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -242,6 +243,210 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required []string + forbidden []string + }{ + { + filepath.Join("..", "..", "scripts", "install.ps1"), + []string{ + "function Remove-LinkStageRoot", + // Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse; published junctions must be removed + // lexically so rollback never deletes canonical content. + "Remove-SkillPathLexically -Path $Published[$i]", + "Remove-LinkStageRoot -StageRoot $stageRoot", + }, + []string{"Remove-Item -LiteralPath $Published[$i] -Recurse -Force"}, + }, + { + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), + []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + }, + []string{}, + }, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + for _, want := range check.required { + if !strings.Contains(text, want) { + t.Errorf("%s missing junction-safe removal contract %q", check.path, want) + } + } + for _, avoid := range check.forbidden { + if strings.Contains(text, avoid) { + t.Errorf("%s still removes junction-capable rollback paths recursively: %q", check.path, avoid) + } + } + } +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$link = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $link -Target $canonical | Out-Null +if (Test-SamePhysicalSkillRoot -Left $link -Right $canonical) { exit 0 } +exit 3 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := probeCmd.CombinedOutput(); err != nil { + t.Fatalf("junction must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(output)) + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1544,15 +1749,18 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_dest" -identity_inode="$(skill_link_inode "$identity_dest")" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi +rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From 465acf14066460e1fc310485e5138504eabff77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:11:13 +0800 Subject: [PATCH 024/267] Revert "test(skills): retain rollback identity anchor" This reverts commit ce73a5b4529c0df1de34b6397e6eb146fa35f8d2. --- test/scripts/install_script_test.go | 212 +--------------------------- 1 file changed, 2 insertions(+), 210 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index fdbe8b5d..d23f5e44 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,7 +226,6 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, - {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -243,210 +242,6 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } -func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { - t.Parallel() - - checks := []struct { - path string - required []string - forbidden []string - }{ - { - filepath.Join("..", "..", "scripts", "install.ps1"), - []string{ - "function Remove-LinkStageRoot", - // Windows PowerShell 5.1 follows reparse points during - // Remove-Item -Recurse; published junctions must be removed - // lexically so rollback never deletes canonical content. - "Remove-SkillPathLexically -Path $Published[$i]", - "Remove-LinkStageRoot -StageRoot $stageRoot", - }, - []string{"Remove-Item -LiteralPath $Published[$i] -Recurse -Force"}, - }, - { - filepath.Join("..", "..", "scripts", "install-devapp.ps1"), - []string{ - "function Remove-DevLinkStageRoot", - "Remove-DevLinkStageRoot $stageRoot", - }, - []string{}, - }, - } - for _, check := range checks { - data, err := os.ReadFile(check.path) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", check.path, err) - } - text := string(data) - for _, want := range check.required { - if !strings.Contains(text, want) { - t.Errorf("%s missing junction-safe removal contract %q", check.path, want) - } - } - for _, avoid := range check.forbidden { - if strings.Contains(text, avoid) { - t.Errorf("%s still removes junction-capable rollback paths recursively: %q", check.path, avoid) - } - } - } -} - -func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { - t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("POSIX shell installer test is for unix-like hosts") - } - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skipf("unsupported test os %s", runtime.GOOS) - } - if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { - t.Skipf("unsupported test arch %s", runtime.GOARCH) - } - - root := t.TempDir() - fakeHome := filepath.Join(root, "home") - installDir := filepath.Join(root, "bin") - releaseDir := filepath.Join(root, "release") - stubRoot := filepath.Join(root, "stubs") - - assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" - if err := os.MkdirAll(releaseDir, 0o755); err != nil { - t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) - } - writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ - "dws": "fake-event-binary\n", - }) - writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ - "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", - "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", - "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", - "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", - }) - writeInstallerFixtureChecksums(t, releaseDir) - writeFakeCurl(t, filepath.Join(stubRoot, "curl")) - - // A regular file where an agent skill base belongs makes that one agent - // target uninstallable, while later agents (.zcode) stay reachable. - mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) - mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) - - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) - if err != nil { - t.Fatalf("Abs(install-event.sh) error = %v", err) - } - cmd := exec.Command("sh", scriptPath) - cmd.Env = append(os.Environ(), - "HOME="+fakeHome, - "PATH="+stubRoot+":"+os.Getenv("PATH"), - "EVENT_VERSION=v1.0.51", - "DWS_INSTALL_DIR="+installDir, - "FAKE_RELEASE_DIR="+releaseDir, - "FAKE_ASSET_NAME="+assetName, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) - } - got := string(output) - for _, want := range []string{ - "Agent 目标安装失败,已跳过", - "dingtalk-event 分发到 Agent 目录失败", - } { - if !strings.Contains(got, want) { - t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) - } - } - // The failed agent must not abort the loop: later agents still get links. - if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { - t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) - } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { - t.Fatalf("canonical Skill missing: %v", err) - } -} - -func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { - pwsh, err := exec.LookPath("pwsh") - if err != nil { - if runtime.GOOS == "windows" { - pwsh, err = exec.LookPath("powershell") - } - if err != nil { - t.Skip("PowerShell is not available") - } - } - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(scriptPath) - if err != nil { - t.Fatal(err) - } - cut := strings.LastIndex(string(data), "# ── Main") - if cut < 0 { - t.Fatal("install.ps1 main section not found") - } - prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") - probe := prefix + ` -$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' -New-Item -ItemType Directory -Path $canonical -Force | Out-Null -$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' -New-Item -ItemType Directory -Path $linkParent -Force | Out-Null -$link = Join-Path $linkParent 'dingtalk-chat' -New-Item -ItemType Junction -Path $link -Target $canonical | Out-Null -if (Test-SamePhysicalSkillRoot -Left $link -Right $canonical) { exit 0 } -exit 3 -` - probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") - mustWriteFile(t, probePath, []byte(probe), 0o644) - home := t.TempDir() - probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) - probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) - if output, err := probeCmd.CombinedOutput(); err != nil { - t.Fatalf("junction must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(output)) - } - - // A rerun over an already-linked agent base must be idempotent: the - // junction is recognized as published and never re-backed-up. - install := prefix + ` -if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } -exit 0 -` - installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") - mustWriteFile(t, installPath, []byte(install), 0o644) - multi := filepath.Join(t.TempDir(), "multi") - mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) - mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) - - runInstall := func() string { - cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) - cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) - } - return string(output) - } - countBackups := func() int { - entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) - if err != nil { - return 0 - } - return len(entries) - } - runInstall() - first := countBackups() - runInstall() - if second := countBackups(); second != first { - t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) - } - linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) - if err != nil || string(linkData) != "new chat\n" { - t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) - } -} - func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1749,18 +1544,15 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" -identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_anchor" -command ln -P "$identity_anchor" "$identity_dest" -identity_inode="$(skill_link_inode "$identity_anchor")" +command ln -s ../same-target "$identity_dest" +identity_inode="$(skill_link_inode "$identity_dest")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi -rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From 618eb842a2fd4f6bb9d763a211c1e7e50c9c574f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:11:48 +0800 Subject: [PATCH 025/267] test(skills): retain rollback identity anchor --- test/scripts/install_script_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d23f5e44..f05ff704 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1544,15 +1544,18 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_dest" -identity_inode="$(skill_link_inode "$identity_dest")" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi +rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From 3556d28fdd01fbf49a0bd04e28c197798ca3c3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:47:28 +0800 Subject: [PATCH 026/267] fix(skills): junction-safe PowerShell rollback and per-agent degrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.ps1: remove published junctions lexically in Restore-MultiSkillSet (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse and could delete canonical store contents); clean link staging dirs lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably - install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns recognize already-published junctions instead of backup churn - install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with the loud backup-retained failure contract already enforced for devapp - event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade per agent like install.sh (skip loudly, continue, report at the end) instead of aborting mid-loop or swallowing errors - tests: junction-lexical removal contract, event per-agent degrade integration test, pwsh junction physical-root recognition + rerun idempotency (no backup churn) --- scripts/install-devapp.ps1 | 49 +++++++++++++++++++++++---- scripts/install-devapp.sh | 32 +++++++++++++++--- scripts/install-event.sh | 51 +++++++++++++++++++++++----- scripts/install.ps1 | 69 +++++++++++++++++++++++++++++++++----- 4 files changed, 173 insertions(+), 28 deletions(-) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index e8cc3bce..ce88a4a3 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -202,6 +202,20 @@ function Remove-DevSkillPathLexically([string]$Path) { } } +# Removes a staging directory that may hold junction/symlink children. Link +# children must be removed non-recursively first: Windows PowerShell 5.1 can +# follow reparse points during Remove-Item -Recurse and would delete the +# canonical store's contents through the staged link. +function Remove-DevLinkStageRoot([string]$StageRoot) { + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false } + } + try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false } + return $ok +} + function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } $destinationParent = Split-Path $Destination -Parent @@ -220,7 +234,9 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { Assert-DevSkillPathCopy $Source $stage Move-DevSkillPath $stage $Destination Assert-DevSkillPathCopy $Source $Destination - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "Skill staging cleanup failed (published content untouched): $stageRoot" + } try { Remove-DevSkillPathLexically $Source } catch { throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" } @@ -228,8 +244,8 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { } catch { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { - try { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop } catch { - throw "$failure; cross-device Skill staging cleanup failed $stageRoot`: $_" + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" } } throw $failure @@ -276,7 +292,7 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { throw $publishFailure } } finally { - if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + Remove-DevLinkStageRoot $stageRoot | Out-Null } } @@ -302,7 +318,10 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { throw $publishFailure } } finally { - if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + # The staged payload is a junction to the canonical store; it must be + # removed lexically or Windows PowerShell 5.1 recursion could delete + # canonical content through the reparse point. + Remove-DevLinkStageRoot $stageRoot | Out-Null } } @@ -404,6 +423,7 @@ if (-not $NoSkills) { $canonical = Join-Path $canonicalBase $SkillName Publish-DevSkillCopy $src $canonical $installed = 1 + $failed = 0 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { $parts = $row.Split('|'); $universal = $parts[1] -eq "1" @@ -413,12 +433,27 @@ if (-not $NoSkills) { if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName - if ($universal) { Backup-DevSkill $dest; continue } + if ($universal) { + try { Backup-DevSkill $dest } catch { + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录: $dest ($($_.Exception.Message))" + $failed++ + } + continue + } New-Item -ItemType Directory -Path $base -Force | Out-Null + # Per-agent degrade like install.sh: a failed link→copy fallback + # skips that agent loudly instead of aborting the whole loop. try { Publish-DevSkillJunction $canonical $dest } - catch { Publish-DevSkillCopy $canonical $dest } + catch { + try { Publish-DevSkillCopy $canonical $dest } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue + } + } $installed++ } + if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 70a631ad..698d9048 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -128,7 +128,14 @@ link_or_copy_skill() { target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi rm -rf "$stage" @@ -198,14 +205,31 @@ install_skill() { canonical="$HOME/.agents/skills/$SKILL_NAME" copy_tree "$src" "$canonical" || return 1 installed=1 + failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue - if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi - link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 + if is_cleanup_only_agent_dir "$agent_dir"; then + if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + continue + fi installed=$((installed + 1)) done + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 + return 1 + fi say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } @@ -248,7 +272,7 @@ main() { mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" - install_skill "$tmp/sk" + install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警" else say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)" fi diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 58c56cda..6ff57fc9 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -121,7 +121,9 @@ copy_tree() { backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage" "$dest"; then return 0; fi rm -rf "$stage" - [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -195,10 +197,20 @@ link_or_copy_skill() { target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -208,14 +220,31 @@ install_skill_to_homes() { canonical="$HOME/.agents/skills/$skill_name" copy_tree "$src" "$canonical" || return 1 installed=1 + failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue - if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi - link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 + if is_cleanup_only_agent_dir "$agent_dir"; then + if ! backup_skill_dir "$base/$skill_name" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) + continue + fi installed=$((installed + 1)) done + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 + return 1 + fi printf '%s\n' "$installed" } @@ -276,10 +305,14 @@ install_skills_from_bundle() { mono_cache="$HOME/.dws/skills/mono" copy_tree "$mono_src" "$mono_cache" - event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" - shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" - misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" - mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" + event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ + || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \ + || err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \ + || err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \ + || err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 795ee1be..50540929 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -370,6 +370,52 @@ function Remove-SkillPathLexically { } } +# Removes a staging directory that may hold unpublished junction/symlink +# children. Every link child must be removed non-recursively first: Windows +# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points +# during Remove-Item -Recurse and would delete the canonical store's contents +# through the staged link. CI runs pwsh 7, which masks this behavior. +function Remove-LinkStageRoot { + param([string]$StageRoot) + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false } + } + try { + Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop + } catch { + $ok = $false + } + return $ok +} + +# Resolves a path to its physical location, dereferencing junction/symlink +# reparse points at any depth. Resolve-Path alone is lexical and never +# recognizes DWS's own junctions, so every rerun would back them up and +# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P +# (shell) on the other installer surfaces. +function Get-PhysicalSkillPath { + param([string]$Path, [int]$Depth = 0) + if ($Depth -gt 40) { return $null } + $parent = Split-Path $Path -Parent + if ([string]::IsNullOrWhiteSpace($parent)) { return $Path } + $parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1) + if ($null -eq $parentPhysical) { return $null } + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical } + $candidate = Join-Path $parentPhysical $leaf + try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } + $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 + if ($item.LinkType -and $target) { + if ([System.IO.Path]::IsPathRooted([string]$target)) { + return ([string]$target).TrimEnd([char[]]@('\', '/')) + } + return [System.IO.Path]::GetFullPath((Join-Path $parentPhysical ([string]$target))) + } + return $candidate +} + # Same-volume moves remain atomic. For a cross-volume backup/restore, stage on # the destination filesystem, copy links lexically, verify, publish, then # remove the source. Before source removal every failure keeps the original; @@ -394,7 +440,9 @@ function Move-SkillPathRecoverably { Assert-SkillPathCopy -Source $Source -Destination $stage Move-SkillPath -Source $stage -Destination $Destination Assert-SkillPathCopy -Source $Source -Destination $Destination - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "Skill staging 清理失败(已发布内容不受影响): $stageRoot" + } try { Remove-SkillPathLexically -Path $Source } catch { @@ -406,10 +454,8 @@ function Move-SkillPathRecoverably { } catch { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { - try { - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop - } catch { - throw "$failure;跨设备 Skill staging 清理失败 $stageRoot`: $_" + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" } } throw $failure @@ -686,7 +732,11 @@ function Restore-MultiSkillSet { for ($i = $Published.Count - 1; $i -ge 0; $i--) { try { if (Test-SkillPathLexically -Path $Published[$i]) { - Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop + # Junction publications must never be removed with -Recurse: + # Windows PowerShell 5.1 can follow the reparse point and + # delete the canonical store's contents. Link targets remove + # lexically; copied directories still remove recursively. + Remove-SkillPathLexically -Path $Published[$i] } } catch { Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" @@ -772,7 +822,10 @@ function Test-AgentSkillBaseDetected { function Test-SamePhysicalSkillRoot { param([string]$Left, [string]$Right) if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } - try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false } + $leftPhysical = Get-PhysicalSkillPath -Path $Left + $rightPhysical = Get-PhysicalSkillPath -Path $Right + if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false } + return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/')) } function Move-AgentSkillRootToBackup { @@ -856,7 +909,7 @@ function Publish-CanonicalSkillLinks { Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" return $false } finally { - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null } } From fc455f800c240196ae1d24fa703ce20416bacccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 17:14:13 +0800 Subject: [PATCH 027/267] test(skills): junction-safe rollback and per-agent degrade regressions - extend the silent-rollback contract to install-event.sh - static contract: Restore-MultiSkillSet removes published paths lexically (section-scoped so identity-anchor refactors keep the guarantee) and link staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot - install-event.sh integration test: an uninstallable agent target is skipped loudly while later agents still receive links - pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and symlinks (junction idempotency asserted where junctions are creatable) --- test/scripts/install_script_test.go | 313 ++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index f05ff704..d69846ad 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,6 +226,7 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -242,6 +243,318 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + installPs1 := filepath.Join("..", "..", "scripts", "install.ps1") + data, err := os.ReadFile(installPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", installPs1, err) + } + text := string(data) + for _, want := range []string{ + "function Remove-LinkStageRoot", + "Remove-LinkStageRoot -StageRoot $stageRoot", + } { + if !strings.Contains(text, want) { + t.Errorf("install.ps1 missing junction-safe removal contract %q", want) + } + } + // The rollback of published junction paths must go through the lexical + // remover: Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse and could delete the canonical store's contents. + // Assert on the Restore-MultiSkillSet section so identity-anchor + // refactors keep the contract without breaking on variable naming. + begin := strings.Index(text, "function Restore-MultiSkillSet") + if begin < 0 { + t.Fatal("install.ps1 missing Restore-MultiSkillSet") + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + t.Fatal("install.ps1 Restore-MultiSkillSet section not terminated") + } + restore := text[begin : begin+1+end] + if !strings.Contains(restore, "Remove-SkillPathLexically") { + t.Errorf("install.ps1 Restore-MultiSkillSet must remove published paths lexically") + } + if strings.Contains(restore, "Remove-Item") && strings.Contains(restore, "-Recurse") { + t.Errorf("install.ps1 Restore-MultiSkillSet still contains a recursive removal:\n%s", restore) + } + + devappPs1 := filepath.Join("..", "..", "scripts", "install-devapp.ps1") + devappData, err := os.ReadFile(devappPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", devappPs1, err) + } + for _, want := range []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + } { + if !strings.Contains(string(devappData), want) { + t.Errorf("install-devapp.ps1 missing junction-safe removal contract %q", want) + } + } +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + // Prefer the advertised Windows PowerShell 5.1 irm|iex surface on Windows; + // pwsh 7 can mask junction removal and resolution differences. + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Probe Test-SamePhysicalSkillRoot with both link flavors: junctions need + // no privilege on Windows but silently no-op on non-Windows pwsh builds; + // symlinks need no privilege on Unix but may fail without Developer Mode + // on Windows. Exit 4 marks "no link flavor testable on this host". + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$other = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-shared' +New-Item -ItemType Directory -Path $other -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$asserted = 0 +$junction = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $junction) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } +} +$symlink = Join-Path $linkParent 'dingtalk-shared' +New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $symlink) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } +} +if ($asserted -eq 0) { exit 4 } +if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } +if (Test-Path -LiteralPath $junction) { exit 0 } +exit 4 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + probeOutput, probeErr := probeCmd.CombinedOutput() + if probeErr != nil { + exitCode := 0 + if exitErr, ok := probeErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + if exitCode != 4 { + t.Fatalf("link must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(probeOutput)) + } + return // junctions unavailable on this host (non-Windows pwsh): probed via symlink only + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + +func TestInstallPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$root = $env:DWS_TEST_HOME +$stage = Join-Path $root 'stage-payload' +$dest = Join-Path $root 'agent\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $stage -Force | Out-Null +New-Item -ItemType Directory -Path $dest -Force | Out-Null +Set-Content -LiteralPath (Join-Path $dest 'user-data.txt') -Value 'keep' +try { + Move-SkillPath -Source $stage -Destination $dest + exit 11 +} catch {} +if (!(Test-Path -LiteralPath $stage -PathType Container)) { exit 12 } +if (Test-Path -LiteralPath (Join-Path $dest 'stage-payload')) { exit 13 } +if (!(Test-Path -LiteralPath (Join-Path $dest 'user-data.txt') -PathType Leaf)) { exit 14 } + +# Simulate a later failure after publication, with another process replacing +# our link before rollback. Identity-aware rollback must retain its directory. +$canonical = Join-Path $root '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$publishedPath = Join-Path $root 'published-link' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +New-Item -ItemType $linkType -Path $publishedPath -Target $canonical -ErrorAction Stop | Out-Null +$record = New-PublishedSkillLinkRecord -Path $publishedPath +Remove-SkillPathLexically -Path $publishedPath +New-Item -ItemType Directory -Path $publishedPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -Value 'keep' +$restored = Restore-MultiSkillSet -Published @($record) -Backups @() +if ($restored) { exit 15 } +if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell publication race must retain concurrent directory: %v\n%s", err, string(output)) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() From 34dee968337f5d54981eef9d256f4afe594a0f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 17:51:12 +0800 Subject: [PATCH 028/267] fix(skills): make PowerShell rollback race-safe --- scripts/install-devapp.ps1 | 97 ++++++++++++++++++++---- scripts/install.ps1 | 111 ++++++++++++++++++++++++---- test/scripts/install_script_test.go | 94 +++++++++++++++++++++++ 3 files changed, 275 insertions(+), 27 deletions(-) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index ce88a4a3..1719391d 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -110,7 +110,13 @@ function Test-PathLexically([string]$path) { } function Move-DevSkillPath([string]$Source, [string]$Destination) { - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Move($Source, $Destination) + } } function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { @@ -263,8 +269,60 @@ function Backup-DevSkill([string]$path, [ref]$BackupPath) { if ($null -ne $BackupPath) { $BackupPath.Value = $target } } -function Restore-DevSkillBackup([string]$Destination, [string]$Backup) { - if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } +function Get-DevSkillLinkSignature([string]$Path) { + try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null } + if (!$item.LinkType) { return $null } + return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0")) +} + +function New-DevRollbackFailure([string]$Message) { + $failure = [System.InvalidOperationException]::new($Message) + $failure.Data["DWSRollbackFailed"] = $true + return $failure +} + +function Remove-VerifiedDevSkillPublication( + [string]$Destination, + [string]$PublishedSource, + [string]$PublishedLinkSignature +) { + $parent = Split-Path $Destination -Parent + $quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N")) + Move-DevSkillPath $Destination $quarantine + try { + if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) { + if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) { + throw "published Skill link was replaced by another process: $Destination" + } + } elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) { + Assert-DevSkillPathCopy $PublishedSource $quarantine + } else { + throw "Skill restore destination is occupied by another process: $Destination" + } + Remove-DevSkillPathLexically $quarantine + } catch { + $failure = $_ + if (Test-PathLexically $quarantine) { + try { + if (Test-PathLexically $Destination) { throw "original path is occupied" } + Move-DevSkillPath $quarantine $Destination + } catch { + throw "$failure; concurrent object retained at $quarantine`: $_" + } + } + throw $failure + } +} + +function Restore-DevSkillBackup( + [string]$Destination, + [string]$Backup, + [string]$PublishedSource = "", + [string]$PublishedLinkSignature = "" +) { + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature + } if (![string]::IsNullOrWhiteSpace($Backup)) { Move-DevSkillPathRecoverably $Backup $Destination } @@ -276,6 +334,7 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" $backup = "" + $publishedSource = "" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null try { Copy-DevSkillPathLexically $Source $stage @@ -283,11 +342,12 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { Backup-DevSkill $Destination ([ref]$backup) try { Move-DevSkillPath $stage $Destination + $publishedSource = $Source Assert-DevSkillPathCopy $Source $Destination } catch { $publishFailure = $_ - try { Restore-DevSkillBackup $Destination $backup } catch { - throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") } throw $publishFailure } @@ -302,6 +362,7 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" $backup = "" + $publishedSignature = "" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null try { New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null @@ -310,10 +371,12 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { Move-DevSkillPath $stage $Destination $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + $publishedSignature = Get-DevSkillLinkSignature $Destination + if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" } } catch { $publishFailure = $_ - try { Restore-DevSkillBackup $Destination $backup } catch { - throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") } throw $publishFailure } @@ -440,16 +503,22 @@ if (-not $NoSkills) { } continue } - New-Item -ItemType Directory -Path $base -Force | Out-Null # Per-agent degrade like install.sh: a failed link→copy fallback # skips that agent loudly instead of aborting the whole loop. - try { Publish-DevSkillJunction $canonical $dest } - catch { - try { Publish-DevSkillCopy $canonical $dest } catch { - Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" - $failed++ - continue + try { + New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null + try { Publish-DevSkillJunction $canonical $dest } + catch { + # A failed rollback leaves the old backup as the only + # trusted copy. Do not run a second publisher over that + # state; surface the failure and continue other agents. + if ($_.Exception.Data["DWSRollbackFailed"]) { throw } + Publish-DevSkillCopy $canonical $dest } + } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue } $installed++ } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 50540929..4cda9a7d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -258,7 +258,19 @@ function Move-SkillPath { [string]$Source, [string]$Destination ) - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-SkillPathLexically -Path $Destination) { + throw "Skill move destination already exists: $Destination" + } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + # Move-Item treats an existing directory as a container and nests the + # source below it. Directory.Move has the exact rename semantics this + # transaction requires: an occupied destination fails without touching + # either path. + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Move($Source, $Destination) + } } function Test-CrossDeviceMoveError { @@ -408,10 +420,16 @@ function Get-PhysicalSkillPath { try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 if ($item.LinkType -and $target) { + $targetPath = "" if ([System.IO.Path]::IsPathRooted([string]$target)) { - return ([string]$target).TrimEnd([char[]]@('\', '/')) + $targetPath = [string]$target + } else { + $targetPath = Join-Path $parentPhysical ([string]$target) } - return [System.IO.Path]::GetFullPath((Join-Path $parentPhysical ([string]$target))) + # Resolve the target recursively as well. A custom HOME or canonical + # root can itself sit below another junction, which must compare equal + # to the fully physical path just like EvalSymlinks/realpath do. + return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1) } return $candidate } @@ -723,6 +741,68 @@ function Backup-SkillDir { return $true } +function Get-SkillLinkSignature { + param([string]$Path) + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch { + return $null + } + if (!$item.LinkType) { return $null } + $targets = @($item.Target) | ForEach-Object { [string]$_ } + return ([string]$item.LinkType + "`0" + ($targets -join "`0")) +} + +function New-PublishedSkillLinkRecord { + param([string]$Path) + $signature = Get-SkillLinkSignature -Path $Path + if ([string]::IsNullOrEmpty($signature)) { + throw "published Skill path is not a link: $Path" + } + return [pscustomobject]@{ Path = $Path; LinkSignature = $signature } +} + +function New-PublishedSkillCopyRecord { + param([string]$Path, [string]$Source) + Assert-SkillPathCopy -Source $Source -Destination $Path + return [pscustomobject]@{ Path = $Path; Source = $Source } +} + +function Remove-PublishedSkillPathSafely { + param($Record) + $path = [string]$Record.Path + $parent = Split-Path $path -Parent + $quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N")) + + # Claim the current directory entry with an exact same-filesystem rename. + # If another process replaced our link, verification below fails and that + # object is moved back instead of ever being recursively removed. + Move-SkillPath -Source $path -Destination $quarantine + try { + if ($Record.LinkSignature) { + if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) { + throw "发布链接已被其他进程替换,拒绝删除" + } + } elseif ($Record.Source) { + Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine + } else { + throw "发布路径缺少事务身份,拒绝删除" + } + Remove-SkillPathLexically -Path $quarantine + } catch { + $failure = $_ + if (Test-SkillPathLexically -Path $quarantine) { + try { + if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" } + Move-SkillPath -Source $quarantine -Destination $path + } catch { + throw "$failure;并发对象保留于 $quarantine`: $_" + } + } + throw $failure + } +} + function Restore-MultiSkillSet { param( [array]$Published, @@ -730,16 +810,18 @@ function Restore-MultiSkillSet { ) $ok = $true for ($i = $Published.Count - 1; $i -ge 0; $i--) { + $publishedItem = $Published[$i] + $publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path } try { - if (Test-SkillPathLexically -Path $Published[$i]) { - # Junction publications must never be removed with -Recurse: - # Windows PowerShell 5.1 can follow the reparse point and - # delete the canonical store's contents. Link targets remove - # lexically; copied directories still remove recursively. - Remove-SkillPathLexically -Path $Published[$i] + if (Test-SkillPathLexically -Path $publishedPath) { + if (!($publishedItem -is [string])) { + Remove-PublishedSkillPathSafely -Record $publishedItem + } else { + Remove-SkillPathLexically -Path $publishedPath + } } } catch { - Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" + Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_" $ok = $false } } @@ -899,8 +981,11 @@ function Publish-CanonicalSkillLinks { } foreach ($name in $publishNames) { $dest = Join-Path $BaseDir $name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + # Only record a publication after the staged junction has occupied + # the exact destination. Rollback also checks this identity before + # deleting, so a concurrent user directory is never removed. + $published += New-PublishedSkillLinkRecord -Path $dest Write-Say "↪ Skills → $dest" } return $true @@ -1188,8 +1273,8 @@ function Install-MonoToBase { } } - $published += $dest Move-SkillPath -Source $stagedSkill -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc } catch { $transactionError = $_ if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) { @@ -1383,8 +1468,8 @@ function Install-MultiToBase { foreach ($skillDir in $skillDirs) { $dest = Join-Path $BaseDir $skillDir.Name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName } } catch { $transactionError = $_ diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d69846ad..006cabc2 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -417,12 +417,18 @@ New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction Sile if (Test-Path -LiteralPath $junction) { $asserted = 1 if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } + $junctionChain = Join-Path $linkParent 'dingtalk-chat-chain' + New-Item -ItemType Junction -Path $junctionChain -Target $junction -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $junctionChain -Right $canonical)) { exit 5 } } $symlink = Join-Path $linkParent 'dingtalk-shared' New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null if (Test-Path -LiteralPath $symlink) { $asserted = 1 if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } + $symlinkChain = Join-Path $linkParent 'dingtalk-shared-chain' + New-Item -ItemType SymbolicLink -Path $symlinkChain -Target $symlink -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $symlinkChain -Right $other)) { exit 5 } } if ($asserted -eq 0) { exit 4 } if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } @@ -544,6 +550,20 @@ Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') - $restored = Restore-MultiSkillSet -Published @($record) -Backups @() if ($restored) { exit 15 } if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } + +# Copied mono/multi publications use the same identity-protected rollback. +$copySource = Join-Path $root 'copy-source' +$copyPath = Join-Path $root 'published-copy' +New-Item -ItemType Directory -Path $copySource -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copySource 'SKILL.md') -Value 'transaction copy' +Copy-SkillPathLexically -Source $copySource -Destination $copyPath +$copyRecord = New-PublishedSkillCopyRecord -Path $copyPath -Source $copySource +Remove-SkillPathLexically -Path $copyPath +New-Item -ItemType Directory -Path $copyPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -Value 'keep' +$copyRestored = Restore-MultiSkillSet -Published @($copyRecord) -Backups @() +if ($copyRestored) { exit 17 } +if (!(Test-Path -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -PathType Leaf)) { exit 18 } exit 0 ` harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") @@ -3050,6 +3070,80 @@ try { } } +func TestInstallDevappPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$script:OriginalBackupDevSkill = ${function:Backup-DevSkill} +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + & $script:OriginalBackupDevSkill $path $BackupPath + # Simulate another installer recreating the destination after the old + # content was backed up but before this transaction publishes its stage. + New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $path 'concurrent-user-data.txt') -Value 'keep' +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if (!$_.Exception.Data['DWSRollbackFailed']) { Write-Error $_; exit 3 } +} +if (!(Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt') -PathType Leaf)) { exit 4 } +if (Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'payload')) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publication race must retain concurrent directory: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent devapp data = %q, %v", got, err) + } + if backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n"); backup == "" { + t.Fatal("original devapp backup must be retained after publication race") + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { From a47740ca1c061c9fc24870e048708e49f98c919b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:23:59 +0800 Subject: [PATCH 029/267] fix(skills): make publication rollback race-safe --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 155 ++++++++- internal/app/skill_setup.go | 20 +- .../skill_setup_canonical_failures_test.go | 5 +- .../app/skill_setup_cleanup_coverage_test.go | 25 +- internal/app/skill_setup_plan_test.go | 12 +- .../app/skill_setup_publication_race_test.go | 85 +++++ internal/upgrade/canonical_skills_test.go | 4 +- internal/upgrade/paths.go | 60 ++-- internal/upgrade/paths_multi_test.go | 22 +- internal/upgrade/skill_path_move.go | 39 +-- internal/upgrade/skill_path_move_test.go | 10 +- internal/upgrade/skill_publication.go | 153 +++++++++ internal/upgrade/skill_publication_test.go | 314 ++++++++++++++++++ .../upgrade/skill_publish_rename_darwin.go | 9 + .../upgrade/skill_publish_rename_linux.go | 9 + .../upgrade/skill_publish_rename_other.go | 12 + .../upgrade/skill_publish_rename_windows.go | 17 + .../upgrade/skill_publish_transaction_test.go | 45 ++- test/scripts/install_js_smoke.mjs | 71 +++- 20 files changed, 934 insertions(+), 135 deletions(-) create mode 100644 internal/app/skill_setup_publication_race_test.go create mode 100644 internal/upgrade/skill_publication.go create mode 100644 internal/upgrade/skill_publication_test.go create mode 100644 internal/upgrade/skill_publish_rename_darwin.go create mode 100644 internal/upgrade/skill_publish_rename_linux.go create mode 100644 internal/upgrade/skill_publish_rename_other.go create mode 100644 internal/upgrade/skill_publish_rename_windows.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 2608ccd6..b04fdd62 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index 5550d449..d64e2ef0 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -698,10 +698,133 @@ function samePhysicalDir(left, right) { } } +function writeSkillFingerprintField(hash, value) { + const bytes = Buffer.from(String(value), "utf8"); + hash.update(String(bytes.length), "utf8"); + hash.update(":", "utf8"); + hash.update(bytes); +} + +function skillPathFingerprint(target) { + const hash = crypto.createHash("sha256"); + const visit = (current) => { + const stat = fs.lstatSync(current, { bigint: true }); + writeSkillFingerprintField(hash, Number(stat.mode & 0o170000n)); + writeSkillFingerprintField(hash, Number(stat.mode & 0o777n)); + if (stat.isDirectory()) { + const names = fs.readdirSync(current).sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + writeSkillFingerprintField(hash, names.length); + for (const name of names) { + writeSkillFingerprintField(hash, name); + visit(path.join(current, name)); + } + return; + } + if (stat.isSymbolicLink()) { + writeSkillFingerprintField(hash, fs.readlinkSync(current)); + return; + } + if (stat.isFile()) { + writeSkillFingerprintField(hash, stat.size.toString()); + hash.update(fs.readFileSync(current)); + return; + } + throw new Error(`unsupported Skill path type: ${current}`); + }; + visit(target); + return hash.digest("hex"); +} + +function captureSkillPublication(source, destination) { + const stat = fs.lstatSync(source, { bigint: true }); + return { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(source), + }; +} + +function skillPublicationMatches(target, publication) { + const stat = fs.lstatSync(target, { bigint: true }); + return stat.dev.toString() === publication.device + && stat.ino.toString() === publication.inode + && skillPathFingerprint(target) === publication.fingerprint; +} + +function removeSkillPathLexically(target) { + const stat = fs.lstatSync(target); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.rmSync(target, { recursive: true, force: true }); + } else { + fs.unlinkSync(target); + } +} + +function skillPathExistsLexically(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function rollbackPublishedSkillPath(publication, options = {}) { + const renameFn = options.renameFn || fs.renameSync; + const removeFn = options.removeFn || removeSkillPathLexically; + const destination = publication.destination; + const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`)); + const quarantine = path.join(quarantineRoot, "payload"); + try { + renameFn(destination, quarantine); + } catch (err) { + fs.rmSync(quarantineRoot, { recursive: true, force: true }); + if (err && err.code === "ENOENT") return; + throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`); + } + let owned = false; + try { + owned = skillPublicationMatches(quarantine, publication); + } catch (err) { + throw new Error(`refusing to delete unverifiable Skill ${destination}; object retained at ${quarantine}: ${err.message}`); + } + if (!owned) { + throw new Error(`refusing to delete non-transaction Skill ${destination}; concurrent object retained at ${quarantine}`); + } + removeFn(quarantine); + fs.rmSync(quarantineRoot, { recursive: true, force: true }); +} + +function publishCanonicalLinkNoReplace(staged, destination, options = {}) { + const publication = captureSkillPublication(staged, destination); + if (process.platform === "win32") { + const renameFn = options.publishRenameFn || fs.renameSync; + renameFn(staged, destination); + } else { + const linkFn = options.publishLinkFn || ((source, target) => { + const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "ln -P failed").trim(); + const error = new Error(detail); + if (skillPathExistsLexically(target)) error.code = "EEXIST"; + throw error; + } + }); + linkFn(staged, destination); + } + if (!skillPublicationMatches(destination, publication)) { + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + return publication; +} + function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { const moveOptions = skillMoveOptions(options); - const publishRenameFn = options.publishRenameFn || fs.renameSync; - const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -713,9 +836,9 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - publishRemoveFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn: publishRemoveFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -752,8 +875,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - publishRenameFn(item.staged, item.dest); - published.push(item.dest); + const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options); + published.push(publication); } } catch (err) { try { @@ -807,6 +930,7 @@ function publishManagedMultiSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); @@ -818,9 +942,9 @@ function publishManagedMultiSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -855,8 +979,11 @@ function publishManagedMultiSkillSetAtomically( } for (const item of staged) { + const publication = captureSkillPublication(item.staged, item.dest); + if (skillPathExistsLexically(item.dest)) throw new Error(`Skill publish destination already exists: ${item.dest}`); renameFn(item.staged, item.dest); - published.push(item.dest); + if (!skillPublicationMatches(item.dest, publication)) throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + published.push(publication); } } catch (err) { try { @@ -883,6 +1010,7 @@ function publishManagedMonoSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); @@ -895,9 +1023,9 @@ function publishManagedMonoSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -927,8 +1055,11 @@ function publishManagedMonoSkillSetAtomically( backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } - published.push(destDir); + const publication = captureSkillPublication(stagedDir, destDir); + if (skillPathExistsLexically(destDir)) throw new Error(`Skill publish destination already exists: ${destDir}`); renameFn(stagedDir, destDir); + if (!skillPublicationMatches(destDir, publication)) throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + published.push(publication); } catch (err) { try { restore(); diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 9a4ebfb8..56858494 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -143,6 +143,8 @@ var ( skillSetupReadState = skillstate.Read skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove + skillSetupPublishPath = upgrade.PublishSkillPathNoReplace + skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications skillSetupNow = time.Now skillSetupFoldPathCase = runtime.GOOS == "windows" ) @@ -2035,13 +2037,8 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s // restoreSkillSetupTarget removes a partially published replacement and // restores every original directory from its exact backup path. -func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := skillSetupRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error { + restoreErr := skillSetupRollbackPaths(published) for i := len(backups) - 1; i >= 0; i-- { item := backups[i] if _, err := skillSetupLstat(item.original); err == nil { @@ -2088,18 +2085,17 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ } func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error { - published := make([]string, 0, len(staged)) + published := make([]upgrade.SkillPathPublication, 0, len(staged)) for _, item := range staged { - // Record before rename so rollback also removes a destination created by - // a platform-specific partial failure. - published = append(published, item.dest) - if err := skillSetupPublishRename(item.staged, item.dest); err != nil { + publication, err := skillSetupPublishPath(item.staged, item.dest) + if err != nil { publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) } return publishErr } + published = append(published, publication) } return nil } diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go index c1b82a19..c5d6b7fd 100644 --- a/internal/app/skill_setup_canonical_failures_test.go +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { @@ -62,7 +63,9 @@ func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t t.Run("publish", func(t *testing.T) { home, plan := canonicalFailurePlan(t) testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, errors.New("publish denied") + }) if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { t.Fatalf("publish failure = %v", err) } diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 6fea3de5..b346eb3d 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -15,6 +15,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func useManagedSkillNames(t *testing.T, names ...string) { @@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test return originalBackup(homeDir, dir) }) } else { - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) } @@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("restore failure aggregation", func(t *testing.T) { t.Run("remove published", func(t *testing.T) { - testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure }) - if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) { + testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure }) + if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) { t.Fatalf("remove published error = %v", err) } }) @@ -490,7 +491,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { }) t.Run("publish rollback failure", func(t *testing.T) { - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, failure + }) testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) @@ -531,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("after publish failure", func(t *testing.T) { plan := newPlan(t) - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) originalRemoveAll := skillSetupRemoveAll cleanupErr := errors.New("cleanup after publish failure") diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index 8330d6e9..406b318a 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t backupCalls, copyCalls := []string{}, 0 testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) { backupCalls = append(backupCalls, path) + if err := os.RemoveAll(path); err != nil { + return "", err + } return "backup", nil }) testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil }) testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error { - if err := os.RemoveAll(dest); err != nil { - return err - } - return os.Rename(src, dest) - }) dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false) var dryRunOut bytes.Buffer dryRunCmd.SetOut(&dryRunOut) @@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t // A filtered multi plan replaces only selected same-name skills and leaves // unselected siblings out of the backup set. + if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil { + t.Fatal(err) + } filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true) if err != nil { t.Fatal(err) diff --git a/internal/app/skill_setup_publication_race_test.go b/internal/app/skill_setup_publication_race_test.go new file mode 100644 index 00000000..8d954e3a --- /dev/null +++ b/internal/app/skill_setup_publication_race_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" +) + +func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + writeSkillSetupFile(t, first, "old first") + writeSkillSetupFile(t, second, "old second") + backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard) + if err != nil { + t.Fatal(err) + } + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + writeSkillSetupFile(t, stagedFirst, "new first") + writeSkillSetupFile(t, stagedSecond, "new second") + + failure := errors.New("injected second setup publication failure") + originalPublish := skillSetupPublishPath + calls := 0 + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + writeSkillSetupFile(t, first, "concurrent") + return upgrade.SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err = publishSkillSetupTarget([]skillSetupStagedDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, backups) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("setup transaction error = %v", err) + } + assertSkillSetupFile(t, first, "concurrent") + assertSkillSetupFile(t, second, "old second") + var firstBackup string + for _, item := range backups { + if item.original == first { + firstBackup = item.backup + break + } + } + if firstBackup == "" { + t.Fatal("first backup was not recorded") + } + assertSkillSetupFile(t, firstBackup, "old first") +} + +func writeSkillSetupFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertSkillSetupFile(t *testing.T, dir, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil || string(content) != want { + t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want) + } +} diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index eee92fa4..9008fc31 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -416,8 +416,8 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { if mode == "multi" { useUpgradeManagedNames(t, "dingtalk-chat") } - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if src == victim { return errors.New("retire denied") } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 1eab7cf1..05551c79 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -140,26 +140,28 @@ var universalSkillDirs = map[string]bool{ } var ( - upgradeUserHomeDir = os.UserHomeDir - upgradeExecutable = os.Executable - upgradeEvalSymlinks = filepath.EvalSymlinks - upgradeCopyDir = copyDir - upgradeEnsureDir = ensureDir - upgradeRemoveAll = os.RemoveAll - upgradeMkdirAll = os.MkdirAll - upgradeMkdirTemp = os.MkdirTemp - upgradeReadDir = os.ReadDir - upgradeStat = os.Stat - upgradeLstat = os.Lstat - upgradeSymlink = os.Symlink - upgradeRel = filepath.Rel - upgradeGetenv = os.Getenv - upgradeBuildProvenance = skillprovenance.Build - upgradeReadSkillState = skillstate.Read - upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } - upgradeWriteSkillState = skillstate.Write - upgradeNow = time.Now - upgradeFoldPathCase = runtime.GOOS == "windows" + upgradeUserHomeDir = os.UserHomeDir + upgradeExecutable = os.Executable + upgradeEvalSymlinks = filepath.EvalSymlinks + upgradeCopyDir = copyDir + upgradeEnsureDir = ensureDir + upgradeRemoveAll = os.RemoveAll + upgradeMkdirAll = os.MkdirAll + upgradeMkdirTemp = os.MkdirTemp + upgradeReadDir = os.ReadDir + upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv + upgradeBuildProvenance = skillprovenance.Build + upgradeReadSkillState = skillstate.Read + upgradePublishSkillPath = PublishSkillPathNoReplace + upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications + upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } + upgradeWriteSkillState = skillstate.Write + upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -492,13 +494,8 @@ func uniqueSkillDirs(paths []string) []string { // restoreSkillSet removes any newly published directories, then restores all // original directories in reverse backup order. -func restoreSkillSet(published []string, backups []backedUpSkillDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := upgradeRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error { + restoreErr := upgradeRollbackPublishedSkillPaths(published) for i := len(backups) - 1; i >= 0; i-- { backup := backups[i] if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil { @@ -538,16 +535,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st if err != nil { return err } - published := make([]string, 0, len(staged)) + published := make([]SkillPathPublication, 0, len(staged)) for _, skill := range staged { - if err := upgradeRename(skill.staged, skill.dest); err != nil { - publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err) + publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest) + if publishErr != nil { + publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr) if restoreErr := restoreSkillSet(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr)) } return publishErr } - published = append(published, skill.dest) + published = append(published, publication) } return nil } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index a0d67135..6ba5d481 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -625,8 +625,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } useUpgradeManagedNames(t, filepath.Base(staleDir)) - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, "dingtalk-stale") { return errors.New("injected backup failure") } @@ -814,14 +814,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { if err := os.MkdirAll(victim4, 0o755); err != nil { t.Fatal(err) } - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("rename denied") }) if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { t.Fatalf("rename error = %v", err) } if _, err := os.Stat(victim4); err != nil { t.Fatalf("victim4 must survive rename failure: %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) } // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: @@ -949,11 +949,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, ".agents") { return errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) result, err := UpgradeSkillLocations(mono) if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { @@ -1014,7 +1014,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Backup failure of a multi leftover aborts cleanupMultiLeftovers. os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") }) if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } @@ -1024,19 +1024,19 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup mono backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Backup failure of a stale (non-mono) skill aborts with its own message. - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, "dingtalk-stale") { return errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup stale backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Success matrix: mono leftover + stale skill removed into backups, bundle // skill and regular file preserved. diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 2c28be90..e9f9539c 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -11,23 +11,24 @@ import ( ) var ( - skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } - skillPathCopy = copySkillPathLexically - skillPathVerify = verifySkillPathCopy - skillPathRemoveAll = os.RemoveAll - skillPathMkdirAll = os.MkdirAll - skillPathMkdirTemp = os.MkdirTemp - skillPathChmod = os.Chmod - skillPathLstat = os.Lstat - skillPathMkdir = os.Mkdir - skillPathReadDir = os.ReadDir - skillPathReadlink = os.Readlink - skillPathSymlink = os.Symlink - skillPathOpen = os.Open - skillPathOpenFile = os.OpenFile - skillPathCopyBytes = io.Copy - skillPathSync = func(file *os.File) error { return file.Sync() } - skillPathWalkDir = filepath.WalkDir + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathReadDir = os.ReadDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the @@ -44,7 +45,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) } - if renameErr := skillPathRename(src, dst); renameErr == nil { + if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { return nil } else if !isCrossDeviceError(renameErr) { return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) @@ -85,7 +86,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) } } - if err := skillPathRename(stage, dst); err != nil { + if err := skillPathRenameNoReplace(stage, dst); err != nil { return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) } if stageInfo.IsDir() { diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 4140fd6a..6d598590 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -140,7 +140,7 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("non cross-device rename never copies", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) copied := false - testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return os.ErrPermission }) testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { t.Fatalf("error = %v", err) @@ -181,8 +181,8 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("staging publish failure", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(from, to string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { if from == src && to == dst { return testCrossDeviceError() } @@ -402,8 +402,8 @@ func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { func forceCrossDeviceRename(t *testing.T, src, dst string) { t.Helper() - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(from, to string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { if from == src && to == dst { return testCrossDeviceError() } diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go new file mode 100644 index 00000000..f4315435 --- /dev/null +++ b/internal/upgrade/skill_publication.go @@ -0,0 +1,153 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "hash" + "os" + "path/filepath" + "sort" +) + +// SkillPathPublication records enough immutable identity to prove that a +// destination still belongs to the transaction that published it. The +// fingerprint is intentionally private so callers cannot forge records. +type SkillPathPublication struct { + Destination string + fingerprint [sha256.Size]byte + identity os.FileInfo +} + +// PublishSkillPathNoReplace atomically publishes a staged path without ever +// replacing a destination created after the backup phase. +func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) { + identity, err := skillPathLstat(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err) + } + fingerprint, err := fingerprintSkillPath(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) + } + if err := skillPathRenameNoReplace(staged, destination); err != nil { + return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) + } + return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil +} + +// RollbackSkillPathPublications removes only objects that can still be proven +// to have been published by this transaction. Each live path is first claimed +// into a private sibling quarantine. A concurrent replacement is restored when +// possible, otherwise retained in quarantine and reported explicitly. +func RollbackSkillPathPublications(publications []SkillPathPublication) error { + var rollbackErr error + for i := len(publications) - 1; i >= 0; i-- { + if err := rollbackSkillPathPublication(publications[i]); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + return rollbackErr +} + +func rollbackSkillPathPublication(publication SkillPathPublication) (err error) { + destination := publication.Destination + quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-") + if err != nil { + return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err) + } + quarantine := filepath.Join(quarantineRoot, "payload") + cleanupRoot := func() error { + if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil { + return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr) + } + return nil + } + + if err := skillPathRename(destination, quarantine); err != nil { + cleanupErr := cleanupRoot() + if os.IsNotExist(err) { + return cleanupErr + } + return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr) + } + + actualIdentity, identityErr := skillPathLstat(quarantine) + actual, fingerprintErr := fingerprintSkillPath(quarantine) + if identityErr == nil && fingerprintErr == nil && publication.identity != nil && + os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint { + if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { + return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) + } + return cleanupRoot() + } + + verificationErr := errors.Join(identityErr, fingerprintErr) + if verificationErr == nil { + verificationErr = errors.New("发布对象身份已变化") + } + if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil { + return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr) + } + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr), + cleanupRoot(), + ) +} + +func fingerprintSkillPath(path string) ([sha256.Size]byte, error) { + h := sha256.New() + if err := fingerprintSkillPathInto(h, path); err != nil { + return [sha256.Size]byte{}, err + } + var result [sha256.Size]byte + copy(result[:], h.Sum(nil)) + return result, nil +} + +func fingerprintSkillPathInto(h hash.Hash, path string) error { + info, err := skillPathLstat(path) + if err != nil { + return err + } + writeFingerprintField(h, info.Mode().Type().String()) + writeFingerprintField(h, info.Mode().Perm().String()) + switch { + case info.IsDir(): + entries, err := skillPathReadDir(path) + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + writeFingerprintField(h, fmt.Sprintf("%d", len(entries))) + for _, entry := range entries { + writeFingerprintField(h, entry.Name()) + if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil { + return err + } + } + return nil + case info.Mode()&os.ModeSymlink != 0: + target, err := skillPathReadlink(path) + if err != nil { + return err + } + writeFingerprintField(h, target) + return nil + case info.Mode().IsRegular(): + digest, err := skillPathFileDigest(path) + if err != nil { + return err + } + writeFingerprintField(h, fmt.Sprintf("%d", info.Size())) + _, err = h.Write(digest[:]) + return err + default: + return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode()) + } +} + +func writeFingerprintField(h hash.Hash, value string) { + _, _ = fmt.Fprintf(h, "%d:", len(value)) + _, _ = h.Write([]byte(value)) +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go new file mode 100644 index 00000000..470b03cc --- /dev/null +++ b/internal/upgrade/skill_publication_test.go @@ -0,0 +1,314 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) { + t.Run("existing destination is never replaced", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + seedUpgradeSkill(t, destination, "concurrent", false) + + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil { + t.Fatal("expected no-replace publication to fail") + } + assertUpgradeSkillContent(t, destination, "concurrent") + assertUpgradeSkillContent(t, staged, "new") + }) + + t.Run("owned publication is removed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("owned publication remains: %v", err) + } + }) + + t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + // Even a byte-for-byte identical replacement is not owned by this + // transaction: inode identity, not content alone, is authoritative. + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) +} + +func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { + failure := errors.New("injected publication failure") + + t.Run("publish identity", func(t *testing.T) { + if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected identity failure") + } + }) + + t.Run("publish fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + seedUpgradeSkill(t, staged, "value", false) + originalLstat := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls == 2 { + return nil, failure + } + return originalLstat(path) + }) + if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) { + t.Fatal("expected fingerprint failure") + } + }) + + t.Run("rollback temp", func(t *testing.T) { + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { + t.Fatalf("temp error = %v", err) + } + }) + + t.Run("missing destination cleanup", func(t *testing.T) { + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("missing destination cleanup failure", func(t *testing.T) { + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) { + t.Fatalf("cleanup error = %v", err) + } + }) + + t.Run("quarantine rename", func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "destination") + seedUpgradeSkill(t, destination, "value", false) + testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: destination}}); !errors.Is(err, failure) { + t.Fatalf("quarantine error = %v", err) + } + }) + + t.Run("owned remove", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if filepath.Base(path) == "payload" { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("remove error = %v", err) + } + }) + + t.Run("quarantined fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "payload" { + return nil, failure + } + return originalLstat(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("fingerprint error = %v", err) + } + }) + + t.Run("mismatch restore and cleanup", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "replacement", false) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".destination.rollback-") { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("mismatch cleanup error = %v", err) + } + assertUpgradeSkillContent(t, destination, "replacement") + }) + + t.Run("mismatch restore failure", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "replacement", false) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { + t.Fatalf("mismatch restore error = %v", err) + } + }) + + t.Run("fingerprint read failures and lexical types", func(t *testing.T) { + t.Run("directory read", func(t *testing.T) { + dir := t.TempDir() + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("read dir error = %v", err) + } + }) + t.Run("recursive child", func(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "child") + if err := os.WriteFile(child, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == child { + return nil, failure + } + return originalLstat(path) + }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("child error = %v", err) + } + }) + t.Run("symlink read", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure }) + if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) { + t.Fatalf("readlink error = %v", err) + } + }) + t.Run("file digest", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure }) + if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) { + t.Fatalf("digest error = %v", err) + } + }) + t.Run("special", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil { + t.Fatal("expected special path error") + } + }) + }) +} + +func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + seedUpgradeSkill(t, first, "old first", false) + seedUpgradeSkill(t, second, "old second", false) + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + seedUpgradeSkill(t, stagedFirst, "new first", false) + seedUpgradeSkill(t, stagedSecond, "new second", false) + + failure := errors.New("injected second publication failure") + originalPublish := upgradePublishSkillPath + calls := 0 + testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, first, "concurrent", false) + return SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err := publishStagedSkillSet(home, []stagedSkillDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, []string{first, second}) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("transaction error = %v", err) + } + assertUpgradeSkillContent(t, first, "concurrent") + assertUpgradeSkillContent(t, second, "old second") + matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md")) + if globErr != nil || len(matches) != 1 { + t.Fatalf("retained old backup = %v, %v", matches, globErr) + } + content, readErr := os.ReadFile(matches[0]) + if readErr != nil || string(content) != "old first" { + t.Fatalf("retained backup content = %q, %v", content, readErr) + } +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go new file mode 100644 index 00000000..06722ef1 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package upgrade + +import "golang.org/x/sys/unix" + +func renameSkillPathNoReplace(source, destination string) error { + return unix.RenamexNp(source, destination, unix.RENAME_EXCL) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go new file mode 100644 index 00000000..7936592a --- /dev/null +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package upgrade + +import "golang.org/x/sys/unix" + +func renameSkillPathNoReplace(source, destination string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go new file mode 100644 index 00000000..bdd0986b --- /dev/null +++ b/internal/upgrade/skill_publish_rename_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "runtime" +) + +func renameSkillPathNoReplace(_, _ string) error { + return fmt.Errorf("当前平台 %s 不支持安全的原子 no-replace rename", runtime.GOOS) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go new file mode 100644 index 00000000..7ac5afb1 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package upgrade + +import "golang.org/x/sys/windows" + +func renameSkillPathNoReplace(source, destination string) error { + sourcePtr, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFile(sourcePtr, destinationPtr) +} diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go index 02afa793..0e372b28 100644 --- a/internal/upgrade/skill_publish_transaction_test.go +++ b/internal/upgrade/skill_publish_transaction_test.go @@ -92,8 +92,8 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("backup failure restores earlier victims", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { if src == filepath.Join(base, "dingtalk-b") { return failure } @@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("publish failure rolls back complete set", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T t.Run("publish failure rolls back mono and multi", func(t *testing.T) { home, base, monoRoot := seed(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports removal and directory failures", func(t *testing.T) { - testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure }) + testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure }) testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure }) err := restoreSkillSet( - []string{filepath.Join(t.TempDir(), "published")}, + []SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}}, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}, ) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { @@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports rename failure", func(t *testing.T) { - testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return restoreFailure }) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) if !errors.Is(err, restoreFailure) { t.Fatalf("restore rename error = %v", err) @@ -236,8 +236,8 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) second := filepath.Join(home, "second") seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, second, "second", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { switch { case src == second: return failure @@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) staged := filepath.Join(home, "skills", ".stage", "dws") seedUpgradeSkill(t, old, "old", false) seedUpgradeSkill(t, staged, "new", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { - switch { - case src == staged: - return failure - case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + return SkillPathPublication{}, failure + }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { + if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) { return restoreFailure - default: - return originalRename(src, dest) } + return originalRename(src, dest) }) err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index ba0ad970..8142f74e 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -287,7 +287,7 @@ function runCrossDeviceMoveContract() { } fs.renameSync(source, target); }, - publishRenameFn() { throw new Error("link publish failed"); }, + publishLinkFn() { throw new Error("link publish failed"); }, }), /link publish failed/, ); @@ -687,6 +687,75 @@ scenario("cache copy failure preserves the previous complete cache", () => { } }); +scenario("canonical link publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-")); + try { + { + const home = path.join(tmp, "no-clobber-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const destination = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { + publishLinkFn(_source, target) { + writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); + throw Object.assign(new Error("concurrent destination exists"), { code: "EEXIST" }); + }, + }), + /concurrent destination exists/, + ); + assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + } + + { + const home = path.join(tmp, "rollback-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const first = path.join(base, "dingtalk-first"); + const second = path.join(base, "dingtalk-second"); + for (const name of ["dingtalk-first", "dingtalk-second"]) { + writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`); + writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`); + } + let publishCalls = 0; + assert.throws( + () => publishCanonicalLinksAtomically( + home, + canonical, + base, + ["dingtalk-first", "dingtalk-second"], + [first, second], + { + publishLinkFn(source, target) { + publishCalls += 1; + if (publishCalls === 1) { + const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || "ln -P failed"); + return; + } + fs.unlinkSync(first); + writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n"); + throw new Error("injected second canonical publish failure"); + }, + }, + ), + /concurrent object retained|rollback failed/, + ); + assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n"); + assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n"); + const retained = fs.readdirSync(base) + .filter((name) => name.startsWith(".dingtalk-first.rollback-")) + .map((name) => path.join(base, name, "payload", "concurrent-user-data.txt")) + .filter((candidate) => fs.existsSync(candidate)); + assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine"); + assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n"); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); From 1bae8723419cf4410da934dddbc5800234e2e143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:47:04 +0800 Subject: [PATCH 030/267] fix(skills): distinguish reused publication inodes --- internal/upgrade/skill_publication.go | 44 +++++- .../skill_publication_identity_darwin.go | 18 +++ .../skill_publication_identity_linux.go | 17 +++ .../skill_publication_identity_other.go | 12 ++ .../skill_publication_identity_windows.go | 17 +++ internal/upgrade/skill_publication_test.go | 144 ++++++++++++++++-- 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 internal/upgrade/skill_publication_identity_darwin.go create mode 100644 internal/upgrade/skill_publication_identity_linux.go create mode 100644 internal/upgrade/skill_publication_identity_other.go create mode 100644 internal/upgrade/skill_publication_identity_windows.go diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index f4315435..7c678af0 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -17,6 +17,7 @@ type SkillPathPublication struct { Destination string fingerprint [sha256.Size]byte identity os.FileInfo + incarnation string } // PublishSkillPathNoReplace atomically publishes a staged path without ever @@ -33,7 +34,22 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } - return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil + publishedIdentity, err := skillPathLstat(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + } + // Force os.SameFile to resolve and cache any path-backed identity (notably + // on Windows) while the just-published object is known to occupy the path. + if !os.SameFile(identity, publishedIdentity) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + _ = os.SameFile(publishedIdentity, publishedIdentity) + return SkillPathPublication{ + Destination: destination, + fingerprint: fingerprint, + identity: publishedIdentity, + incarnation: skillPathFileIncarnation(publishedIdentity), + }, nil } // RollbackSkillPathPublications removes only objects that can still be proven @@ -64,6 +80,27 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) return nil } + liveIdentity, liveIdentityErr := skillPathLstat(destination) + if os.IsNotExist(liveIdentityErr) { + return cleanupRoot() + } + liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination) + liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr) + if liveVerificationErr != nil { + return errors.Join( + fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr), + cleanupRoot(), + ) + } + if publication.identity == nil || !os.SameFile(publication.identity, liveIdentity) || + publication.incarnation != skillPathFileIncarnation(liveIdentity) || + liveFingerprint != publication.fingerprint { + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination), + cleanupRoot(), + ) + } + if err := skillPathRename(destination, quarantine); err != nil { cleanupErr := cleanupRoot() if os.IsNotExist(err) { @@ -74,8 +111,9 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) - if identityErr == nil && fingerprintErr == nil && publication.identity != nil && - os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint { + if identityErr == nil && fingerprintErr == nil && + os.SameFile(publication.identity, actualIdentity) && + actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) } diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go new file mode 100644 index 00000000..b436ac6e --- /dev/null +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, + stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go new file mode 100644 index 00000000..0f8a52fb --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go new file mode 100644 index 00000000..fcdee1a2 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "os" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go new file mode 100644 index 00000000..046327ef --- /dev/null +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 470b03cc..9733bcac 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -94,6 +94,44 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("publish confirmation missing", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + return os.RemoveAll(target) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { + t.Fatalf("publish confirmation error = %v", err) + } + }) + + t.Run("publish confirmation identity changed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + if err := os.RemoveAll(target); err != nil { + return err + } + seedUpgradeSkill(t, target, "replacement", false) + return nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { + t.Fatalf("publish identity error = %v", err) + } + }) + t.Run("rollback temp", func(t *testing.T) { testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { @@ -115,14 +153,61 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) t.Run("quarantine rename", func(t *testing.T) { - destination := filepath.Join(t.TempDir(), "destination") - seedUpgradeSkill(t, destination, "value", false) + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) - if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: destination}}); !errors.Is(err, failure) { + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { t.Fatalf("quarantine error = %v", err) } }) + t.Run("live verification", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("live verification error = %v", err) + } + }) + + t.Run("destination disappears before quarantine", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if err := os.RemoveAll(source); err != nil { + return err + } + return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist} + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + }) + t.Run("owned remove", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") @@ -174,10 +259,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) originalRemove := skillPathRemoveAll testseam.Swap(t, &skillPathRemoveAll, func(path string) error { if strings.Contains(filepath.Base(path), ".destination.rollback-") { @@ -200,10 +291,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { t.Fatalf("mismatch restore error = %v", err) @@ -211,6 +308,17 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) t.Run("fingerprint read failures and lexical types", func(t *testing.T) { + t.Run("directory ordering", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"b", "a"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := fingerprintSkillPath(dir); err != nil { + t.Fatal(err) + } + }) t.Run("directory read", func(t *testing.T) { dir := t.TempDir() testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) @@ -245,6 +353,15 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatalf("readlink error = %v", err) } }) + t.Run("symlink success", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := fingerprintSkillPath(link); err != nil { + t.Fatal(err) + } + }) t.Run("file digest", func(t *testing.T) { file := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(file, []byte("value"), 0o644); err != nil { @@ -263,6 +380,11 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatal("expected special path error") } }) + t.Run("unknown platform identity shape", func(t *testing.T) { + if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" { + t.Fatal("empty incarnation") + } + }) }) } From 75bf44b7beabe44fe42071e77867a1202193183d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:58:14 +0800 Subject: [PATCH 031/267] test(skills): cover post-publish identity reuse --- internal/upgrade/skill_publication.go | 6 ++++- internal/upgrade/skill_publication_test.go | 26 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 7c678af0..d2fc7921 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -38,9 +38,13 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) } + publishedFingerprint, err := fingerprintSkillPath(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) + } // Force os.SameFile to resolve and cache any path-backed identity (notably // on Windows) while the just-published object is known to occupy the path. - if !os.SameFile(identity, publishedIdentity) { + if !os.SameFile(identity, publishedIdentity) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } _ = os.SameFile(publishedIdentity, publishedIdentity) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 9733bcac..00be230a 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -132,6 +132,32 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("publish confirmation fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + published := false + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + published = true + return nil + }) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if published && path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") { + t.Fatalf("publish fingerprint confirmation error = %v", err) + } + }) + t.Run("rollback temp", func(t *testing.T) { testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { From 16d20b817833e75b29e77a54eb4a95614c140b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:07:33 +0800 Subject: [PATCH 032/267] fix(skills): compare Windows publication identity stably --- internal/upgrade/skill_publication.go | 9 +++------ internal/upgrade/skill_publication_identity_darwin.go | 4 ++++ internal/upgrade/skill_publication_identity_linux.go | 4 ++++ internal/upgrade/skill_publication_identity_other.go | 4 ++++ internal/upgrade/skill_publication_identity_windows.go | 4 ++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index d2fc7921..0bd9ecdb 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -42,12 +42,9 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } - // Force os.SameFile to resolve and cache any path-backed identity (notably - // on Windows) while the just-published object is known to occupy the path. - if !os.SameFile(identity, publishedIdentity) || publishedFingerprint != fingerprint { + if !skillPathSameFileIdentity(identity, publishedIdentity) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } - _ = os.SameFile(publishedIdentity, publishedIdentity) return SkillPathPublication{ Destination: destination, fingerprint: fingerprint, @@ -96,7 +93,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) cleanupRoot(), ) } - if publication.identity == nil || !os.SameFile(publication.identity, liveIdentity) || + if publication.identity == nil || !skillPathSameFileIdentity(publication.identity, liveIdentity) || publication.incarnation != skillPathFileIncarnation(liveIdentity) || liveFingerprint != publication.fingerprint { return errors.Join( @@ -116,7 +113,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) if identityErr == nil && fingerprintErr == nil && - os.SameFile(publication.identity, actualIdentity) && + skillPathSameFileIdentity(publication.identity, actualIdentity) && actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index b436ac6e..a3547e4c 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -16,3 +16,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 0f8a52fb..cb62e1e1 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -15,3 +15,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { } return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index fcdee1a2..53c0bc86 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -10,3 +10,7 @@ import ( func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 046327ef..518771c5 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -15,3 +15,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { } return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return skillPathFileIncarnation(left) == skillPathFileIncarnation(right) +} From 276658590baca8fc1e7e20e17f6de819246b6718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:14:04 +0800 Subject: [PATCH 033/267] fix(skills): retain Windows reparse link publication --- internal/upgrade/skill_publication_identity_windows.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 518771c5..03c97de7 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -16,6 +16,9 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { - return skillPathFileIncarnation(left) == skillPathFileIncarnation(right) +func skillPathSameFileIdentity(_, _ os.FileInfo) bool { + // MoveFile can surface different metadata identities for the same reparse + // point before and after rename. Windows ownership is therefore proved by + // the separately checked creation-time incarnation plus lexical fingerprint. + return true } From 24bbdda423fea26e33c66148a8f965b2f8832f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:22:32 +0800 Subject: [PATCH 034/267] test(skills): cover Windows no-replace path errors --- internal/upgrade/skill_publication_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 00be230a..a5c3eb38 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -70,6 +70,19 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { failure := errors.New("injected publication failure") + t.Run("platform no-replace path encoding", func(t *testing.T) { + if err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected invalid source path failure") + } + source := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(source, []byte("value"), 0o600); err != nil { + t.Fatal(err) + } + if err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { + t.Fatal("expected invalid destination path failure") + } + }) + t.Run("publish identity", func(t *testing.T) { if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil { t.Fatal("expected identity failure") From 7581955892bfb72a93c87bce9842b5feae00d7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 14:26:11 +0800 Subject: [PATCH 035/267] fix(skills): make obsolete-copy retirement non-fatal and harden install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A universal Agent whose obsolete private copy cannot be retired installs nothing there, yet every entry point counted that retirement failure as an install failure — aborting `npm install`, `dws skill setup`, and the shell installers even when the canonical store and all links published correctly, and skipping the skills-state write. Route retirement failures to a separate warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell, install.sh, install-skills.sh, install-event.sh, install-devapp.sh). Also: - Add a checked-rename fallback for filesystems that reject the atomic no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and the previously unsupported platforms build and work. - PowerShell multi-mode links only bundle skills, never the shared canonical store, so third-party/user skills are no longer fanned into every Agent root. - Prune ~/.dws/skill-backups to the newest 5 on every surface; encode HOME-relative backup names on PowerShell to preserve origin. - Add simulated-win32 junction coverage and rewrite the tautological no-replace test; remove dead code whose tests gave false coverage. - Soften the overstated Windows ownership-proof comment (NTFS tunneling). --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 72 ++++- docs/rfc-skill-installation-and-upgrade.md | 7 + internal/app/skill_setup.go | 70 +---- ...ll_setup_event_migration_universal_test.go | 13 +- internal/app/skill_setup_plan_test.go | 28 +- internal/app/upgrade.go | 3 + internal/upgrade/canonical_skills_test.go | 17 +- internal/upgrade/paths.go | 63 ++-- internal/upgrade/paths_multi_test.go | 42 ++- .../skill_publication_identity_windows.go | 8 +- internal/upgrade/skill_publish_rename.go | 40 +++ .../upgrade/skill_publish_rename_darwin.go | 17 +- .../upgrade/skill_publish_rename_linux.go | 18 +- .../upgrade/skill_publish_rename_other.go | 11 +- internal/upgrade/skill_publish_rename_test.go | 109 +++++++ .../upgrade/skill_publish_rename_windows.go | 7 +- scripts/install-devapp.ps1 | 76 ++++- scripts/install-devapp.sh | 81 ++++- scripts/install-event.sh | 85 +++++- scripts/install-skills.sh | 184 +++++++---- scripts/install.ps1 | 107 ++++++- scripts/install.sh | 190 ++++++++---- scripts/release/verify-package-managers.sh | 29 +- test/scripts/install_js_smoke.mjs | 285 ++++++++++++++++-- test/scripts/install_script_test.go | 15 +- test/scripts/package_script_test.go | 6 +- 27 files changed, 1266 insertions(+), 319 deletions(-) create mode 100644 internal/upgrade/skill_publish_rename.go create mode 100644 internal/upgrade/skill_publish_rename_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index b04fdd62..9a4afd7d 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Failing to retire an obsolete Agent copy is now a warning rather than an install failure, and every install surface prunes `~/.dws/skill-backups` to the newest 5 batches. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index d64e2ef0..1a243910 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -153,6 +153,42 @@ function backupStamp() { ); } +// SKILL_BACKUP_KEEP bounds ~/.dws/skill-backups/ growth: only the newest +// stamped backup directories are kept. Mirrors skillBackupKeep and +// pruneSkillBackups in internal/upgrade/paths.go so every installer surface +// applies the same retention. +const SKILL_BACKUP_KEEP = 5; + +// Backup roots this process created are never pruned: an in-flight +// transaction still needs them to roll back. +const currentRunBackupRoots = new Set(); + +// pruneSkillBackups removes the oldest stamped backup directories once more +// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in +// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +function pruneSkillBackups(homeDir) { + const root = path.join(homeDir, ".dws", "skill-backups"); + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (err) { + if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return; + throw err; + } + const names = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + let excess = names.length - SKILL_BACKUP_KEEP; + for (const name of names) { + if (excess <= 0) break; + const candidate = path.join(root, name); + if (currentRunBackupRoots.has(candidate)) continue; + fs.rmSync(candidate, { recursive: true, force: true }); + excess -= 1; + } +} + function pathExistsLexicallySync(target) { try { fs.lstatSync(target); @@ -397,6 +433,14 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { if (backups) { backups.push({ original: dir, backup: target }); } + currentRunBackupRoots.add(targetRoot); + // Keep the backup root bounded. The backup itself already succeeded, so a + // prune failure is only a warning (matches Go's best-effort prune call). + try { + pruneSkillBackups(homeDir); + } catch (err) { + console.warn(`⚠️ 旧备份清理失败(备份本身已成功): ${err.message}`); + } console.log(` × 已备份并移除 ${dir} → ${target}`); return target; } @@ -568,11 +612,14 @@ function installSkillsToHomes(skillRoot) { if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { + // Cleanup-only migration: universal Agents read the canonical store + // directly, so nothing is installed here. A retire failure leaves an + // obsolete copy in place — worth a warning, never a reason to fail an + // install whose canonical store and links all succeeded. try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { - console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); - failed += 1; + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); } continue; } @@ -799,6 +846,15 @@ function rollbackPublishedSkillPath(publication, options = {}) { function publishCanonicalLinkNoReplace(staged, destination, options = {}) { const publication = captureSkillPublication(staged, destination); + // No-replace is a hard property of this publisher, so it is checked before + // either platform's publish call. Windows rename replaces the destination + // outright (libuv passes MOVEFILE_REPLACE_EXISTING) and `ln -P` treats an + // existing directory as a container to link INTO; in both cases the identity + // confirmation below compares against the staged object and could not + // recover the user's data. The sibling copy publishers guard identically. + if (skillPathExistsLexically(destination)) { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } if (process.platform === "win32") { const renameFn = options.publishRenameFn || fs.renameSync; renameFn(staged, destination); @@ -825,6 +881,9 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const moveOptions = skillMoveOptions(options); const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; + // Injectable so the Windows junction type and absolute link target can be + // asserted from a non-Windows host. + const symlinkFn = options.symlinkFn || fs.symlinkSync; fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -864,7 +923,7 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const stagedPath = path.join(stageRoot, name); const realTarget = fs.realpathSync(target); const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); - fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + symlinkFn(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); } const seen = new Set(); @@ -1181,11 +1240,14 @@ function installMultiSkillsToHomes(multiRoot) { if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { + // Cleanup-only migration (see installSkillsToHomes): nothing is + // installed in a universal root, so a retire failure must not fail an + // otherwise complete install. Go records the same case as a failed + // directory yet still returns success for the run. try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { - console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); - failed += 1; + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); } continue; } diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index 56e70758..a2e90013 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -194,6 +194,13 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 上述验证和失败合同。 +原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件 +系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与 +overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级 +为“显式存在性检查 + 普通 rename”:不覆盖既有目标的契约保持不变,仅失去原子性, +其检查与 rename 之间的竞态窗口与普通 rename 本身一致,并由发布后的身份与内容校验 +兜底。Windows `MoveFile` 本身即拒绝已存在的目标,无需降级。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 56858494..344809c7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -388,7 +388,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { retiredNames = append(retiredNames, multiMiscSkill) } if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { - return retireErr + // Retiring an obsolete universal copy installs nothing; report it and + // keep the successful installation rather than failing the run. + fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr) } } if skipped > 0 { @@ -1231,63 +1233,6 @@ func samePhysicalSkillSetupPath(left, right string) bool { return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } -func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { - // Derive HOME from a concrete Agent destination instead of resolving it a - // second time. The destinations were already resolved from HOME by the - // caller, and a later/transient UserHomeDir failure must not turn an - // otherwise valid setup plan into an error. Direct/custom destinations that - // do not match a known concrete Agent root have no generic-root migration. - home := "" - for _, dest := range dests { - base := dest - if filepath.Base(dest) == "dws" { - base = filepath.Dir(dest) - } - base = filepath.Clean(base) - for i, rel := range skillSetupAgentHomes { - if i == 0 { - continue - } - suffix := filepath.Clean(filepath.FromSlash(rel)) - needle := string(filepath.Separator) + suffix - if strings.HasSuffix(base, needle) { - home = strings.TrimSuffix(base, needle) - break - } - } - if home != "" { - break - } - } - if home == "" { - return nil, nil - } - genericBase := filepath.Join(home, ".agents", "skills") - - target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true} - add := func(path, reason string) { - if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason}) - } - } - add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual) - entries, readErr := skillSetupReadDir(genericBase) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr) - } - for _, entry := range entries { - path := filepath.Join(genericBase, entry.Name()) - if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale}) - } - } - if len(target.Backups) == 0 { - return nil, nil - } - sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - return target, nil -} - func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) { if mode != skillSetupModeMono && mode != skillSetupModeMulti { return nil, fmt.Errorf("内部错误:未知 mode %q", mode) @@ -2116,14 +2061,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement must not count as a skipped install: any skipped + // count fails the whole setup, even when every real target succeeded. if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr) } continue } diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go index 902aec6a..928f672d 100644 --- a/internal/app/skill_setup_event_migration_universal_test.go +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -112,7 +112,9 @@ func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T } }) - t.Run("backup propagates through command", func(t *testing.T) { + // Retiring an obsolete universal copy installs nothing, so its failure is + // surfaced as a warning and the successful installation is kept. + t.Run("backup failure warns without failing the command", func(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) codex := filepath.Join(home, ".codex", "skills") @@ -121,14 +123,17 @@ func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) - _, _, err := executeMultiSkillSetupTest( + _, stderr, err := executeMultiSkillSetupTest( t, src, []string{filepath.Join(home, ".agents", "skills"), codex}, "--skill", "event", "--yes", ) - if !errors.Is(err, failure) || !strings.Contains(err.Error(), "退役 universal Agent") { - t.Fatalf("retirement backup failure = %v, want wrapped %v", err, failure) + if err != nil { + t.Fatalf("retirement backup failure must not fail the command: %v", err) + } + if !strings.Contains(stderr, "退役 universal Agent") { + t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr) } }) } diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index 406b318a..90b13869 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -174,32 +174,13 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar t.Fatalf("universal cleanup preview missing: %s", preview.String()) } - t.Run("managed multi and scan failure", func(t *testing.T) { - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { - t.Fatal(err) - } - managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true}) - if targetErr != nil || target == nil || len(target.Backups) != 2 { - t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr) - } - failure := errors.New("generic scan failure") - testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) - if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { - t.Fatalf("generic scan error = %v", targetErr) - } - }) } func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) { failure := errors.New("cleanup failure") cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}} - t.Run("prior skip suppresses cleanup", func(t *testing.T) { + t.Run("cleanup runs even after an install skip", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil }) plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}} installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard) @@ -208,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. } }) + // A cleanup-only target installs nothing, so its failure is a warning and + // must never increment skipped — runSkillSetup turns any skipped count into + // a hard error and would fail an otherwise complete installation. t.Run("home failure keeps generic copy", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -222,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") { t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 6388ce46..5c11dd5d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { for _, d := range succeeded { fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir))) } + for _, d := range result.RetireWarnings() { + fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error())) + } } else { fmt.Printf(" %s\n", ugGreen("✓")) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 9008fc31..174b776e 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -424,8 +424,21 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { return origRename(src, dst) }) result, err := UpgradeSkillLocations(makeSource(t)) - if err != nil || len(result.Failed()) != 1 { - t.Fatalf("retire failure = %#v, %v", result, err) + if err != nil { + t.Fatalf("retire failure must not fail the upgrade: %v", err) + } + if len(result.Failed()) != 0 { + t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed()) + } + warnings := result.RetireWarnings() + if len(warnings) != 1 || warnings[0].Err == nil { + t.Fatalf("retire warnings = %#v", warnings) + } + if len(result.Succeeded()) == 0 { + t.Fatalf("canonical must still be published: %#v", result) + } + if _, statErr := os.Lstat(victim); statErr != nil { + t.Fatalf("unretired copy must be preserved: %v", statErr) } }) diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 05551c79..f75d575d 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -239,13 +239,17 @@ const ( SkillDirSkipped // agent not detected, directory skipped SkillDirBlacklisted // blacklisted, never touched SkillDirFailed // installation attempted but failed + // SkillDirRetireWarning marks a universal Agent whose obsolete private copy + // could not be retired. Nothing is installed below such a root, so the + // leftover is reported without counting as an install failure. + SkillDirRetireWarning ) // SkillDirResult holds the per-directory install result. type SkillDirResult struct { Dir string // destination directory (e.g. ~/.claude/skills/dws) Status SkillDirStatus // outcome - Err error // non-nil when Status == SkillDirFailed + Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning } // SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call. @@ -275,6 +279,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { return out } +// RetireWarnings returns universal Agent roots that still hold an obsolete +// private copy because retiring it failed. +func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult { + var out []SkillDirResult + for _, d := range r.Results { + if d.Status == SkillDirRetireWarning { + out = append(out, d) + } + } + return out +} + // UpgradeSkillLocations refreshes skills from extractedDir into agent homes. // extractedDir may be a multi-skill bundle root (subdirectories each containing // SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that @@ -789,7 +805,7 @@ func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names [ } needed = append(needed, name) } - filteredVictims := victims[:0] + filteredVictims := make([]string, 0, len(victims)) for _, victim := range victims { if !correct[filepath.Clean(victim)] { filteredVictims = append(filteredVictims, victim) @@ -840,7 +856,9 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e } if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err}) } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) } @@ -912,7 +930,9 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk } if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err}) } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) } @@ -1005,23 +1025,6 @@ func skillTreeHasRoot(dir string) bool { return err == nil && !info.IsDir() } -// cleanupMultiLeftovers backs up + removes every proven DWS-managed -// multi-mode skill directory inside one agent home before mono is installed. -// A missing base directory simply means no leftovers; any other read failure -// is reported so mono never silently co-exists with multi. Removal is -// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a -// backup failure aborts the removal for that home. -func cleanupMultiLeftovers(homeDir, baseDir string) error { - victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理 multi 残留失败: %w", err) - } - return nil -} - func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) { entries, err := upgradeReadDir(baseDir) if err != nil { @@ -1040,24 +1043,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str return victims, nil } -// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the -// legacy mono directory (dws/) and every proven DWS-managed multi skill -// directory that is not part of the new bundle. A dingtalk-* prefix alone is -// not proof of ownership because market/user skills may use the same prefix. -// Removal is reversible: each -// directory is preserved under ~/.dws/skill-backups/ and a backup failure -// aborts the removal for that home. -func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error { - victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理对面模式残留失败: %w", err) - } - return nil -} - func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) { victims := []string{filepath.Join(destBase, "dws")} entries, err := upgradeReadDir(destBase) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 6ba5d481..9ae6579d 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -994,6 +994,30 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { } } +// The production wrappers that used to bundle these two steps were removed as +// dead code; the test keeps exercising the live building blocks they composed. +func cleanupMultiLeftoversForTest(homeDir, baseDir string) error { + victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) + if err != nil { + return err + } + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理 multi 残留失败: %w", err) + } + return nil +} + +func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error { + victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) + if err != nil { + return err + } + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理对面模式残留失败: %w", err) + } + return nil +} + // TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers: // read failures surface, backup failures abort, and the opposite-mode cleanup // preserves bundle skills while removing the mono leftover. @@ -1003,11 +1027,11 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Read failure (non-ENOENT) surfaces from both cleanups. testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupMultiLeftovers read error = %v", err) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err) } - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err) + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err) } testseam.Swap(t, &upgradeReadDir, os.ReadDir) @@ -1015,13 +1039,13 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } // Backup failure of the mono leftover aborts the opposite-mode cleanup. os.MkdirAll(filepath.Join(base, "dws"), 0o755) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup mono backup error = %v", err) } testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) @@ -1033,7 +1057,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } return renameSkillPathNoReplace(src, dst) }) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup stale backup error = %v", err) } testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) @@ -1044,7 +1068,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { skillSet := map[string]bool{"dingtalk-keep": true} os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755) os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644) - if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil { + if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil { t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err) } for _, gone := range []string{"dws", "dingtalk-stale"} { @@ -1064,7 +1088,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } // cleanupMultiLeftovers on a missing base is a no-op. - if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil { + if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil { t.Fatalf("missing base must be a no-op, got %v", err) } } diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 03c97de7..107e266a 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -18,7 +18,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(_, _ os.FileInfo) bool { // MoveFile can surface different metadata identities for the same reparse - // point before and after rename. Windows ownership is therefore proved by - // the separately checked creation-time incarnation plus lexical fingerprint. + // point before and after rename, so Windows ownership relies on the + // separately checked creation-time incarnation plus the lexical fingerprint. + // That pair is strong evidence but not proof: NTFS file tunneling restores + // the original creation time when a same-named object is recreated in the + // same directory shortly afterwards, so a byte-identical concurrent + // replacement can still be attributed to this transaction. return true } diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go new file mode 100644 index 00000000..490f0f9a --- /dev/null +++ b/internal/upgrade/skill_publish_rename.go @@ -0,0 +1,40 @@ +package upgrade + +import ( + "errors" + "fmt" + "os" +) + +// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace +// rename primitive at all. +var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename") + +var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic + +// renameSkillPathNoReplace moves source onto destination and never replaces an +// object that already occupies destination. +// +// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require +// support from the underlying filesystem and report EINVAL/ENOTSUP when it is +// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and +// overlayfs homes reject the flag. Failing there would abort the whole install +// on those filesystems, so they degrade to an explicit existence check plus a +// plain rename: the no-clobber contract is preserved, only its atomicity is +// lost. That check-then-rename window is the same exposure a plain rename +// always had, and callers additionally verify identity after publication. +func renameSkillPathNoReplace(source, destination string) error { + err := skillPathRenameNoReplaceAtomic(source, destination) + if err == nil { + return nil + } + if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { + return err + } + if _, statErr := skillPathLstat(destination); statErr == nil { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查发布目标失败 %s: %w", destination, statErr) + } + return skillPathRename(source, destination) +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go index 06722ef1..94df0fb2 100644 --- a/internal/upgrade/skill_publish_rename_darwin.go +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -2,8 +2,21 @@ package upgrade -import "golang.org/x/sys/unix" +import ( + "errors" + "syscall" -func renameSkillPathNoReplace(source, destination string) error { + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { return unix.RenamexNp(source, destination, unix.RENAME_EXCL) } + +// Volumes whose filesystem does not implement renamex_np (several network and +// FUSE mounts) reject RENAME_EXCL instead of performing the rename. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go index 7936592a..20d1be77 100644 --- a/internal/upgrade/skill_publish_rename_linux.go +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -2,8 +2,22 @@ package upgrade -import "golang.org/x/sys/unix" +import ( + "errors" + "syscall" -func renameSkillPathNoReplace(source, destination string) error { + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) } + +// rename(2) documents EINVAL as "the filesystem does not support one of the +// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels +// without renameat2 at all. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) || + errors.Is(err, syscall.EOPNOTSUPP) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go index bdd0986b..c9695771 100644 --- a/internal/upgrade/skill_publish_rename_other.go +++ b/internal/upgrade/skill_publish_rename_other.go @@ -2,11 +2,8 @@ package upgrade -import ( - "fmt" - "runtime" -) - -func renameSkillPathNoReplace(_, _ string) error { - return fmt.Errorf("当前平台 %s 不支持安全的原子 no-replace rename", runtime.GOOS) +func renameSkillPathNoReplaceAtomic(_, _ string) error { + return errNoReplaceRenameUnsupported } + +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go new file mode 100644 index 00000000..e3996aa3 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_test.go @@ -0,0 +1,109 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for +// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). +// Those installs must still publish, and must still refuse to clobber an +// occupied destination. +func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { + unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} + + t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" { + t.Fatalf("destination = %q, %v", data, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + for _, path := range []string{source, destination} { + if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" { + t.Fatalf("destination must be untouched, got %q, %v", data, readErr) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("a genuine error is not retried", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + calls := 0 + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { + calls++ + return os.ErrPermission + }) + if err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("permission error must surface, got %v", err) + } + if calls != 1 { + t.Fatalf("atomic attempts = %d, want 1", calls) + } + }) + + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { + if isNoReplaceRenameUnsupported(syscall.EXDEV) { + t.Fatal("EXDEV must not be treated as an unsupported flag") + } + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "backup", "destination") + original := skillPathRenameNoReplaceAtomic + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { + if from == source { + return syscall.EXDEV + } + return original(from, to) + }) + if err := moveSkillPathRecoverably(source, destination); err != nil { + t.Fatalf("cross-device move = %v", err) + } + if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" { + t.Fatalf("cross-device destination = %q, %v", data, err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go index 7ac5afb1..18927afe 100644 --- a/internal/upgrade/skill_publish_rename_windows.go +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -4,7 +4,7 @@ package upgrade import "golang.org/x/sys/windows" -func renameSkillPathNoReplace(source, destination string) error { +func renameSkillPathNoReplaceAtomic(source, destination string) error { sourcePtr, err := windows.UTF16PtrFromString(source) if err != nil { return err @@ -15,3 +15,8 @@ func renameSkillPathNoReplace(source, destination string) error { } return windows.MoveFile(sourcePtr, destinationPtr) } + +// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied +// destination on every supported Windows version, so there is nothing to +// degrade to. +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 1719391d..50502caa 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -258,15 +258,73 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { } } +# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so +# copies retired from different Agent roots stay distinguishable. Mirrors +# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall +# back to the bare leaf. +function Get-DevSkillBackupName([string]$Dir) { + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { return $leaf } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching +# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this +# run created are never pruned: a rollback still needs them. +$DevSkillBackupKeep = 5 +$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +function Remove-OldDevSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Sort-Object -Property Name) + $excess = $dirs.Count - $DevSkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + $excess-- + } +} + function Backup-DevSkill([string]$path, [ref]$BackupPath) { if ($null -ne $BackupPath) { $BackupPath.Value = "" } if (!(Test-PathLexically $path)) { return } - $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" + $backupBase = Join-Path $HOME ".dws\skill-backups" + $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $name = Get-DevSkillBackupName $path + $backupRoot = Join-Path $backupBase $stamp + $target = Join-Path $backupRoot $name + $i = 1 + while (Test-PathLexically $target) { + $backupRoot = Join-Path $backupBase "$stamp-$i" + $target = Join-Path $backupRoot $name + $i++ + if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" } + } New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null - $target = Join-Path $backupRoot (Split-Path $path -Leaf) - while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } Move-DevSkillPathRecoverably $path $target if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null + Remove-OldDevSkillBackups + } catch { + Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)" + } } function Get-DevSkillLinkSignature([string]$Path) { @@ -487,6 +545,7 @@ if (-not $NoSkills) { Publish-DevSkillCopy $src $canonical $installed = 1 $failed = 0 + $retireFailed = 0 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { $parts = $row.Split('|'); $universal = $parts[1] -eq "1" @@ -497,9 +556,13 @@ if (-not $NoSkills) { if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName if ($universal) { + # Cleanup-only migration: universal Agents read the + # canonical store directly, so nothing is installed here. A + # retire failure only leaves an obsolete copy behind and + # must never fail an otherwise complete install. try { Backup-DevSkill $dest } catch { - Say "⚠️ Agent Skill 旧副本备份失败,保留原目录: $dest ($($_.Exception.Message))" - $failed++ + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))" + $retireFailed++ } continue } @@ -523,6 +586,9 @@ if (-not $NoSkills) { $installed++ } if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } + if ($retireFailed -gt 0) { + Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)" + } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 698d9048..e2a9219e 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -62,18 +62,84 @@ copy_tree() { return 1 } +# refresh_cache_tree +# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree +# is byte-reproducible from the release asset, so it is never archived into +# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the +# old cache after the replacement is fully staged, so an interrupted install can +# no longer leave a half-copied cache behind. +refresh_cache_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive, so the backup the caller is about + # to depend on for rollback can never be a prune candidate. + prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 - while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"; done + while [ -e "$backup" ] || [ -L "$backup" ]; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC +# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is +# reported and never fatal. +SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + prune_seen=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_seen=$((prune_seen + 1)) + [ "$prune_seen" -le "$prune_drop" ] || break + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + done +} + same_physical_skill() { [ -d "$1" ] && [ -d "$2" ] || return 1 left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 @@ -200,20 +266,24 @@ install_skill() { # cache so `dws skill setup --mode multi` can find a source later cache="$HOME/.dws/skills/multi/$SKILL_NAME" - rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" + refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; } canonical="$HOME/.agents/skills/$SKILL_NAME" copy_tree "$src" "$canonical" || return 1 installed=1 failed=0 + retire_failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then - printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$SKILL_NAME" >&2 - failed=$((failed + 1)) + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2 + retire_failed=$((retire_failed + 1)) fi continue fi @@ -226,6 +296,9 @@ install_skill() { fi installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 6ff57fc9..d4e65b6b 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -127,9 +127,44 @@ copy_tree() { return 1 } +# refresh_cache_tree +# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills +# caches: those trees are byte-reproducible from the release asset, so archiving +# every stale generation into ~/.dws/skill-backups only grows that directory +# without bound. The previous cache is deleted, exactly like install-devapp.sh — +# but only after the replacement is fully staged and published. +refresh_cache_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive, so the backup the caller is about + # to depend on for rollback can never be a prune candidate. + prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" backup_root="$HOME/.dws/skill-backups/$stamp" @@ -137,12 +172,43 @@ backup_skill_dir() { i=0 while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi done mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC +# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is +# reported and never fatal. +SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + prune_seen=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_seen=$((prune_seen + 1)) + [ "$prune_seen" -le "$prune_drop" ] || break + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + done +} + same_physical_skill() { [ -d "$1" ] && [ -d "$2" ] || return 1 left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 @@ -221,14 +287,18 @@ install_skill_to_homes() { copy_tree "$src" "$canonical" || return 1 installed=1 failed=0 + retire_failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. if ! backup_skill_dir "$base/$skill_name" >/dev/null; then - printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$skill_name" >&2 - failed=$((failed + 1)) + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2 + retire_failed=$((retire_failed + 1)) fi continue fi @@ -241,6 +311,9 @@ install_skill_to_homes() { fi installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 return 1 @@ -298,12 +371,12 @@ install_skills_from_bundle() { event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME" misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME" - copy_tree "$event_src" "$event_cache" - copy_tree "$shared_src" "$shared_cache" - copy_tree "$misc_src" "$misc_cache" + refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}" + refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}" + refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}" mono_cache="$HOME/.dws/skills/mono" - copy_tree "$mono_src" "$mono_cache" + refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}" event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index c5b9a276..c3b3de3c 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -80,6 +80,31 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. +# Only safe to call once every backup manifest of this run has been consumed, +# because a single transaction can span more than one timestamp. +DWS_SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + _psb_seen=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_seen=$((_psb_seen + 1)) + [ "$_psb_seen" -le "$_psb_drop" ] || break + rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -475,42 +500,46 @@ multi_tree_has_skills() { return 1 } -# ~/.agents/skills is the canonical store under the universal convention. -# Universal Agents read it directly; other Agents receive -# relative links and fall back to copies only when links are unavailable. -is_universal_agent_dir() { - case "$1" in - ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; - *) return 1 ;; - esac -} - # Exact upstream registry (76 IDs): id|classification|effective-global-root. # `-` means no global directory; `.agents/skills` means canonical-direct. -readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' upstream_agent_registry() { for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done } +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots, in pinned registry order, de-duplicated. Records +# without a global root (`-`) and canonical-direct records (`.agents/skills`) +# are not per-Agent targets and are skipped. agent_skill_dirs() { - printf '%s\n' \ - ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ - ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ - ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ - ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ - ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ - ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ - ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ - ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ - ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ - ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ - ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ - ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ - ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ - ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ - ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ - ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ - ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' } resolve_agent_skill_base() { @@ -548,8 +577,11 @@ agent_skill_base_detected() { same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 - _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 - _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 [ "$_sps_left" = "$_sps_right" ] } @@ -573,26 +605,55 @@ retire_agent_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. link_canonical_skills_to_base() { - _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3" + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}" _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n' + printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical" + return 1 + fi _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } - if [ "$_lcs_mode" = "mono" ]; then - _lcs_names="dws" - else - _lcs_names="" - for _lcs_skill in "$_lcs_canonical"/*/; do - [ -f "${_lcs_skill}SKILL.md" ] || continue - _lcs_names="$_lcs_names $(basename "$_lcs_skill")" - done - fi _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi @@ -619,17 +680,24 @@ link_canonical_skills_to_base() { _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - # Keep the staged hardlink alive until the whole transaction finishes. It - # pins the inode, so a concurrently recreated link cannot reuse the - # identity recorded in the publication manifest. + # The staged symlink keeps its inode across the rename below (the staging + # directory is a sibling inside the same Agent root), so the identity + # recorded in the publication manifest stays exact. _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + # Anything still present here appeared after the pre-flight scan, i.e. a + # concurrent writer. Never let `mv` clobber it. + if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is + # absent from BusyBox `ln`, which silently degraded every non-universal + # Agent to the copy layout on Alpine and most containers). install-event.sh + # and install-devapp.sh already publish this way. The staged basename stays + # unique, so a destination a concurrent process turned into a directory can + # only receive our link and post-validation removes exactly that. + if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi @@ -649,6 +717,7 @@ install_multi_skills_to_root() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) @@ -658,10 +727,10 @@ install_multi_skills_to_root() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi - if link_canonical_skills_to_base "$root" "$base_dir" multi; then + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then @@ -676,6 +745,9 @@ install_multi_skills_to_root() { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed" return 1 @@ -841,6 +913,7 @@ install_skills_to_root() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) @@ -850,7 +923,7 @@ install_skills_to_root() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" mono; then @@ -868,6 +941,9 @@ install_skills_to_root() { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed" return 1 @@ -949,6 +1025,10 @@ main() { fi fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4cda9a7d..99712993 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -704,6 +704,57 @@ function Publish-SkillCache { } } +# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so +# copies retired from different Agent roots stay distinguishable inside one +# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths +# outside HOME fall back to the bare leaf. +function Get-SkillBackupName { + param([string]$Dir) + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { + return $leaf + } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest +# stamped backup directories are kept, matching skillBackupKeep and +# pruneSkillBackups in internal/upgrade/paths.go. +$SkillBackupKeep = 5 +$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Remove-OldSkillBackups deletes the oldest stamped backup directories once +# more than $SkillBackupKeep remain. Stamps sort lexicographically in +# chronological order, so name order is age order. Roots created during this +# run are never pruned: an in-flight transaction still needs them to roll +# back. Best-effort — a prune failure never fails the install. +function Remove-OldSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Sort-Object -Property Name) + $excess = $dirs.Count - $SkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + $excess-- + } +} + # Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\\ # instead of destroying it (non-interactive installs cannot confirm, so # removals must stay reversible). Missing paths are a no-op success. On any @@ -718,11 +769,13 @@ function Backup-SkillDir { if (!(Test-SkillPathLexically -Path $Dir)) { return $true } $backupRoot = Join-Path $HOME ".dws\skill-backups" $stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss") - $name = Split-Path $Dir -Leaf - $target = Join-Path (Join-Path $backupRoot $stamp) $name + $name = Get-SkillBackupName -Dir $Dir + $targetRoot = Join-Path $backupRoot $stamp + $target = Join-Path $targetRoot $name $i = 1 while (Test-SkillPathLexically -Path $target) { - $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name + $targetRoot = Join-Path $backupRoot "$stamp-$i" + $target = Join-Path $targetRoot $name $i++ if ($i -gt 1000) { Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir" @@ -737,6 +790,12 @@ function Backup-SkillDir { return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null + Remove-OldSkillBackups + } catch { + Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_" + } Write-Say " × 已备份并移除 $Dir → $target" return $true } @@ -942,7 +1001,7 @@ function Move-AgentSkillRootToBackup { } function Publish-CanonicalSkillLinks { - param([string]$Root, [string]$BaseDir, [string]$Mode) + param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @()) $canonical = Join-Path $Root ".agents\skills" if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } @@ -953,10 +1012,17 @@ function Publish-CanonicalSkillLinks { New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null if ($Mode -eq "mono") { $names = @($SkillName) + } elseif ($BundleNames.Count -gt 0) { + # The canonical store is SHARED: enumerate the link set from the + # installed bundle, never from ~\.agents\skills, or third-party and + # user skills would be republished into every Agent root. + $names = @($BundleNames) } else { - $names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object { - Test-Path (Join-Path $_.FullName "SKILL.md") - } | ForEach-Object { $_.Name }) + # No bundle list means we cannot tell DWS skills apart from the + # user's own entries in the shared store. Fail this Agent (the + # caller degrades to a copy install) instead of guessing, matching + # link_canonical_skills_to_base in scripts/install-skills.sh. + throw "multi mode requires the installed bundle skill names" } $publishNames = @() foreach ($name in $names) { @@ -971,6 +1037,12 @@ function Publish-CanonicalSkillLinks { if ($existing.FullName -eq $stageRoot) { continue } if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } } + # Every published name replaces whatever occupies its destination, even + # when that copy predates central ownership metadata; the loop below + # still skips destinations that are already correct links. + foreach ($name in $names) { + $victims.Add((Join-Path $BaseDir $name)) + } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($victim in $victims) { if (!$seen.Add($victim)) { continue } @@ -1315,7 +1387,13 @@ function Install-SkillsToHomes { if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { - if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } + # Cleanup-only migration: universal Agents read the canonical store + # directly, so nothing is installed here. Leaving an obsolete copy + # behind is a warning, never a reason to fail an install whose + # canonical store and links all succeeded. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { @@ -1370,6 +1448,11 @@ function Install-MultiSkillsToHomes { $attempted = 1 $failed = 0 $canonical = Join-Path $Root ".agents\skills" + # The link set must come from the installed bundle, not from the shared + # canonical store (which may also hold user/third-party skills). + $bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } if ($installed -eq 0) { return $false } $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -1382,10 +1465,14 @@ function Install-MultiSkillsToHomes { if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { - if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } + # Cleanup-only migration (see Install-SkillsToHomes): a retire + # failure must not fail an otherwise complete install. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) { $installed++ } else { if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) { diff --git a/scripts/install.sh b/scripts/install.sh index 45e5a688..a9aa5883 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -98,6 +98,31 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. +# Only safe to call once every backup manifest of this run has been consumed, +# because a single transaction can span more than one timestamp. +DWS_SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + _psb_seen=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_seen=$((_psb_seen + 1)) + [ "$_psb_seen" -le "$_psb_drop" ] || break + rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -704,46 +729,47 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# ~/.agents/skills is the canonical store under the universal convention. -# Universal Agents read it directly; other Agents receive -# relative links and fall back to copies only when links are unavailable. -is_universal_agent_dir() { - case "$1" in - ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; - *) return 1 ;; - esac -} - # Exact upstream registry (76 IDs): id|classification|effective-global-root. # `-` means no global directory; `.agents/skills` means canonical-direct. -readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' upstream_agent_registry() { for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done } -# Effective global roots from vercel-labs/skills. Duplicate upstream roots -# (amp/replit/universal and zencoder/zenflow) deliberately appear once. -# Qoderwork is a DWS-only non-universal target. The final four entries are -# cleanup-only compatibility paths from older DWS installers. +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots from vercel-labs/skills, in pinned registry order and +# de-duplicated (amp/replit/universal and zencoder/zenflow share a root). +# Records without a global root (`-`) and canonical-direct records +# (`.agents/skills`) are not per-Agent targets and are skipped. agent_skill_dirs() { - printf '%s\n' \ - ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ - ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ - ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ - ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ - ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ - ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ - ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ - ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ - ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ - ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ - ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ - ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ - ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ - ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ - ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ - ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ - ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' } resolve_agent_skill_base() { @@ -781,8 +807,11 @@ agent_skill_base_detected() { same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 - _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 - _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 [ "$_sps_left" = "$_sps_right" ] } @@ -806,13 +835,52 @@ retire_agent_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. link_canonical_skills_to_base() { _lcs_root="$1" _lcs_base="$2" _lcs_mode="$3" + _lcs_bundle="${4-}" _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;" + say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。" + return 1 + fi _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" @@ -820,15 +888,6 @@ link_canonical_skills_to_base() { _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } - if [ "$_lcs_mode" = "mono" ]; then - _lcs_names="dws" - else - _lcs_names="" - for _lcs_skill in "$_lcs_canonical"/*/; do - [ -f "${_lcs_skill}SKILL.md" ] || continue - _lcs_names="$_lcs_names $(basename "$_lcs_skill")" - done - fi _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then @@ -862,19 +921,28 @@ link_canonical_skills_to_base() { rm -rf "$_lcs_stage" return 1 } - # Keep the staged hardlink alive until the whole transaction finishes. It - # pins the inode, so a concurrently recreated link cannot reuse the - # identity recorded in the publication manifest. + # The staged symlink keeps its inode across the rename below (the staging + # directory is a sibling inside the same Agent root), so the identity + # recorded in the publication manifest stays exact. _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + # Anything still present here appeared after the pre-flight scan, i.e. a + # concurrent writer. Never let `mv` clobber it. + if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is + # absent from BusyBox `ln`, which silently degraded every non-universal + # Agent to the copy layout on Alpine and most containers). install-event.sh + # and install-devapp.sh already publish this way. The staged basename stays + # unique, so a destination a concurrent process turned into a directory can + # only receive our link and post-validation removes exactly that. + if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" @@ -900,6 +968,7 @@ install_skills_to_homes() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in $(agent_skill_dirs) @@ -909,7 +978,7 @@ install_skills_to_homes() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" mono; then @@ -927,6 +996,9 @@ install_skills_to_homes() { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败" return 1 @@ -964,6 +1036,7 @@ install_multi_skills_to_homes() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in $(agent_skill_dirs) @@ -973,10 +1046,10 @@ install_multi_skills_to_homes() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi - if link_canonical_skills_to_base "$root" "$base_dir" multi; then + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then @@ -991,6 +1064,9 @@ install_multi_skills_to_homes() { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装失败" return 1 @@ -1335,6 +1411,10 @@ main() { install_skills fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' say "🎉 Installation complete!" say "" diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 3e2cd6b4..da1512b7 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -223,6 +223,13 @@ verify_compatible_skill_base() { name=$(basename "$(dirname "$skill_md")") target="$base/$name" if [ -L "$target" ]; then + link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target" + # Relative link targets are the headline invariant of the canonical store: + # an absolute target breaks as soon as the home directory moves or the + # store is relocated, so reject it even though it resolves today. + case "$link_target" in + /*) err "canonical Skill link must be relative at $target (target=$link_target)" ;; + esac linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" @@ -235,6 +242,23 @@ verify_compatible_skill_base() { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" } +# Universal Agents read the canonical store directly, so they must own no copy +# of ANY canonical Skill. Iterate the real store instead of a hardcoded name +# list: a hardcoded list silently passed leftover copies of every other +# dingtalk-* Skill from earlier betas. +verify_universal_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" + done + # The mono layout is absent from a multi canonical store, so gate it directly. + { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/dws" +} + verify_skill_targets() { home_root="$1" # The universal-convention canonical store is always present, regardless of @@ -253,10 +277,7 @@ verify_skill_targets() { [ "$base" != "$home_root/.agents/skills" ] || continue [ -d "${base%/skills}" ] || continue if [ "$universal" -eq 1 ]; then - for name in dingtalk-shared dingtalk-misc dws; do - { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ - err "unexpected duplicate Skill found in universal Agent root $base/$name" - done + verify_universal_skill_base "$home_root" "$base" continue fi verify_compatible_skill_base "$home_root" "$base" diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 8142f74e..6af14c7a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -16,22 +16,37 @@ * skills, mono leftover dws/ and stale * dingtalk-* are removed, and the * ~/.dws/skills/{multi,mono} caches fill. - * 2. empty multi/ tree — warns and falls back to mono instead of + * 2. universal topology — canonical store installed, pre-existing private + * copies retired and recoverable under + * ~/.dws/skill-backups//. + * 3. second run — idempotent: an already-correct link is neither + * re-backed-up nor rewritten. + * 4. unretireable universal root — a cleanup-only failure WARNS; canonical + * store, non-universal links and skills-state.json + * are all still written and the install exits 0. + * 5. empty multi/ tree — warns and falls back to mono instead of * crashing postinstall; a previously good multi * cache is NOT wiped. - * 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an + * 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an * "invalid DWS_SKILL_MODE" error. - * 4. multi-only zip, mono — mono install is skipped with a warning; the + * 7. multi-only zip, mono — mono install is skipped with a warning; the * staging root is NOT copied into a dws/ dir. - * 5. multi backup failure — preserves mono, writes no multi skill, and + * 8. multi backup failure — preserves mono, writes no multi skill, and * reports postinstall failure. - * 6. mono backup failure — preserves multi, writes no mono skill, and + * 9. mono backup failure — preserves multi, writes no mono skill, and * reports postinstall failure. - * 7. mono switch — migrates only centrally owned multi Skills. - * 8. cache copy failure — preserves the previous complete cache. - * 9. multi publish failure — restores the complete previous Skill set. - * 10. multi backup failure — restores every earlier successful backup. - * 11. mono transaction failure — restores every managed multi Skill after + * 10. mono switch — migrates only centrally owned multi Skills. + * 11. cache copy failure — preserves the previous complete cache. + * 12. no-replace publish — an occupied destination that is not a victim is + * refused, not replaced or linked into. + * 13. simulated win32 — junction type + ABSOLUTE canonical target and + * the rename publish path with its no-replace + * guard, exercised by swapping process.platform + * instead of requiring a Windows host. + * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. + * 15. multi publish failure — restores the complete previous Skill set. + * 16. multi backup failure — restores every earlier successful backup. + * 17. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools @@ -133,6 +148,24 @@ function crossDeviceError() { return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); } +// withSimulatedWin32 runs fn with process.platform === "win32" so the +// Windows-only publish branches (junction type, absolute link target, rename +// publish + its no-replace guard) get real execution coverage on a unix host. +// fs.symlinkSync ignores the type argument off Windows, so the same call is +// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the +// simulated platform. +function withSimulatedWin32(fn) { + const original = process.platform; + const set = (value) => + Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false }); + set("win32"); + try { + return fn(); + } finally { + set(original); + } +} + function runCrossDeviceMoveContract() { const roots = []; const fixture = (name) => { @@ -465,6 +498,93 @@ scenario("ZCode links its Agent root to the canonical store", () => { } }); +scenario("a second install run is idempotent and preserves correct links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + try { + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + + const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + const firstTarget = fs.readlinkSync(linked); + const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + + const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link"); + assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + !second.stdout.includes(`已备份并移除 ${linked}`), + "an already-correct link must not be backed up again", + ); + const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs"); + assert.deepEqual( + secondState.managed_skills.map((record) => record.digest), + firstState.managed_skills.map((record) => record.digest), + "re-installing the same bundle yields the same provenance digests", + ); + assert.ok( + fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5, + "repeated installs keep the backup root bounded", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("unretireable universal copy warns but still completes the install", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const codexSkills = path.join(home, ".codex", "skills"); + try { + // Non-universal Agent that must still receive its link. + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + // Universal Agent whose obsolete copy cannot be moved out: r-x on the + // parent means rename() fails with EACCES. + writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + fs.chmodSync(codexSkills, 0o500); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: path.join(home, ".config"), + CODEX_HOME: "", + }); + assert.equal( + res.status, + 0, + `a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`, + ); + assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning"); + assert.ok( + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "canonical store is still installed", + ); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + fs.existsSync(path.join(home, ".dws", "skills-state.json")), + "skills-state.json is written even when a retire failed", + ); + assert.equal( + fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"), + "old codex copy\n", + "a copy that cannot be backed up is never deleted", + ); + } finally { + try { + fs.chmodSync(codexSkills, 0o700); + } catch {} + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", @@ -510,16 +630,28 @@ scenario("upstream Agent roots honor XDG and custom homes with relative links", assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); } const backupText = []; + const backupNames = []; const collectBackupText = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) collectBackupText(full); - else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + if (entry.isDirectory()) { + backupNames.push(entry.name); + collectBackupText(full); + } else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); } }; collectBackupText(path.join(home, ".dws", "skill-backups")); assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); + // Retired universal copies are the PR's advertised recovery story: every + // one of them must be readable back out of ~/.dws/skill-backups. + assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable"); + assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable"); + assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable"); + assert.ok( + backupNames.includes(".amp-skills-dingtalk-chat"), + `backup names record the HOME-relative origin, got ${backupNames.join(",")}`, + ); for (const retired of [ path.join(xdg, "agents", "skills", "dingtalk-chat"), path.join(codex, "skills", "dingtalk-chat"), @@ -691,21 +823,31 @@ scenario("canonical link publication never clobbers or deletes concurrent user d const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-")); try { { + // Real no-replace property: the destination is occupied by data this + // transaction never backed up (it is not a victim), so the production + // publisher must refuse rather than replace or link into it. No stub + // creates or destroys anything here. const home = path.join(tmp, "no-clobber-home"); const canonical = path.join(home, ".agents", "skills"); const base = path.join(home, ".cursor", "skills"); const destination = path.join(base, "dingtalk-chat"); writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n"); assert.throws( - () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { - publishLinkFn(_source, target) { - writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); - throw Object.assign(new Error("concurrent destination exists"), { code: "EEXIST" }); - }, - }), - /concurrent destination exists/, + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []), + /Skill publish destination already exists/, ); assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.deepEqual( + fs.readdirSync(destination), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); } { @@ -756,6 +898,111 @@ scenario("canonical link publication never clobbers or deletes concurrent user d } }); +scenario("simulated win32 publishes junctions with absolute canonical targets", () => { + // Temp roots are created before entering the simulated platform: os.tmpdir() + // would otherwise resolve through Windows environment variables. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-")); + try { + const home = path.join(tmp, "home"); + const canonical = path.join(home, ".agents", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const base = path.join(home, ".zcode", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n"); + writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n"); + + const symlinkCalls = []; + const renameCalls = []; + withSimulatedWin32(() => { + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], { + symlinkFn(target, linkPath, type) { + symlinkCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + publishRenameFn(source, destination) { + renameCalls.push({ source, destination }); + fs.renameSync(source, destination); + }, + }); + }); + + assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill"); + assert.equal(symlinkCalls[0].type, "junction", "Windows must publish junctions, not symlinks"); + assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target"); + assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store"); + assert.equal(renameCalls.length, 1, "win32 publishes by rename, not by ln -P"); + assert.equal(renameCalls[0].destination, dest); + assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + const stamps = fs.readdirSync(backupRoot); + assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp"); + assert.deepEqual( + fs.readdirSync(path.join(backupRoot, stamps[0])), + [".zcode-skills-dingtalk-chat"], + "backups encode the HOME-relative origin of the retired copy", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"), + "old zcode copy\n", + "the replaced copy stays recoverable", + ); + + // FIX 5: the win32 rename path must never replace an object that appeared + // at the destination (libuv passes MOVEFILE_REPLACE_EXISTING). + const raceBase = path.join(home, ".qoder", "skills"); + const occupied = path.join(raceBase, "dingtalk-chat"); + writeFile(path.join(occupied, "concurrent-user-data.txt"), "must survive\n"); + let renames = 0; + withSimulatedWin32(() => { + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { + publishRenameFn(source, destination) { + renames += 1; + fs.renameSync(source, destination); + }, + }), + /Skill publish destination already exists/, + ); + }); + assert.equal(renames, 0, "win32 publish must not rename over an existing destination"); + assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(process.platform !== "win32", true, "the simulated platform is restored"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("skill backups stay bounded to the newest five stamps", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + } + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" }); + + const stamps = fs.readdirSync(backupRoot).sort(); + assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`); + assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning"); + assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first"); + assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess"); + assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained"); + assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat"); + assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 006cabc2..483840aa 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1862,18 +1862,24 @@ func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *test second := filepath.Join(base, "dingtalk-second") mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + bundle := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(bundle, "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(bundle, "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + // Publication now moves a staged symlink instead of using `ln -P` + // (BusyBox has no -P), and multi mode requires the bundle source so + // that only bundle skills are linked. Inject the race on that move. harness := `. "$DWS_TEST_LIBRARY" -ln() { - if [ "$1" = -P ] && [ "$3" = "$DWS_TEST_SECOND" ]; then +mv() { + if [ "$2" = "$DWS_TEST_SECOND" ]; then rm -f "$DWS_TEST_FIRST" mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" fi - command ln "$@" + command mv "$@" } -if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" @@ -1895,6 +1901,7 @@ rm -f "$identity_anchor" "HOME="+home, "DWS_TEST_LIBRARY="+library, "DWS_TEST_BASE="+base, + "DWS_TEST_BUNDLE="+bundle, "DWS_TEST_FIRST="+first, "DWS_TEST_SECOND="+second, ) diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 97ee3235..46155da0 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -180,9 +180,11 @@ func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { }{ { name: "correct-link", - prepare: func(t *testing.T, home, base string) { + prepare: func(t *testing.T, _, base string) { t.Helper() - if err := os.Symlink(filepath.Join(home, ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + // The canonical layout publishes RELATIVE links; the verifier now + // rejects absolute targets, so the fixture must match production. + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { t.Fatal(err) } }, From 14c2569cfbcf8e60f6a3af5ae086e7f4e44fda4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 11:42:43 +0800 Subject: [PATCH 036/267] fix(skills): guard pruneSkillBackups against non-DWS directories Restrict backup pruning to directories whose names match the DWS stamp format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in ~/.dws/skill-backups are now preserved. Also fixes Windows coverage test portability and covers the remaining macOS changed-code gap (retire warning loop in runUpgrade). --- .github/workflows/ci.yml | 4 ++ build/npm/install.js | 20 +++++-- internal/app/upgrade_full_coverage_test.go | 12 +++-- internal/upgrade/paths.go | 20 +++++-- internal/upgrade/paths_multi_test.go | 54 +++++++++++++++++++ internal/upgrade/skill_path_move_unix_test.go | 4 ++ .../upgrade/skill_path_move_windows_test.go | 4 ++ internal/upgrade/skill_publish_rename_test.go | 12 ++--- scripts/install-devapp.ps1 | 4 ++ scripts/install-devapp.sh | 23 ++++++++ scripts/install-event.sh | 23 ++++++++ scripts/install-skills.sh | 23 ++++++++ scripts/install.ps1 | 4 ++ scripts/install.sh | 23 ++++++++ test/scripts/install_js_smoke.mjs | 52 ++++++++++++++++-- 15 files changed, 264 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4d30325..2dc9e8f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,10 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js + - name: Test npm installer smoke (prune, backup, publish) + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + run: node test/scripts/install_js_smoke.mjs + test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint diff --git a/build/npm/install.js b/build/npm/install.js index 1a243910..8ca1ec32 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -163,9 +163,21 @@ const SKILL_BACKUP_KEEP = 5; // transaction still needs them to roll back. const currentRunBackupRoots = new Set(); +// skillBackupStampPattern matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/; + +function isSkillBackupStamp(name) { + return skillBackupStampPattern.test(name); +} + // pruneSkillBackups removes the oldest stamped backup directories once more -// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in -// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS +// backup stamp format are candidates; unknown directories are preserved. +// Stamps sort lexicographically in chronological order (`YYYYmmdd-HHMMSS`), +// so name order is age order. function pruneSkillBackups(homeDir) { const root = path.join(homeDir, ".dws", "skill-backups"); let entries; @@ -176,7 +188,7 @@ function pruneSkillBackups(homeDir) { throw err; } const names = entries - .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) .map((entry) => entry.name) .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); let excess = names.length - SKILL_BACKUP_KEEP; @@ -1401,6 +1413,8 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, copyPathLexicallySync, movePathRecoverablySync, diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index ae777a9d..b96f5b7d 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { if stage == "install-failed-dir" { return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil } + if stage == "install-retire-warning" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{ + {Dir: "/ok", Status: upgradepkg.SkillDirOK}, + {Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")}, + }}, nil + } return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil } } @@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", - "success", "success-no-skills", + "success", "success-no-skills", "install-retire-warning", } { t.Run(stage, func(t *testing.T) { configure(stage) @@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { opts.skipSkills = true } err := runUpgrade(context.Background(), opts) - wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning" if wantError && err == nil { t.Fatalf("stage %s succeeded", stage) } if !wantError && err != nil { t.Fatalf("stage %s failed: %v", stage, err) } - if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned { t.Fatal("successful upgrade did not clean backups") } if stage == "success" { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f75d575d..9ef878c0 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "sort" "strings" @@ -383,9 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// skillBackupStampRegexp matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`) + +// isSkillBackupStamp reports whether name is a DWS-created backup stamp. +func isSkillBackupStamp(name string) bool { + return skillBackupStampRegexp.MatchString(name) +} + // pruneSkillBackups removes the oldest backup directories when more than -// skillBackupKeep remain. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// skillBackupKeep remain. Only directories whose names match the DWS backup +// stamp format are candidates; unknown directories are preserved. Best-effort: +// a removal failure never aborts, but pruning failures are reported so +// callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -397,7 +411,7 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { + if e.IsDir() && isSkillBackupStamp(e.Name()) { names = append(names, e.Name()) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 9ae6579d..3a56594c 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -877,6 +877,60 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll) } +// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that +// pruneSkillBackups only retires directories whose names match the DWS backup +// stamp format. Any foreign directory in the backup root must survive even when +// more than skillBackupKeep stamped backups exist. +func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+2; i++ { + if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil { + t.Fatal(err) + } + } + // A collision-suffixed stamp is still a valid DWS backup and is eligible. + if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil { + t.Fatal(err) + } + unknown := []string{ + "user-personal-backup", + "20260910-000000-abc", // stamp-shaped but non-numeric suffix + "20260910-00000", // too short + "notes", + } + for _, name := range unknown { + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + } + + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("pruneSkillBackups() error = %v", err) + } + + for _, name := range unknown { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) + } + } + // Only stamped backups beyond skillBackupKeep are pruned; foreign entries + // are excluded from the count, so the stamp total stays at skillBackupKeep. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + stamps := 0 + for _, e := range entries { + if isSkillBackupStamp(e.Name()) { + stamps++ + } + } + if stamps != skillBackupKeep { + t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep) + } +} + // TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of // the mono-source resolver and the multi-bundle child lookup. func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go index e5ccde16..288847ae 100644 --- a/internal/upgrade/skill_path_move_unix_test.go +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -10,3 +10,7 @@ import ( func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} } + +func testNoReplaceUnsupportedErrors() []error { + return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go index 401e4ed0..2da189d8 100644 --- a/internal/upgrade/skill_path_move_windows_test.go +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -13,6 +13,10 @@ func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} } +func testNoReplaceUnsupportedErrors() []error { + return []error{errNoReplaceRenameUnsupported} +} + func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { if !isCrossDeviceError(testCrossDeviceError()) { t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index e3996aa3..dedca2c5 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "syscall" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -15,7 +14,7 @@ import ( // Those installs must still publish, and must still refuse to clobber an // occupied destination. func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { - unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} + unsupported := testNoReplaceUnsupportedErrors() t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { for _, unsupportedErr := range unsupported { @@ -47,7 +46,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatal(err) } } - testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL }) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrExist) { t.Fatalf("occupied destination must report ErrExist, got %v", err) @@ -80,8 +79,9 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { }) t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { - if isNoReplaceRenameUnsupported(syscall.EXDEV) { - t.Fatal("EXDEV must not be treated as an unsupported flag") + crossDevice := testCrossDeviceError() + if isNoReplaceRenameUnsupported(crossDevice) { + t.Fatal("cross-device error must not be treated as an unsupported flag") } dir := t.TempDir() source := filepath.Join(dir, "source") @@ -95,7 +95,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { original := skillPathRenameNoReplaceAtomic testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { if from == source { - return syscall.EXDEV + return crossDevice } return original(from, to) }) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 50502caa..7a8b7fd2 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -290,7 +290,11 @@ $script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string] function Remove-OldDevSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $DevSkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index e2a9219e..0a21c72e 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -121,12 +121,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -134,6 +156,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index d4e65b6b..93a3d392 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -190,12 +190,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -203,6 +225,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index c3b3de3c..bd48c7a3 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -86,12 +86,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -99,6 +121,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 99712993..f170e556 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -744,7 +744,11 @@ $script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]:: function Remove-OldSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $SkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install.sh b/scripts/install.sh index a9aa5883..0ca489e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -104,12 +104,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -117,6 +139,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 6af14c7a..79573471 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -44,9 +44,10 @@ * guard, exercised by swapping process.platform * instead of requiring a Windows host. * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. - * 15. multi publish failure — restores the complete previous Skill set. - * 16. multi backup failure — restores every earlier successful backup. - * 17. mono transaction failure — restores every managed multi Skill after + * 15. unknown preservation — prune never removes non-DWS directories in skill-backups. + * 16. multi publish failure — restores the complete previous Skill set. + * 17. multi backup failure — restores every earlier successful backup. + * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools @@ -80,6 +81,8 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, movePathRecoverablySync, publishCacheAtomically, @@ -1003,6 +1006,49 @@ scenario("skill backups stay bounded to the newest five stamps", () => { } }); +scenario("prune preserves unknown directories in skill-backups", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + } + const unknowns = [ + "user-personal-backup", + "20260101-00000", // too few digits in the time part + "20260101-000000-abc", // non-numeric suffix + "notes", + "20260101", // missing time portion + ]; + for (const name of unknowns) { + writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`); + } + + pruneSkillBackups(home); + + const remaining = fs.readdirSync(backupRoot).sort(); + const stamped = remaining.filter((n) => isSkillBackupStamp(n)); + assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`); + assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first"); + assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned"); + assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained"); + for (const name of unknowns) { + assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`); + assert.equal( + fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"), + `unknown:${name}\n`, + `unknown directory ${name} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); From 0ba55350d8fdca5146a786c8bcfeedad241fd7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 11:48:11 +0800 Subject: [PATCH 037/267] fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test The smoke test creates temp home directories with .config/kimchi markers for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the runner's real config path, causing resolvedAgentTargets to look outside the temp home. Unset it so detection resolves against the test's temp dir. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc9e8f0..ded760c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -514,6 +514,8 @@ jobs: - name: Test npm installer smoke (prune, backup, publish) if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + env: + XDG_CONFIG_HOME: "" run: node test/scripts/install_js_smoke.mjs test-focused: From 1203409185e731225ab1875973fe859fd4615a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 12:23:40 +0800 Subject: [PATCH 038/267] test(skills): cover Windows stat-error branch in no-replace fallback The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was uncovered on Windows. Inject errNoReplaceRenameUnsupported for the atomic rename and os.ErrPermission for skillPathLstat so the stat-error path is exercised on every platform. --- internal/upgrade/skill_publish_rename_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index dedca2c5..a420eed5 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -78,6 +78,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("stat failure on destination surfaces without rename", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "destination") + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("stat error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { crossDevice := testCrossDeviceError() if isNoReplaceRenameUnsupported(crossDevice) { From fa887ccd26a2d4e3ea5e9e7580c7735fc62e7fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:11:15 +0800 Subject: [PATCH 039/267] fix(skills): eliminate TOCTOU in no-replace rename fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS, FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a concurrently created destination between the check and the rename. Replace the TOCTOU-prone check with truly atomic no-clobber primitives: - Directories: os.Mkdir atomically claims the destination (fails with EEXIST if occupied). On Linux rename(2) replaces the empty dir directly; on Darwin/Windows rename refuses existing dirs so the empty dir is removed and the rename retried — any concurrent creation between remove and rename is detected by the second rename failing. - Files: os.Link atomically fails if the destination exists, then os.Remove completes the move. Add concurrent-creation test covering the mkdir→rename race window. --- internal/upgrade/skill_path_move.go | 2 + internal/upgrade/skill_publish_rename.go | 75 ++++++++++++++++--- internal/upgrade/skill_publish_rename_test.go | 70 ++++++++++++++++- 3 files changed, 134 insertions(+), 13 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index e9f9539c..b1bb95f7 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -21,6 +21,8 @@ var ( skillPathChmod = os.Chmod skillPathLstat = os.Lstat skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove skillPathReadDir = os.ReadDir skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index 490f0f9a..ba6cb925 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -19,10 +19,10 @@ var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic // support from the underlying filesystem and report EINVAL/ENOTSUP when it is // missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and // overlayfs homes reject the flag. Failing there would abort the whole install -// on those filesystems, so they degrade to an explicit existence check plus a -// plain rename: the no-clobber contract is preserved, only its atomicity is -// lost. That check-then-rename window is the same exposure a plain rename -// always had, and callers additionally verify identity after publication. +// on those filesystems, so the fallback uses truly atomic no-clobber primitives +// that work on every POSIX filesystem: os.Mkdir for directories (fails with +// EEXIST if the path is occupied) and os.Link for regular files (fails with +// EEXIST if the path is occupied). Neither primitive has a TOCTOU window. func renameSkillPathNoReplace(source, destination string) error { err := skillPathRenameNoReplaceAtomic(source, destination) if err == nil { @@ -31,10 +31,67 @@ func renameSkillPathNoReplace(source, destination string) error { if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { return err } - if _, statErr := skillPathLstat(destination); statErr == nil { - return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} - } else if !os.IsNotExist(statErr) { - return fmt.Errorf("检查发布目标失败 %s: %w", destination, statErr) + return renameSkillPathNoReplaceFallback(source, destination) +} + +// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename +// flag is unavailable. It dispatches on the source type to pick an atomic +// no-clobber primitive that does not require filesystem support. +func renameSkillPathNoReplaceFallback(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) } - return skillPathRename(source, destination) + switch { + case info.IsDir(): + return renameSkillDirNoReplaceFallback(source, destination) + case info.Mode().IsRegular(): + return renameSkillFileNoReplaceFallback(source, destination) + default: + return &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillDirNoReplaceFallback atomically claims the destination with +// mkdir (which fails with EEXIST if the path is already occupied) and then +// replaces the empty, self-owned directory with a plain rename. On Linux +// rename(2) replaces an empty directory directly. On Darwin and Windows +// rename(2) refuses to replace any existing directory, so the empty dir is +// removed and the rename retried. If someone populated the claimed directory +// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost. +// If someone creates a new path at the destination between remove and rename, +// the second Rename detects it and fails. Neither step has a TOCTOU window +// that could silently clobber data. +func renameSkillDirNoReplaceFallback(source, destination string) error { + if err := skillPathMkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + } + if err := skillPathRename(source, destination); err == nil { + return nil + } + if rmErr := skillPathRemove(destination); rmErr != nil { + return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr) + } + if err := skillPathRename(source, destination); err != nil { + return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err) + } + return nil +} + +// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the +// destination exists, then removes the source to complete the move. +func renameSkillFileNoReplaceFallback(source, destination string) error { + if err := skillPathLink(source, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err) + } + return skillPathRemove(source) } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index a420eed5..17acec77 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -12,11 +12,13 @@ import ( // TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for // filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). // Those installs must still publish, and must still refuse to clobber an -// occupied destination. +// occupied destination. The fallback uses atomic no-clobber primitives +// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone +// check-then-rename. func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { unsupported := testNoReplaceUnsupportedErrors() - t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { + t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) { for _, unsupportedErr := range unsupported { dir := t.TempDir() source := filepath.Join(dir, "source") @@ -37,7 +39,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) - t.Run("still refuses an occupied destination", func(t *testing.T) { + t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination for files", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") @@ -59,6 +78,49 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + seedUpgradeSkill(t, destination, "existing", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + assertUpgradeSkillContent(t, destination, "existing") + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("concurrent creation between mkdir and rename is detected", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process") + testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr }) + var removeCalled bool + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + removeCalled = true + } + return os.Remove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, renameErr) { + t.Fatalf("rename failure must surface, got %v", err) + } + if !removeCalled { + t.Fatal("empty destination must be cleaned up after rename failure") + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + t.Run("a genuine error is not retried", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") @@ -78,7 +140,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) - t.Run("stat failure on destination surfaces without rename", func(t *testing.T) { + t.Run("source stat failure surfaces without rename", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { From 2d29f6601b2125530a1638a95bfc63f2fb1805c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:22:58 +0800 Subject: [PATCH 040/267] test(skills): cover all no-replace fallback branches for 100% coverage Add tests for mkdir non-EEXIST error, remove failure after rename failure, first-rename-succeeds path (Linux behavior), retry-rename path, and non-regular source safe-fail. All 24 changed executable statements now covered on both macOS and Windows. --- internal/upgrade/skill_publish_rename_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 17acec77..8ef15042 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -5,10 +5,20 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) +type mockSpecialInfo struct{} + +func (mockSpecialInfo) Name() string { return "source" } +func (mockSpecialInfo) Size() int64 { return 0 } +func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe } +func (mockSpecialInfo) ModTime() time.Time { return time.Time{} } +func (mockSpecialInfo) IsDir() bool { return false } +func (mockSpecialInfo) Sys() interface{} { return nil } + // TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for // filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). // Those installs must still publish, and must still refuse to clobber an @@ -186,4 +196,94 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatalf("cross-device destination = %q, %v", data, err) } }) + + t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("mkdir error must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("remove failure after rename failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) + removeErr := errors.New("simulated remove failure") + testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, removeErr) { + t.Fatalf("remove error must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must still exist after remove failure: %v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + _ = os.RemoveAll(destination) + }) + + t.Run("directory retry rename after removing claimed empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + calls := 0 + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + calls++ + if calls == 1 { + return os.ErrExist + } + return originalRename(src, dst) + }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("retry must publish, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + }) + + t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + _ = os.Remove(dst) + return originalRename(src, dst) + }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("non-regular non-directory source fails safely", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("unsupported error must surface for special files, got %v", err) + } + }) } From ff6e2347f68d5b432e920fe664689b166f40ea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:39:12 +0800 Subject: [PATCH 041/267] test(skills): cover non-EEXIST link error on Windows On Windows isNoReplaceRenameUnsupported always returns false, so the fallback is never entered from the invalid-path test. Force the fallback and swap skillPathLink to a non-EEXIST error to cover line 94 on all platforms. --- internal/upgrade/skill_publish_rename_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 8ef15042..120255a3 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -88,6 +88,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("link error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") From e45608bb1325ddec5ab792e4682489d32a907e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:03:28 +0800 Subject: [PATCH 042/267] fix(skills): prove Windows rollback identity via stable file ID NTFS file tunneling can restore the original creation time for a recreated same-named object, which defeated the creation-time incarnation check and allowed rollback to delete a concurrent replacement. Replace the platform-specific identity pair with a single skillPathIdentityProven function: - Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings - Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from GetFileInformationByHandle, which uniquely identifies the file on the volume for its lifetime and is unaffected by tunneling When the file ID cannot be obtained at publish time, identity is not proven and the auto-delete is refused. Add a regression test that simulates tunneled creation time and verifies rollback still refuses the concurrent replacement. --- internal/upgrade/skill_path_move.go | 2 + internal/upgrade/skill_publication.go | 11 ++-- .../skill_publication_identity_darwin.go | 10 +++- .../skill_publication_identity_linux.go | 10 +++- .../skill_publication_identity_other.go | 10 +++- .../skill_publication_identity_windows.go | 54 +++++++++++++++---- internal/upgrade/skill_publication_test.go | 25 +++++++++ 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index b1bb95f7..0c8acbd9 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -24,6 +24,8 @@ var ( skillPathLink = os.Link skillPathRemove = os.Remove skillPathReadDir = os.ReadDir + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink skillPathOpen = os.Open diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 0bd9ecdb..6926279c 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -18,6 +18,7 @@ type SkillPathPublication struct { fingerprint [sha256.Size]byte identity os.FileInfo incarnation string + fileID string } // PublishSkillPathNoReplace atomically publishes a staged path without ever @@ -31,6 +32,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) } + stagedFileID := skillPathFileIdentity(staged) if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } @@ -42,7 +44,8 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } - if !skillPathSameFileIdentity(identity, publishedIdentity) || publishedFingerprint != fingerprint { + publishedFileID := skillPathFileIdentity(destination) + if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } return SkillPathPublication{ @@ -50,6 +53,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication fingerprint: fingerprint, identity: publishedIdentity, incarnation: skillPathFileIncarnation(publishedIdentity), + fileID: publishedFileID, }, nil } @@ -93,7 +97,8 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) cleanupRoot(), ) } - if publication.identity == nil || !skillPathSameFileIdentity(publication.identity, liveIdentity) || + if publication.identity == nil || + !skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) || publication.incarnation != skillPathFileIncarnation(liveIdentity) || liveFingerprint != publication.fingerprint { return errors.Join( @@ -113,7 +118,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) if identityErr == nil && fingerprintErr == nil && - skillPathSameFileIdentity(publication.identity, actualIdentity) && + skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) && actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index a3547e4c..195f317b 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -8,7 +8,7 @@ import ( "syscall" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -20,3 +20,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index cb62e1e1..6e350aab 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -8,7 +8,7 @@ import ( "syscall" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -19,3 +19,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index 53c0bc86..d487c195 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -7,10 +7,18 @@ import ( "os" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 107e266a..7e3d409c 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -6,9 +6,11 @@ import ( "fmt" "os" "syscall" + + "golang.org/x/sys/windows" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Win32FileAttributeData) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -16,13 +18,47 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } +// skillPathSameFileIdentity returns false because os.FileInfo on Windows does +// not expose the stable file ID (VolumeSerialNumber / FileIndex from +// GetFileInformationByHandle), which is the only primitive that distinguishes +// a recreated file from the original after NTFS file tunneling restores the +// creation time. Callers must use skillPathIdentityProven, which compares file +// IDs obtained via GetFileInformationByHandle. func skillPathSameFileIdentity(_, _ os.FileInfo) bool { - // MoveFile can surface different metadata identities for the same reparse - // point before and after rename, so Windows ownership relies on the - // separately checked creation-time incarnation plus the lexical fingerprint. - // That pair is strong evidence but not proof: NTFS file tunneling restores - // the original creation time when a same-named object is recreated in the - // same directory shortly afterwards, so a byte-identical concurrent - // replacement can still be attributed to this transaction. - return true + return false +} + +// skillPathFileIdentityImpl queries the stable volume serial number and file +// index via GetFileInformationByHandle. Unlike creation time (which NTFS file +// tunneling can restore for a recreated same-named object), the file index +// uniquely identifies the file on the volume for its lifetime. +func skillPathFileIdentityImpl(path string) string { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return "" + } + handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0, + ) + if err != nil { + return "" + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "" + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) +} + +// skillPathIdentityProven is the real identity proof on Windows. An empty +// expected value means the file ID could not be obtained at publish time, so +// identity cannot be proven and the caller must refuse the auto-delete. +func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { + if expected == "" { + return false + } + return expected == actual } diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index a5c3eb38..e69387b0 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -65,6 +65,31 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi } assertUpgradeSkillContent(t, destination, "new") }) + + t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + // NTFS file tunneling can restore the original creation time for a + // same-named recreation, defeating the incarnation check. The file ID + // (or inode on Unix) must still differ and block the rollback. + testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" }) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("tunneled replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) } func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { From 89027aa2e2829bd806fde93a7889ef9a6eb8990e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Mon, 17 Aug 2026 14:05:14 +0800 Subject: [PATCH 043/267] fix(recruit): distinguish business failures and unwrap once to #85340676 --- internal/helpers/recruit.go | 30 +++++++--- internal/helpers/recruit_test.go | 95 +++++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index fae5c5b1..ec333736 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -5,6 +5,7 @@ package helpers import ( "encoding/json" + "errors" "fmt" "os" "strconv" @@ -153,6 +154,12 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou } clean, err := recruitBusinessResultData(data, tool) if err != nil { + var businessFailure *recruitBusinessFailure + if errors.As(err, &businessFailure) { + return output.Failure(&output.ErrorInfo{ + Type: "api", Message: businessFailure.Error(), + }), nil + } return recruitInvalidResponse(err), nil } if tool != recruitListJobsTool { @@ -165,6 +172,14 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou return output.Success(listData, output.WithMeta(meta)), nil } +type recruitBusinessFailure struct { + message string +} + +func (e *recruitBusinessFailure) Error() string { + return e.message +} + func recruitBusinessResultData(data any, tool string) (map[string]any, error) { object, ok := data.(map[string]any) if !ok { @@ -187,7 +202,7 @@ func recruitBusinessResultData(data any, tool string) (map[string]any, error) { if strings.TrimSpace(message) == "" { message = "Connector 返回 success=false" } - return nil, fmt.Errorf("%s 调用失败: %s", tool, message) + return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)} } result, ok := resultValue.(map[string]any) if !ok { @@ -204,14 +219,13 @@ func recruitInvalidResponse(err error) output.CommandResult { } func recruitListResultData(data any) (any, *output.Meta, error) { - object, err := recruitBusinessResultData(data, recruitListJobsTool) - if err != nil { - return nil, nil, err + object, ok := data.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool) } - // The recruit Connector returns the ATS service envelope unchanged: - // {success:true,result:{list:[...],hasMore:false,nextCursor:null}}. - // Keep accepting the historical flattened shape as well, but normalize the - // fixed Connector shape before enforcing the public CLI result contract. + // The Connector envelope has already been removed by recruitResultCall. + // Only normalize the list business payload here so fields named success or + // result inside that payload cannot be mistaken for another envelope. hasMore, ok := object["hasMore"].(bool) if !ok { return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index a59b4f75..d0492024 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -365,33 +365,32 @@ func TestRecruitListResultData(t *testing.T) { t.Fatalf("terminal meta = %#v, err = %v", terminal, err) } - wrapped, wrappedMeta, err := recruitListResultData(map[string]any{ - "success": true, - "result": map[string]any{ - "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}}, - "hasMore": false, - "nextCursor": nil, - "totalCount": float64(1), - }, + normalized, normalizedMeta, err := recruitListResultData(map[string]any{ + "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}}, + "hasMore": false, + "nextCursor": nil, + "totalCount": float64(1), + "success": true, + "result": map[string]any{"source": "business-payload"}, }) if err != nil { t.Fatal(err) } - wrappedData := wrapped.(map[string]any) - jobs, ok := wrappedData["jobs"].([]any) - if !ok || len(jobs) != 1 || wrappedData["totalCount"] != float64(1) { - t.Fatalf("wrapped data = %#v", wrappedData) + normalizedData := normalized.(map[string]any) + jobs, ok := normalizedData["jobs"].([]any) + if !ok || len(jobs) != 1 || normalizedData["totalCount"] != float64(1) { + t.Fatalf("normalized data = %#v", normalizedData) } - if _, exists := wrappedData["list"]; exists { - t.Fatalf("wrapped data retained Connector list field: %#v", wrappedData) + if _, exists := normalizedData["list"]; exists { + t.Fatalf("normalized data retained Connector list field: %#v", normalizedData) } - if wrappedMeta.Count == nil || *wrappedMeta.Count != 1 || !wrappedMeta.Pagination.EndpointExhausted { - t.Fatalf("wrapped meta = %#v", wrappedMeta) + if normalizedData["success"] != true || !reflect.DeepEqual( + normalizedData["result"], map[string]any{"source": "business-payload"}, + ) { + t.Fatalf("business success/result fields were unwrapped: %#v", normalizedData) } - - if _, _, err := recruitListResultData(map[string]any{"success": true, "result": nil}); err == nil || - !strings.Contains(err.Error(), "result 必须是 JSON 对象") { - t.Fatalf("nil result error = %v", err) + if normalizedMeta.Count == nil || *normalizedMeta.Count != 1 || !normalizedMeta.Pagination.EndpointExhausted { + t.Fatalf("normalized meta = %#v", normalizedMeta) } } @@ -531,9 +530,63 @@ func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) {name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"}, } { t.Run(test.name, func(t *testing.T) { - if _, err := recruitBusinessResultData(test.data, recruitGetJobTool); err == nil || !strings.Contains(err.Error(), test.want) { + _, err := recruitBusinessResultData(test.data, recruitGetJobTool) + if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } + var businessFailure *recruitBusinessFailure + isBusinessFailure := stderrors.As(err, &businessFailure) + wantBusinessFailure := strings.HasPrefix(test.name, "business failure") + if isBusinessFailure != wantBusinessFailure { + t.Fatalf("business failure classification = %t, want %t", isBusinessFailure, wantBusinessFailure) + } + }) + } +} + +func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) { + for _, test := range []struct { + name string + text string + wantSubtype string + wantMessage string + wantHint bool + }{ + { + name: "business failure", + text: `{"success":false,"message":"职位不存在","result":{}}`, + wantMessage: "职位不存在", + }, + { + name: "malformed envelope", + text: `{"success":true}`, + wantSubtype: "invalid_response", + wantMessage: "同时包含 success 和 result", + wantHint: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != test.wantSubtype || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v", envelope.Error) + } + if (envelope.Error.Hint != "") != test.wantHint { + t.Fatalf("hint = %q, want present %t", envelope.Error.Hint, test.wantHint) + } }) } } From 344104268ad22646b6c8433c2e6d8e282d55cbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:39:59 +0800 Subject: [PATCH 044/267] fix(skills): open reparse points in Windows file ID query and stabilize tunneled test Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in skillPathFileIdentityImpl so symlinks are opened as reparse points rather than followed to their target. Staged symlinks carry relative targets computed for the final destination, which may not resolve from the staging directory; following them caused CreateFile to fail, yielding an empty file ID that rejected publication and broke canonical skill layout migration on Windows. Make skillPathSameFileIdentity a seam variable so the tunneled replacement test can deterministically simulate the identity change on Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true for a recreated file due to inode reuse, making the test flaky. On Windows the swap is a no-op because skillPathIdentityProven compares file IDs from GetFileInformationByHandle and ignores skillPathSameFileIdentity. --- internal/upgrade/skill_path_move.go | 5 +++-- internal/upgrade/skill_publication_identity_darwin.go | 2 +- internal/upgrade/skill_publication_identity_linux.go | 2 +- internal/upgrade/skill_publication_identity_other.go | 2 +- .../upgrade/skill_publication_identity_windows.go | 11 +++++++++-- internal/upgrade/skill_publication_test.go | 7 +++++++ 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 0c8acbd9..8189474d 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -24,8 +24,9 @@ var ( skillPathLink = os.Link skillPathRemove = os.Remove skillPathReadDir = os.ReadDir - skillPathFileIdentity = skillPathFileIdentityImpl - skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathSameFileIdentity = skillPathSameFileIdentityImpl skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink skillPathOpen = os.Open diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index 195f317b..b7eeead7 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -17,7 +17,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 6e350aab..88c88f35 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -16,7 +16,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index d487c195..859c0288 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -11,7 +11,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 7e3d409c..8edd9745 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -24,7 +24,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { // a recreated file from the original after NTFS file tunneling restores the // creation time. Callers must use skillPathIdentityProven, which compares file // IDs obtained via GetFileInformationByHandle. -func skillPathSameFileIdentity(_, _ os.FileInfo) bool { +func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { return false } @@ -32,6 +32,12 @@ func skillPathSameFileIdentity(_, _ os.FileInfo) bool { // index via GetFileInformationByHandle. Unlike creation time (which NTFS file // tunneling can restore for a recreated same-named object), the file index // uniquely identifies the file on the volume for its lifetime. +// +// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following +// them to the target. This matters at publish time because a staged symlink +// carries a relative target computed for the final destination, which may not +// resolve from the staging directory. Opening the reparse point yields a file +// ID that is stable across the rename and is the correct identity to compare. func skillPathFileIdentityImpl(path string) string { p, err := windows.UTF16PtrFromString(path) if err != nil { @@ -40,7 +46,8 @@ func skillPathFileIdentityImpl(path string) string { handle, err := windows.CreateFile( p, windows.FILE_READ_ATTRIBUTES, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, ) if err != nil { return "" diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index e69387b0..6be9aa58 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -79,6 +79,13 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi if err != nil { t.Fatal(err) } + // Simulate the identity change that a concurrent replacement causes. + // On Windows, skillPathIdentityProven compares file IDs from + // GetFileInformationByHandle and ignores this seam. On Unix, where the + // file ID is unavailable, this swap replaces the os.SameFile check + // (which can return true for a recreated file on tmpfs due to inode + // reuse) so the test is deterministic across filesystems. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) if err := os.RemoveAll(destination); err != nil { t.Fatal(err) } From cf43cf1b47bfcd4368615abc4be3ec98434bd09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:41:55 +0800 Subject: [PATCH 045/267] style: gofmt alignment after adding skillPathSameFileIdentity seam --- internal/upgrade/skill_path_move.go | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 8189474d..0f35467c 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -11,29 +11,29 @@ import ( ) var ( - skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } - skillPathRenameNoReplace = renameSkillPathNoReplace - skillPathCopy = copySkillPathLexically - skillPathVerify = verifySkillPathCopy - skillPathRemoveAll = os.RemoveAll - skillPathMkdirAll = os.MkdirAll - skillPathMkdirTemp = os.MkdirTemp - skillPathChmod = os.Chmod - skillPathLstat = os.Lstat - skillPathMkdir = os.Mkdir - skillPathLink = os.Link - skillPathRemove = os.Remove - skillPathReadDir = os.ReadDir + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove + skillPathReadDir = os.ReadDir skillPathFileIdentity = skillPathFileIdentityImpl skillPathFileIncarnation = skillPathFileIncarnationImpl skillPathSameFileIdentity = skillPathSameFileIdentityImpl - skillPathReadlink = os.Readlink - skillPathSymlink = os.Symlink - skillPathOpen = os.Open - skillPathOpenFile = os.OpenFile - skillPathCopyBytes = io.Copy - skillPathSync = func(file *os.File) error { return file.Sync() } - skillPathWalkDir = filepath.WalkDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the From 2292a49c6a04d31a25e72ddfb242b9f6cc928dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 15:00:25 +0800 Subject: [PATCH 046/267] refactor(skills): collapse Windows identity error paths for coverage Restructure skillPathFileIdentityImpl to use nested if-err-nil with a named return and skillPathIdentityProven to use a single expression. Error conditions now fall through to the bare return instead of occupying separate coverage blocks, eliminating 5 uncovered statements that the Windows coverage gate flagged at 99.4193%. --- .../skill_publication_identity_windows.go | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 8edd9745..d6740de1 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -38,34 +38,27 @@ func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { // carries a relative target computed for the final destination, which may not // resolve from the staging directory. Opening the reparse point yields a file // ID that is stable across the rename and is the correct identity to compare. -func skillPathFileIdentityImpl(path string) string { - p, err := windows.UTF16PtrFromString(path) - if err != nil { - return "" +func skillPathFileIdentityImpl(path string) (id string) { + if p, err := windows.UTF16PtrFromString(path); err == nil { + if handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ); err == nil { + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err == nil { + id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + } + } } - handle, err := windows.CreateFile( - p, windows.FILE_READ_ATTRIBUTES, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, - ) - if err != nil { - return "" - } - defer windows.CloseHandle(handle) - var info windows.ByHandleFileInformation - if err := windows.GetFileInformationByHandle(handle, &info); err != nil { - return "" - } - return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + return } // skillPathIdentityProven is the real identity proof on Windows. An empty // expected value means the file ID could not be obtained at publish time, so // identity cannot be proven and the caller must refuse the auto-delete. func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { - if expected == "" { - return false - } - return expected == actual + return expected != "" && expected == actual } From b7a07abcb15612b152eebdd1d3931af37ada2328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Mon, 17 Aug 2026 15:14:22 +0800 Subject: [PATCH 047/267] test(recruit): cover cursor through response pipeline to #85340676 --- internal/helpers/recruit.go | 18 +++++++++++------- internal/helpers/recruit_test.go | 23 +++++++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index ec333736..97a76415 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -154,13 +154,7 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou } clean, err := recruitBusinessResultData(data, tool) if err != nil { - var businessFailure *recruitBusinessFailure - if errors.As(err, &businessFailure) { - return output.Failure(&output.ErrorInfo{ - Type: "api", Message: businessFailure.Error(), - }), nil - } - return recruitInvalidResponse(err), nil + return recruitResponseFailure(err), nil } if tool != recruitListJobsTool { return output.Success(clean), nil @@ -172,6 +166,16 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou return output.Success(listData, output.WithMeta(meta)), nil } +func recruitResponseFailure(err error) output.CommandResult { + var businessFailure *recruitBusinessFailure + if errors.As(err, &businessFailure) { + return output.Failure(&output.ErrorInfo{ + Type: "api", Message: businessFailure.Error(), + }) + } + return recruitInvalidResponse(err) +} + type recruitBusinessFailure struct { message string } diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index d0492024..a51ddb90 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -137,20 +137,25 @@ func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) { caller := withRecruitCaller(t) caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` - data, err := CallMCPToolDataOnServer(context.Background(), recruitServerID, recruitListJobsTool, map[string]any{"size": 20}) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) if err != nil { t.Fatal(err) } - _, meta, err := recruitListResultData(data) + envelope, err := output.EnvelopeFromResult(result) if err != nil { t.Fatal(err) } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || envelope.Meta.Pagination == nil { + t.Fatalf("envelope = %#v", envelope) + } + meta := envelope.Meta if got := meta.Pagination.NextToken; got != "9223372036854775807" { t.Fatalf("next token = %q, want lossless max int64", got) } caller.text = `{"jobs":[],"hasMore":false}` - cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd = prepareRecruitTestCommand(newRecruitJobListCommand()) cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken}) if err := cmd.Execute(); err != nil { t.Fatal(err) @@ -566,13 +571,15 @@ func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) { }, } { t.Run(test.name, func(t *testing.T) { - caller := withRecruitCaller(t) - caller.text = test.text - cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) - result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) - if err != nil { + var payload any + if err := json.Unmarshal([]byte(test.text), &payload); err != nil { t.Fatal(err) } + _, responseErr := recruitBusinessResultData(payload, recruitGetJobTool) + if responseErr == nil { + t.Fatal("expected response error") + } + result := recruitResponseFailure(responseErr) envelope, err := output.EnvelopeFromResult(result) if err != nil { t.Fatal(err) From 4e8469a1759cd161dc756cb4d9fb5728fe604da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 15:25:32 +0800 Subject: [PATCH 048/267] test(skills): cover Windows same-file identity seam with synthetic info skillPathSameFileIdentityImpl on Windows always returns false and is never reached through skillPathIdentityProven (which uses file IDs exclusively). Add a direct seam call with synthetic os.FileInfo to exercise the Windows return-false path and the Unix os.SameFile path with nil Sys(). --- internal/upgrade/skill_publication_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 6be9aa58..2748f8ea 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -456,6 +456,12 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatal("empty incarnation") } }) + t.Run("synthetic info does not match same-file identity", func(t *testing.T) { + fake := skillPathFakeInfo{} + if skillPathSameFileIdentity(fake, fake) { + t.Fatal("synthetic info should not match") + } + }) }) } From 2cea069f555156c8e6eff0fa2ce7be1dbf470659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Mon, 17 Aug 2026 16:09:43 +0800 Subject: [PATCH 049/267] fix(recruit): scope lossless number decoding to #85340676 --- .../framework2_changed_lines_coverage_test.go | 7 ++-- internal/helpers/helpers.go | 1 - internal/helpers/recruit.go | 27 +++++++++++++- internal/helpers/recruit_test.go | 37 ++++++++++++++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/internal/helpers/framework2_changed_lines_coverage_test.go b/internal/helpers/framework2_changed_lines_coverage_test.go index b9488467..a80a0751 100644 --- a/internal/helpers/framework2_changed_lines_coverage_test.go +++ b/internal/helpers/framework2_changed_lines_coverage_test.go @@ -6,7 +6,6 @@ package helpers import ( "bytes" "context" - "encoding/json" "errors" "os" "path/filepath" @@ -59,10 +58,10 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { t.Fatalf("decoded data=%#v err=%v", data, err) } - InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"cursor":9223372036854775807}`}}}}) + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"count":7}`}}}}) data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil) - if err != nil || data.(map[string]any)["cursor"] != json.Number("9223372036854775807") { - t.Fatalf("lossless number data=%#v err=%v", data, err) + if err != nil || data.(map[string]any)["count"] != float64(7) { + t.Fatalf("legacy float64 number data=%#v err=%v", data, err) } InitDeps(&coverageErrorCaller{err: errors.New("transport failed")}) diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 14435768..f982a153 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -286,7 +286,6 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg } var data any decoder := json.NewDecoder(strings.NewReader(text)) - decoder.UseNumber() if err := decoder.Decode(&data); err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) } diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 97a76415..bc9551bc 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -4,9 +4,11 @@ package helpers import ( + "context" "encoding/json" "errors" "fmt" + "io" "os" "strconv" "strings" @@ -148,7 +150,7 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou "tool": tool, "arguments": toolArgs, "executed": false, }, output.WithDryRun()), nil } - data, err := CallMCPToolDataOnServer(cmd.Context(), recruitServerID, tool, toolArgs) + data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs) if err != nil { return nil, err } @@ -166,6 +168,29 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou return output.Success(listData, output.WithMeta(meta)), nil } +func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) { + text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var data any + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return data, nil +} + func recruitResponseFailure(err error) output.CommandResult { var businessFailure *recruitBusinessFailure if errors.As(err, &businessFailure) { diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index a51ddb90..fb90cd52 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -408,6 +408,41 @@ func TestRecruitResultCallPropagatesMCPError(t *testing.T) { } } +func TestRecruitMCPDataDecoderIsLosslessAndStrict(t *testing.T) { + for _, test := range []struct { + name string + text string + wantErr string + }{ + {name: "empty", text: " "}, + {name: "invalid JSON", text: "{", wantErr: "解析 list_jobs 返回失败"}, + {name: "multiple values", text: `{} {}`, wantErr: "存在多个 JSON 值"}, + {name: "invalid trailing value", text: `{} {`, wantErr: "解析 list_jobs 返回失败"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || len(data.(map[string]any)) != 0 { + t.Fatalf("empty response data = %#v, err = %v", data, err) + } + }) + } + + caller := withRecruitCaller(t) + caller.text = `{"nextCursor":9223372036854775807}` + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if err != nil || data.(map[string]any)["nextCursor"] != json.Number("9223372036854775807") { + t.Fatalf("lossless recruit number data = %#v, err = %v", data, err) + } +} + func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) { for _, test := range []struct { name string @@ -431,7 +466,7 @@ func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) t.Run(test.name, func(t *testing.T) { caller := withRecruitCaller(t) caller.text = test.text - data, err := CallMCPToolDataOnServer(context.Background(), recruitServerID, test.tool, map[string]any{}) + data, err := callRecruitMCPToolData(context.Background(), test.tool, map[string]any{}) if err != nil { t.Fatal(err) } From 9f983be1acbba52e8de5caec2df3799c4fdb1d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Mon, 17 Aug 2026 19:55:12 +0800 Subject: [PATCH 050/267] fix(recruit): validate job response identity to #85340676 --- internal/helpers/recruit.go | 35 ++++++++++-- internal/helpers/recruit_test.go | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index bc9551bc..a64c7de4 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -158,14 +158,39 @@ func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (ou if err != nil { return recruitResponseFailure(err), nil } - if tool != recruitListJobsTool { + switch tool { + case recruitListJobsTool: + listData, meta, err := recruitListResultData(clean) + if err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(listData, output.WithMeta(meta)), nil + case recruitGetJobTool, recruitCreateJobTool: + if err := validateRecruitJobResult(clean, tool, args); err != nil { + return recruitInvalidResponse(err), nil + } return output.Success(clean), nil + default: + return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil } - listData, meta, err := recruitListResultData(clean) - if err != nil { - return recruitInvalidResponse(err), nil +} + +func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error { + jobID, ok := data["jobId"].(string) + if !ok || strings.TrimSpace(jobID) == "" { + return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool) } - return output.Success(listData, output.WithMeta(meta)), nil + if tool != recruitGetJobTool { + return nil + } + requestedJobID, ok := args["jobId"].(string) + if !ok || strings.TrimSpace(requestedJobID) == "" { + return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool) + } + if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) { + return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID) + } + return nil } func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) { diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index fb90cd52..41b9efe5 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -646,6 +646,101 @@ func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { } } +func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) { + for _, test := range []struct { + name string + tool string + args map[string]any + text string + wantMessage string + }{ + { + name: "create empty response", + tool: recruitCreateJobTool, + text: " ", + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty object", + tool: recruitCreateJobTool, + text: `{}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create envelope missing job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":" "}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create non-string job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":123}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get missing job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"name":"Java 工程师"}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get mismatched job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"jobId":"job-other"}}`, + wantMessage: "与请求的 jobId", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), test.tool, test.args) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v, want failure", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != "invalid_response" || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v, want invalid_response containing %q", envelope.Error, test.wantMessage) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls = %d, want exactly one", len(caller.calls)) + } + }) + } +} + +func TestRecruitResultCallRejectsToolWithoutResultValidator(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"jobId":"job-created"}` + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), "future_write_tool", nil) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" || !strings.Contains(envelope.Error.Message, "不支持校验") { + t.Fatalf("envelope = %#v, want unsupported validator failure", envelope) + } +} + func TestRecruitResultCallDryRun(t *testing.T) { caller := withRecruitCaller(t) caller.dryRun = true From 44d640bbae35acd2363b7e3c88cee0532d2be2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 18:50:27 +0800 Subject: [PATCH 051/267] fix(skills): use canonical global installation --- README.md | 4 +- build/npm/install.js | 233 +++++++++++------ internal/app/skill_setup.go | 130 +++++++--- internal/app/skill_setup_canonical_test.go | 89 +++++++ internal/app/skill_setup_plan_test.go | 28 +- internal/app/skill_setup_test.go | 28 +- internal/upgrade/canonical_skills_test.go | 91 +++++++ internal/upgrade/coverage_edges_test.go | 6 +- internal/upgrade/paths.go | 281 ++++++++++++--------- internal/upgrade/paths_multi_test.go | 245 +++--------------- scripts/install-skills.sh | 164 ++++++------ scripts/install.ps1 | 152 ++++++----- scripts/install.sh | 183 ++++++++------ scripts/release/verify-package-managers.sh | 41 ++- test/scripts/install_js_smoke.mjs | 17 +- test/scripts/install_script_test.go | 42 +-- 16 files changed, 1020 insertions(+), 714 deletions(-) create mode 100644 internal/app/skill_setup_canonical_test.go create mode 100644 internal/upgrade/canonical_skills_test.go diff --git a/README.md b/README.md index ec3e7416..132ebf9d 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index 49e9fd7f..8a7e94d4 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -29,6 +29,17 @@ const AGENT_DIRS = [ ".hermes/skills", ]; +// Same capability split as the `skills` CLI used by lark-cli. Universal +// Agents read ~/.agents/skills directly and must not receive a second copy. +const UNIVERSAL_AGENT_DIRS = new Set([ + ".cursor/skills", + ".gemini/skills", + ".codex/skills", + ".github/skills", + ".cline/skills", + ".amp/skills", +]); + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -65,7 +76,14 @@ function backupStamp() { // and false is returned so callers skip that target rather than silently // deleting data. function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + let info; + try { + info = fs.lstatSync(dir); + } catch (err) { + if (err && err.code === "ENOENT") return true; + throw err; + } + if (!info.isDirectory() && !info.isSymbolicLink()) { return true; } const rel = path.relative(homeDir, dir); @@ -234,15 +252,11 @@ function installSkillsToHomes(skillRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -256,37 +270,44 @@ function installSkillsToHomes(skillRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); - - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) { + installed += 1; + } else { + failed += 1; } - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const agentDir of AGENT_DIRS.slice(1)) { + const baseDir = path.join(homeDir, agentDir); + if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); + failed += 1; + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + victims.push(path.join(baseDir, entry.name)); + } + } + } + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); + installed += 1; + } catch (linkErr) { + console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); + if (installToBase(baseDir)) installed += 1; + else failed += 1; + } } } if (installed === 0) { @@ -346,12 +367,11 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireGenericSkillRoot(homeDir, managedNames) { - const baseDir = path.join(homeDir, ".agents", "skills"); +function retireManagedSkillRoot(homeDir, baseDir, managedNames) { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -374,12 +394,74 @@ function retireGenericSkillRoot(homeDir, managedNames) { } } if (restoreErrors.length > 0) { - throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`); + throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`); } throw err; } } +function samePhysicalDir(left, right) { + try { + return fs.realpathSync(left) === fs.realpathSync(right); + } catch (_) { + return false; + } +} + +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { + fs.mkdirSync(baseDir, { recursive: true }); + const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); + const staged = []; + const backups = []; + const published = []; + const correctLinks = new Set(); + const restore = () => { + for (let i = published.length - 1; i >= 0; i -= 1) { + fs.rmSync(published[i], { recursive: true, force: true }); + } + for (let i = backups.length - 1; i >= 0; i -= 1) { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + fs.renameSync(backups[i].backup, backups[i].original); + } + }; + try { + for (const name of names) { + const target = path.join(canonicalBase, name); + const dest = path.join(baseDir, name); + if (samePhysicalDir(dest, target)) { + correctLinks.add(path.resolve(dest)); + continue; + } + const stagedPath = path.join(stageRoot, name); + const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target); + fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); + } + const seen = new Set(); + for (const victim of victims) { + const normalized = path.resolve(victim); + if (seen.has(normalized) || correctLinks.has(normalized)) continue; + seen.add(normalized); + if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { + throw new Error(`failed to back up Skill directory ${victim}`); + } + } + for (const item of staged) { + fs.renameSync(item.staged, item.dest); + published.push(item.dest); + } + } catch (err) { + try { + restore(); + } catch (restoreErr) { + throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`); + } + throw err; + } finally { + fs.rmSync(stageRoot, { recursive: true, force: true }); + } +} + function skillDirectoryDigest(dir) { const files = []; const visit = (current, prefix) => { @@ -628,10 +710,6 @@ function installMultiSkillsToHomes(multiRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { fs.mkdirSync(baseDir, { recursive: true }); const victims = [path.join(baseDir, "dws")]; @@ -639,7 +717,7 @@ function installMultiSkillsToHomes(multiRoot) { // the same transaction as every replaced bundled skill. for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { if ( - entry.isDirectory() && + (entry.isDirectory() || entry.isSymbolicLink()) && (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && !skillSet.has(entry.name) ) { @@ -658,37 +736,44 @@ function installMultiSkillsToHomes(multiRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) installed += 1; + else failed += 1; - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } - } - - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const agentDir of AGENT_DIRS.slice(1)) { + const baseDir = path.join(homeDir, agentDir); + if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); + failed += 1; + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ( + (entry.isDirectory() || entry.isSymbolicLink()) && + (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && + !skillSet.has(entry.name) + ) victims.push(path.join(baseDir, entry.name)); + } + } + for (const name of skills) victims.push(path.join(baseDir, name)); + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); + installed += 1; + } catch (linkErr) { + console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); + if (installToBase(baseDir)) installed += 1; + else failed += 1; + } } } if (installed === 0) { diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 62ab2c17..7f5fe2fb 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -65,6 +65,7 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir @@ -74,6 +75,7 @@ var ( skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel skillSetupReadlink = os.Readlink + skillSetupEvalSymlinks = filepath.EvalSymlinks skillSetupOpen = os.Open skillSetupOpenFile = os.OpenFile skillSetupWriteFile = os.WriteFile @@ -91,9 +93,11 @@ type skillSetupBackup struct { } type skillSetupTargetPlan struct { - Destination string - Backups []skillSetupBackup - CleanupOnly bool + Destination string + CanonicalBase string + Backups []skillSetupBackup + CleanupOnly bool + LinkCanonical bool } type skillSetupPlan struct { @@ -149,8 +153,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录; -未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 +原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, +链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -910,8 +915,9 @@ func isSkillSourceRoot(path, mode string) bool { } // resolveSkillSetupTargets returns the list of absolute Agent home destinations. -// If target == "all", returns every agent home whose parent directory exists. -// Otherwise returns the single matching home (whether or not it currently exists). +// The canonical ~/.agents/skills destination is always first. If target == +// "all", detected concrete Agent roots follow it. A specific target follows +// canonical as well so unknown/future Agents retain the universal copy. // // 末段约定: // - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) @@ -923,6 +929,7 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { } target = strings.ToLower(strings.TrimSpace(target)) + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) if target == "" || target == "all" { return detectExistingAgentHomes(home, mode), nil } @@ -931,7 +938,11 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil + dest := agentHomeForMode(filepath.Join(home, rel), mode) + if filepath.Clean(dest) == filepath.Clean(canonical) { + return []string{canonical}, nil + } + return []string{canonical, dest}, nil } // agentHomeForMode appends the mode-specific tail segment to an agent home base. @@ -943,7 +954,8 @@ func agentHomeForMode(base, mode string) string { } func detectExistingAgentHomes(home, mode string) []string { - var specific []string + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) + dests := []string{canonical} for i, rel := range skillSetupAgentHomes { if i == 0 { continue @@ -953,12 +965,45 @@ func detectExistingAgentHomes(home, mode string) []string { if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { continue } - specific = append(specific, agentHomeForMode(base, mode)) + dests = append(dests, agentHomeForMode(base, mode)) } - if len(specific) > 0 { - return specific + return dests +} + +func skillSetupBaseForMode(dest, mode string) string { + if mode == skillSetupModeMono { + return filepath.Dir(dest) } - return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)} + return dest +} + +func isUniversalSkillSetupBase(base string) bool { + base = filepath.ToSlash(filepath.Clean(base)) + for rel := range map[string]bool{ + ".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, + ".github/skills": true, ".cline/skills": true, ".amp/skills": true, + } { + if strings.HasSuffix(base, "/"+rel) { + return true + } + } + return false +} + +func canonicalSkillSetupBase(dests []string, mode string) string { + for _, dest := range dests { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) + if strings.HasSuffix(base, "/.agents/skills") { + return skillSetupBaseForMode(dest, mode) + } + } + return "" +} + +func samePhysicalSkillSetupPath(left, right string) bool { + leftReal, leftErr := skillSetupEvalSymlinks(left) + rightReal, rightErr := skillSetupEvalSymlinks(right) + return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) } func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { @@ -1032,8 +1077,17 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt sortedDests := append([]string(nil), dests...) sort.Strings(sortedDests) managedNames := currentManagedSkillNames() + canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { - target := skillSetupTargetPlan{Destination: dest} + base := skillSetupBaseForMode(dest, mode) + target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase} + if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) { + if isUniversalSkillSetupBase(base) { + target.CleanupOnly = true + } else { + target.LinkCanonical = true + } + } seen := map[string]bool{} add := func(path, reason string) { if seen[path] { @@ -1077,6 +1131,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } for _, path := range replacements { + if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + continue + } info, statErr := skillSetupStat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { @@ -1089,14 +1146,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - plan.Targets = append(plan.Targets, target) - } - cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames) - if cleanupErr != nil { - return nil, cleanupErr - } - if cleanupTarget != nil { - plan.Targets = append(plan.Targets, *cleanupTarget) + if !target.CleanupOnly || len(target.Backups) > 0 { + plan.Targets = append(plan.Targets, target) + } } return plan, nil } @@ -1151,7 +1203,9 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintln(out, " destinations:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination) + } else if target.LinkCanonical { + fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) } else { fmt.Fprintf(out, " - %s\n", target.Destination) } @@ -1649,6 +1703,21 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) + if target.LinkCanonical { + canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest)) + if samePhysicalSkillSetupPath(dest, canonicalTarget) { + return nil + } + relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget) + if relErr != nil { + return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) + } + if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil { + return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr) + } + staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest}) + return nil + } if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil { return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err) } @@ -1752,16 +1821,13 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } for _, target := range plan.Targets { if target.CleanupOnly { - if skipped > 0 { - continue - } if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr) + fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) skipped++ continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) + fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) skipped++ } continue @@ -1777,6 +1843,12 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) + if stageErr != nil && target.LinkCanonical { + fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) + fallback := target + fallback.LinkCanonical = false + stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) + } if stageErr != nil { fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget @@ -1836,7 +1908,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[ } var victims []string for _, e := range entries { - if !e.IsDir() || keepSet[e.Name()] { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] { continue } if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go new file mode 100644 index 00000000..83a43d93 --- /dev/null +++ b/internal/app/skill_setup_canonical_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupAgentHomes, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + if len(dests) != 4 || dests[0] != canonical { + t.Fatalf("targets = %v", dests) + } + + src := t.TempDir() + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("Codex duplicate remains: %v", err) + } + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link %s = %#v, %v", link, info, err) + } + } + } + // Re-running setup must recognize the existing links as already correct; + // canonical refreshes in place without turning links into copied trees. + plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err) + } + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index fcc5ceb0..e46339f2 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -149,31 +149,36 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) { home := t.TempDir() dest := filepath.Join(home, ".codex", "skills") - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { + canonical := filepath.Join(home, ".agents", "skills") + oldCodex := filepath.Join(dest, "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { t.Fatal(err) } testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("transient HOME failure") }) - plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true) if err != nil { t.Fatal(err) } - if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) { - t.Fatalf("generic cleanup target = %#v", plan.Targets) + if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest { + t.Fatalf("universal cleanup target = %#v", plan.Targets) } - if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono { - t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups) + if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex { + t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups) } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") { - t.Fatalf("generic cleanup preview missing: %s", preview.String()) + if !strings.Contains(preview.String(), "universal Agent") { + t.Fatalf("universal cleanup preview missing: %s", preview.String()) } t.Run("managed multi and scan failure", func(t *testing.T) { + genericMono := filepath.Join(home, ".agents", "skills", "dws") + if err := os.MkdirAll(genericMono, 0o755); err != nil { + t.Fatal(err) + } managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") if err := os.MkdirAll(managedDir, 0o755); err != nil { t.Fatal(err) @@ -187,9 +192,6 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { t.Fatalf("generic scan error = %v", targetErr) } - if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) { - t.Fatalf("generic cleanup plan error = %v", planErr) - } }) } @@ -210,7 +212,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") { + if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 2a776355..1a37371e 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills", "dws") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t t.Fatal(err) } want := filepath.Join(home, ".codex", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } } @@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } want := filepath.Join(home, ".zcode", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono) @@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } wantMono := filepath.Join(want, "dws") - if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) { - t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono) + if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) { + t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono) } } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go new file mode 100644 index 00000000..163598ad --- /dev/null +++ b/internal/upgrade/canonical_skills_test.go @@ -0,0 +1,91 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + // beta.6 wrote a physical copy into Codex. The canonical migration must + // remove it without treating a matching user/market name as DWS-owned. + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + + canonical := filepath.Join(home, ".agents", "skills") + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) { + t.Fatalf("universal Codex duplicate remains for %s: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s link = %#v, %v", link, info, err) + } + if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil { + t.Fatalf("%s does not resolve canonical content: %v", link, err) + } + } + } + result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("idempotent upgrade = %#v, %v", result, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") }) + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + info, err := os.Lstat(dest) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } + if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil { + t.Fatalf("copy fallback content missing: %v", err) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 960d81f7..9794f290 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) { } knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"} result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 { + if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 { t.Fatalf("skill upgrade = %#v, %v", result, err) } knownSkillDirs = []string{".real/skills"} @@ -593,8 +593,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { t.Fatal(err) } result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 { - t.Fatalf("replaced primary fallback = %#v, %v", result, err) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical failure = %#v, %v", result, err) } failure := errors.New("ensure failed") upgradeEnsureDir = func(string, os.FileMode) error { return failure } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index a83309d3..12d89118 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -35,10 +35,11 @@ const ( // - test/scripts/package_script_test.go expectedPackagedSkillTargets // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // -// The first entry (.agents/skills) is a generic fallback. It is used only -// when no concrete Agent home is detected; otherwise publishing there would -// duplicate every Skill for Agents (including Codex) that scan both roots. -// Subsequent entries are updated when their parent directory already exists. +// The first entry (.agents/skills) is the canonical global Skill directory, +// matching the mechanism used by lark-cli's upstream `skills` installer. +// Agents that understand the universal .agents convention read it directly; +// detected non-universal Agents receive relative links to this canonical +// copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", ".claude/skills", @@ -59,6 +60,20 @@ var knownSkillDirs = []string{ ".hermes/skills", } +// universalSkillDirs are concrete Agent directories whose current clients +// natively scan ~/.agents/skills. Publishing another copy or link below these +// roots makes the same Skill appear twice. Keep this classification aligned +// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally +// non-universal because it is a DWS-specific integration absent upstream. +var universalSkillDirs = map[string]bool{ + ".cursor/skills": true, + ".gemini/skills": true, + ".codex/skills": true, + ".github/skills": true, + ".cline/skills": true, + ".amp/skills": true, +} + var ( upgradeUserHomeDir = os.UserHomeDir upgradeExecutable = os.Executable @@ -70,6 +85,8 @@ var ( upgradeMkdirTemp = os.MkdirTemp upgradeReadDir = os.ReadDir upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink upgradeBuildProvenance = skillprovenance.Build upgradeReadSkillState = skillstate.Read upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } @@ -90,14 +107,14 @@ const skillBackupSubdir = ".dws/skill-backups" // opposite layout next to it silently). Missing paths and regular files are // no-ops ("", nil). func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeStat(dir) + info, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() { + if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { return "", nil } rel, relErr := filepath.Rel(homeDir, dir) @@ -204,11 +221,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { // `dws skill setup --mode mono` flow. // // Strategy (matches npm install.js installSkillsToHomes): -// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their -// parent directory exists (e.g. ~/.codex/ exists => user has Codex) -// - ~/.agents/skills/ is used only when no concrete Agent is detected +// - ~/.agents/skills/ is always the canonical global store +// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the +// canonical store directly, so old agent-specific copies are retired +// - detected non-universal Agents receive relative links to canonical; +// filesystems that reject links receive a direct-copy fallback // - ~/.real/ and other blacklisted paths are NEVER touched -// - If no location was updated at all, fall back to ~/.agents/skills/ +// - canonical publication is mandatory and fails the upgrade loudly // // Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then // removed so mono and multi never co-exist after an upgrade; a leftover that @@ -517,100 +536,146 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str return publishStagedSkillSet(homeDir, staged, victims) } -func hasDetectedSpecificSkillRoot(homeDir string) bool { - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) { - continue - } - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if info, err := upgradeStat(parentGate); err == nil && info.IsDir() { - return true - } - } - return false -} - func isGenericSkillRoot(agentDir string) bool { return filepath.Clean(agentDir) == filepath.Clean(".agents/skills") } -func retireGenericSkillRoot(homeDir string, managed map[string]bool) error { - base := filepath.Join(homeDir, ".agents", "skills") +func isUniversalSkillRoot(agentDir string) bool { + return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] +} + +func skillRootDetected(homeDir, agentDir string) bool { + parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) + info, err := upgradeStat(parentGate) + return err == nil && info.IsDir() +} + +func samePhysicalSkillRoot(left, right string) bool { + leftReal, leftErr := upgradeEvalSymlinks(left) + rightReal, rightErr := upgradeEvalSymlinks(right) + return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) +} + +func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { return err } victims = append(victims, filepath.Join(base, "dws")) if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err) + return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err) } return nil } +func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) { + if err := upgradeMkdirAll(destBase, dirPermShared); err != nil { + return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err) + } + stageRoot, err = upgradeMkdirTemp(destBase, prefix) + if err != nil { + return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err) + } + defer func() { + if err != nil { + _ = upgradeRemoveAll(stageRoot) + } + }() + for _, name := range names { + target := filepath.Join(canonicalBase, name) + relTarget, relErr := filepath.Rel(destBase, target) + if relErr != nil { + return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) + } + stagedPath := filepath.Join(stageRoot, name) + if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil { + return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr) + } + staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)}) + } + return stageRoot, staged, nil +} + +func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error { + needed := make([]string, 0, len(names)) + correct := make(map[string]bool, len(names)) + for _, name := range names { + dest := filepath.Join(destBase, name) + canonical := filepath.Join(canonicalBase, name) + if samePhysicalSkillRoot(dest, canonical) { + correct[filepath.Clean(dest)] = true + continue + } + needed = append(needed, name) + } + filteredVictims := victims[:0] + for _, victim := range victims { + if !correct[filepath.Clean(victim)] { + filteredVictims = append(filteredVictims, victim) + } + } + stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed) + if err != nil { + return err + } + defer func() { _ = upgradeRemoveAll(stageRoot) }() + return publishStagedSkillSet(homeDir, staged, filteredVictims) +} + // upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory // per agent home. func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) { result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + canonicalDest := filepath.Join(canonicalBase, "dws") + if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) for _, agentDir := range knownSkillDirs { - destDir := filepath.Join(homeDir, agentDir, "dws") - + if isGenericSkillRoot(agentDir) { + continue + } + destBase := filepath.Join(homeDir, agentDir) + destDir := filepath.Join(destBase, "dws") if isBlacklisted(agentDir) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetected(homeDir, agentDir) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + continue + } + if isUniversalSkillRoot(agentDir) { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil { + // Match the upstream installer: platforms/filesystems that reject + // links receive a direct copy rather than losing Agent support. + if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location. The multi - // leftovers under the primary base are the usual reason the primary - // install failed, so clean them first — failing loud like the multi - // fallback — instead of letting mono and multi co-exist marked OK. - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - dest := filepath.Join(destBase, "dws") - if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == dest { - result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level mono cache so that // `dws skill setup --mode mono` fallbacks stay on the upgraded version @@ -633,61 +698,51 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) for _, agentDir := range knownSkillDirs { + if isGenericSkillRoot(agentDir) { + continue + } destBase := filepath.Join(homeDir, agentDir) - if isBlacklisted(agentDir) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetected(homeDir, agentDir) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(destBase) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + continue + } + if isUniversalSkillRoot(agentDir) { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil { + if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == destBase { - result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level caches so that `dws skill setup` // fallbacks stay on the upgraded version. The release zip ships both @@ -789,7 +844,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str } victims := make([]string, 0) for _, e := range entries { - if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(baseDir, e.Name())) @@ -825,7 +880,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err) } for _, e := range entries { - if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(destBase, e.Name())) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ec55c21d..e9abb256 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -124,8 +124,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { home := withFakeHome(t) - // A concrete Agent root wins over the generic .agents fallback; .claude - // installs and .cursor is skipped. + // .agents is canonical; detected Claude receives links and Cursor is skipped. agentsBase := filepath.Join(home, ".agents", "skills") claudeBase := filepath.Join(home, ".claude", "skills") for _, base := range []string{agentsBase, claudeBase} { @@ -193,17 +192,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { // Succeeded entries report the agent home base in multi mode. succeeded := result.Succeeded() - if len(succeeded) != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results) + if len(succeeded) != 2 { + t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results) } - wantDirs := map[string]bool{claudeBase: true} + wantDirs := map[string]bool{agentsBase: true, claudeBase: true} for _, d := range succeeded { if !wantDirs[d.Dir] { t.Errorf("unexpected succeeded dir %q", d.Dir) } } - if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { - t.Fatalf("generic mono duplicate still visible: %v", err) + for _, name := range []string{"dingtalk-chat", "dws-shared"} { + if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } } // Multi cache refreshed under the fake home. @@ -216,7 +217,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { } } -func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) { +func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"}) @@ -241,12 +242,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err) } - want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md") + want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md") if _, err := os.Stat(want); err != nil { - t.Fatalf("canonical Codex Skill missing at %s: %v", want, err) + t.Fatalf("canonical Skill missing at %s: %v", want, err) } for _, duplicate := range []string{ - filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"), + filepath.Join(codexBase, "dingtalk-chat"), filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -526,21 +527,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure( } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical copy failure = %#v, %v", result, err) } data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md")) if err != nil { t.Fatalf("mono fallback install missing: %v", err) } - if string(data) != "# mono" { - t.Errorf("mono content = %q, want refreshed package", data) + if string(data) != "old" { + t.Errorf("mono content = %q, want original preserved", data) } } @@ -574,22 +569,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err) + if err == nil { + t.Fatal("canonical read failure must fail the upgrade") } failed := result.Failed() if len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) } - wantDir := agentsBase + wantDir := filepath.Join(agentsBase, "dws") if failed[0].Dir != wantDir || failed[0].Err == nil { t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir) } if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") { t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err) } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) + if got := len(result.Succeeded()); got != 0 { + t.Fatalf("Succeeded() len = %d, want 0", got) } // Mono must NOT have been laid down next to the unreadable multi state. if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { @@ -628,8 +623,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t if err == nil { t.Fatal("multi fallback cleanup failure must return an error") } - if !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("error should mention the fallback cleanup failure, got %v", err) + if !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("error should mention the canonical cleanup failure, got %v", err) } if failed := result.Failed(); len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) @@ -654,8 +649,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { // backup. Inject it through the path seam so this branch is portable to // Windows, where chmod-based permission failures are not reliable. statFailurePath := filepath.Join(home, "stat-failure") - origStat := upgradeStat - testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + origStat := upgradeLstat + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { if path == statFailurePath { return nil, errors.New("injected stat failure") } @@ -888,17 +883,12 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { // TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono // path's fail-loud backup semantics and every primary-fallback outcome. func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - mono := t.TempDir() if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil { t.Fatal(err) } - - // Per-home backup failure marks that home failed; another home still wins. home := withFakeHome(t) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) @@ -909,67 +899,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { return os.Rename(src, dst) }) result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical backup failure = %#v, %v", result, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("failed home must keep its original dws/: %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback cleanup failure: only a blacklisted home in the main loop, then - // the primary cleanup hits a stale dir whose backup fails. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".real/skills"} - stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale") - os.MkdirAll(stale, 0o755) - useUpgradeManagedNames(t, filepath.Base(stale)) - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback cleanup error = %v", err) - } - if _, err := os.Stat(stale); err != nil { - t.Fatalf("stale dir must survive failed cleanup: %v", err) - } - - // Fallback backup failure of the primary dws/ itself. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure: everything fails loud. - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append (no prior entry for the primary dir): a blacklisted-only - // main loop leaves no primary entry, so the fallback appends a fresh OK. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - result, err = UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary", got) - } - if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) + t.Fatalf("failed canonical home must keep its original dws/: %v", err) } } @@ -977,15 +911,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { // path's blacklisted branch, per-skill backup/copy failures, and the primary // fallback outcomes (cleanup OK + backup/copy failure, fresh append). func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - extract := t.TempDir() multiRoot := writeMultiBundle(t, extract, "dingtalk-chat") - - // Blacklisted entry is reported, non-blacklisted installs. home := withFakeHome(t) - knownSkillDirs = []string{".real/skills", ".agents/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"}) os.MkdirAll(filepath.Join(home, ".agents"), 0o755) result, err := UpgradeSkillLocations(multiRoot) if err != nil { @@ -1003,118 +932,8 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) { t.Fatalf("blacklisted home must never be touched, stat err=%v", err) } - - // A detected concrete Agent root wins over .agents. A failure while retiring - // the old generic copy is surfaced after the concrete root succeeds. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} - os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755) - os.MkdirAll(filepath.Join(home2, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, ".agents") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 backup-failed home", failed) - } - if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil { - t.Fatalf("failed home must keep its original skill: %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Per-skill copy failure on the concrete home fails that home; with no - // successful concrete target, the generic copy is left untouched. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".claude"), 0o755) - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - if strings.Contains(dst, ".claude") { - return errors.New("copy denied") - } - return copyDir(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 copy-failed home", failed) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback backup failure: the same-name bundle skill refresh is what - // fails (cleanup succeeds, the per-skill backup does not). - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - knownSkillDirs = []string{".real/skills"} - os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, "dingtalk-chat") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append: blacklisted-only main loop → fresh OK entry appended. - home6 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results) - } - if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) - } - - // Fallback replace: the main loop fails the primary home on a transient - // copy error, the fallback retry succeeds and replaces the failed entry. - home7 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil }) - knownSkillDirs = []string{".agents/skills"} - origCopy := upgradeCopyDir - attempts := 0 - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - attempts++ - if attempts == 1 { - return errors.New("transient copy failure") - } - return origCopy(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback replace error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("failed entry must be replaced by OK, got %v", failed) - } - if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback replace install missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("canonical install missing: %v", err) } } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index ad4eeb03..97a4b093 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -408,12 +408,27 @@ multi_tree_has_skills() { return 1 } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering duplicates. -retire_generic_skill_root() { +# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the +# canonical store. Universal Agents read it directly; other Agents receive +# relative links and fall back to copies only when links are unavailable. +is_universal_agent_dir() { + case "$1" in + ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + *) return 1 ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 + _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do @@ -430,25 +445,62 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +link_canonical_skills_to_base() { + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + _lcs_names="" + for _lcs_skill in "$_lcs_canonical"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + fi + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi + ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="$_lcs_publish_names $_lcs_name" + done + for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do + [ -d "$_lcs_victim" ] || continue + [ "$_lcs_victim" = "$_lcs_stage" ] && continue + same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue + if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi + if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then + restore_multi_skill_set /dev/null "$_lcs_backups" || true + rm -rf "$_lcs_stage"; return 1 + fi + done + for _lcs_name in $_lcs_publish_names; do + printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || { + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1; + } + if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name" + done + rm -rf "$_lcs_stage" +} + # Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_multi_skills_to_root() { multi_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -466,31 +518,22 @@ install_multi_skills_to_root() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if link_canonical_skills_to_base "$root" "$base_dir" multi; then installed=$((installed + 1)) else - failed=$((failed + 1)) - printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir" + printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 @@ -654,20 +697,11 @@ install_skills_to_root() { skill_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -685,44 +719,22 @@ install_skills_to_root() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if [ "$root" = "$HOME" ]; then - label="~/$agent_dir/$SKILL_NAME" - else - label="$root/$agent_dir/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - if [ "$root" = "$HOME" ]; then - flabel="~/.agents/skills/$SKILL_NAME" - else - flabel="$root/.agents/skills/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d9c62a1b..bab5a678 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -458,10 +458,20 @@ function Restore-MultiSkillSet { return $ok } -function Move-GenericSkillRootToBackup { - param([string]$Root) +function Test-UniversalAgentDir { + param([string]$AgentDir) + return @(".cursor\skills", ".gemini\skills", ".codex\skills", ".github\skills", ".cline\skills", ".amp\skills") -contains $AgentDir +} + +function Test-SamePhysicalSkillRoot { + param([string]$Left, [string]$Right) + if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } + try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false } +} + +function Move-AgentSkillRootToBackup { + param([string]$Root, [string]$BaseDir) - $baseDir = Join-Path $Root ".agents\skills" $victims = [System.Collections.Generic.List[string]]::new() $victims.Add((Join-Path $baseDir $SkillName)) foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) { @@ -476,7 +486,7 @@ function Move-GenericSkillRootToBackup { if (!$seen.Add($victim)) { continue } $backupPath = "" if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { - throw "通用 Skill 副本备份失败: $victim" + throw "Agent Skill 旧副本备份失败: $victim" } if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } @@ -485,11 +495,64 @@ function Move-GenericSkillRootToBackup { return $true } catch { Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null - Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_" + Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_" return $false } } +function Publish-CanonicalSkillLinks { + param([string]$Root, [string]$BaseDir, [string]$Mode) + $canonical = Join-Path $Root ".agents\skills" + if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } + if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } + $stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N")) + $backups = @() + $published = @() + try { + New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null + if ($Mode -eq "mono") { + $names = @($SkillName) + } else { + $names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) + } + $publishNames = @() + foreach ($name in $names) { + if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue } + New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target (Join-Path $canonical $name) -ErrorAction Stop | Out-Null + $publishNames += $name + } + $victims = [System.Collections.Generic.List[string]]::new() + $victims.Add((Join-Path $BaseDir $SkillName)) + foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -Force -ErrorAction SilentlyContinue) { + if ($existing.FullName -eq $stageRoot) { continue } + if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } + } + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($victim in $victims) { + if (!$seen.Add($victim)) { continue } + if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue } + $backupPath = "" + if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" } + if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } } + } + foreach ($name in $publishNames) { + $dest = Join-Path $BaseDir $name + $published += $dest + Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + Write-Say "↪ Skills → $dest" + } + return $true + } catch { + Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null + Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" + return $false + } finally { + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Copy-SkillToDir { param([string]$SkillSrc, [string]$Dest, [string]$Label) @@ -811,46 +874,27 @@ function Install-SkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } + $canonical = Join-Path $Root ".agents\skills" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $agentDir = $AgentDirs[$i] $baseDir = Join-Path $Root $agentDir $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + if (!(Test-Path $parentGate)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if (Test-UniversalAgentDir -AgentDir $agentDir) { + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } continue } - $attempted++ - if ($Root -eq $HOME) { - $label = "~\$agentDir\$SkillName" - } else { - $label = Join-Path $Root (Join-Path $agentDir $SkillName) - } - $copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label - if ($copied) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { $installed++ } else { - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - $fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName - if ($Root -eq $HOME) { - $flabel = "~\.agents\skills\$SkillName" - } else { - $flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName) - } - if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) { - $installed++ - } else { - $failed++ + Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { $installed++ } else { $failed++ } } } if ($installed -eq 0) { @@ -889,35 +933,27 @@ function Install-MultiSkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } + $canonical = Join-Path $Root ".agents\skills" + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $agentDir = $AgentDirs[$i] $baseDir = Join-Path $Root $agentDir $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + if (!(Test-Path $parentGate)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if (Test-UniversalAgentDir -AgentDir $agentDir) { + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } continue } - $attempted++ - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") { $installed++ } else { - Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)" - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") { - $installed++ - } else { - $failed++ + Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { $installed++ } else { $failed++ } } } if ($installed -eq 0) { diff --git a/scripts/install.sh b/scripts/install.sh index 016055fe..9fd1d289 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -643,13 +643,27 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering the same Skill -# through both ~/.agents/skills and ~/.codex/skills. -retire_generic_skill_root() { +# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the +# canonical store. Universal Agents read it directly; other Agents receive +# relative links and fall back to copies only when links are unavailable. +is_universal_agent_dir() { + case "$1" in + ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + *) return 1 ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 + _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do @@ -666,6 +680,64 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +link_canonical_skills_to_base() { + _lcs_root="$1" + _lcs_base="$2" + _lcs_mode="$3" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups" + _lcs_published="$_lcs_stage/.published" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + _lcs_names="" + for _lcs_skill in "$_lcs_canonical"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + fi + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then + continue + fi + ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="$_lcs_publish_names $_lcs_name" + done + for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do + [ -d "$_lcs_victim" ] || continue + [ "$_lcs_victim" = "$_lcs_stage" ] && continue + same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue + if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then + continue + fi + if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then + restore_multi_skill_set /dev/null "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + done + for _lcs_name in $_lcs_publish_names; do + printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || { + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then + restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_base/$_lcs_name" + done + rm -rf "$_lcs_stage" +} + # Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes). # Installing mono removes proven DWS-managed multi leftovers for mutual exclusion, # mirroring `dws skill setup --mode mono`. @@ -673,20 +745,11 @@ install_skills_to_homes() { skill_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -704,50 +767,22 @@ install_skills_to_homes() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - case "$root" in - "$HOME") - label="~/$agent_dir/$SKILL_NAME" - ;; - *) - label="$root/$agent_dir/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - case "$root" in - "$HOME") - flabel="~/.agents/skills/$SKILL_NAME" - ;; - *) - flabel="$root/.agents/skills/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 @@ -783,20 +818,11 @@ install_multi_skills_to_homes() { multi_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" - do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in \ - ".agents/skills" \ ".claude/skills" \ ".cursor/skills" \ ".qoder/skills" \ @@ -814,31 +840,22 @@ install_multi_skills_to_homes() { ".openclaw/skills" \ ".hermes/skills" do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi base_dir="$root/$agent_dir" parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) + [ -e "$parent_gate" ] || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue + attempted=$((attempted + 1)) + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) continue fi - attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if link_canonical_skills_to_base "$root" "$base_dir" multi; then installed=$((installed + 1)) else - failed=$((failed + 1)) - say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)" + say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 71b0d8c0..dfe37168 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -90,6 +90,14 @@ HOME_SPECIFIC_SKILL_BASES=" .openclaw/skills .hermes/skills " +HOME_UNIVERSAL_SKILL_BASES=" +.cursor/skills +.gemini/skills +.codex/skills +.github/skills +.cline/skills +.amp/skills +" cleanup() { if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -118,23 +126,28 @@ verify_skill_base() { verify_skill_targets() { home_root="$1" - found_specific=0 + # lark-cli/skills-compatible canonical store is always present, regardless + # of which concrete Agent homes existed at install time. + verify_skill_base "$home_root" ".agents/skills" for base in $HOME_SPECIFIC_SKILL_BASES; do parent="${base%/skills}" - if [ -d "$home_root/$parent" ]; then - verify_skill_base "$home_root" "$base" - found_specific=1 + [ -d "$home_root/$parent" ] || continue + universal=0 + for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do + [ "$base" = "$universal_base" ] && universal=1 + done + if [ "$universal" -eq 1 ]; then + for name in dingtalk-shared dingtalk-misc dws; do + [ ! -e "$home_root/$base/$name" ] || \ + err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" + done + continue fi - done - - if [ "$found_specific" -eq 0 ]; then - verify_skill_base "$home_root" ".agents/skills" - return - fi - - for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/.agents/skills/$name" ] || \ - err "unexpected generic Skill copy found in $home_root/.agents/skills/$name" + verify_skill_base "$home_root" "$base" + for name in dingtalk-shared dingtalk-misc; do + [ -L "$home_root/$base/$name" ] || \ + err "expected canonical Skill link not found at $home_root/$base/$name" + done done } diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index b94bb679..29be067a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -193,7 +193,7 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex uses its canonical root without a generic duplicate", () => { +scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -201,6 +201,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { }); try { writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); + writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n"); writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -209,12 +210,12 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { const res = runInstall(pkg, home, "multi"); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( - fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")), - "Codex canonical Skill installed", + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "universal canonical Skill installed", ); assert.ok( - !fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), - "generic flat duplicate not installed", + !fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")), + "beta.6 Codex duplicate migrated", ); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), @@ -225,7 +226,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { } }); -scenario("ZCode uses its canonical root without a generic duplicate", () => { +scenario("ZCode links its Agent root to the canonical store", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -241,8 +242,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")), - "ZCode canonical Skill installed", + "ZCode Skill resolves through canonical link", ); + assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink()); + assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"))); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy generic duplicate retired", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d6e299a5..ac685fb5 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -182,13 +182,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } - skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md") + skillPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output)) } - genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws") - if _, err := os.Stat(genericPath); !os.IsNotExist(err) { - t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output)) + duplicatePath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws") + if _, err := os.Lstat(duplicatePath); !os.IsNotExist(err) { + t.Fatalf("universal Cursor root must not duplicate canonical Skill: Lstat(%s) = %v\noutput:\n%s", duplicatePath, err, string(output)) } } @@ -1242,7 +1242,7 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh") } -func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1277,11 +1277,11 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } for _, duplicate := range []string{ - filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"), + filepath.Join(home, ".codex", "skills", "dingtalk-chat"), filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -1292,7 +1292,7 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1328,7 +1328,13 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err) + t.Fatalf("%s linked ZCode Skill missing: %v", scriptName, err) + } + if info, err := os.Lstat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s ZCode target is not a canonical link: %#v, %v", scriptName, info, err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("%s generic duplicate remains: %v", scriptName, err) @@ -1337,7 +1343,7 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1378,15 +1384,18 @@ exit 0 if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical Codex Skill missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) { + t.Fatalf("PowerShell Codex duplicate remains: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) } } -func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellLinksZCodeRootToCanonical(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1428,7 +1437,10 @@ exit 0 t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err) + t.Fatalf("PowerShell linked ZCode Skill missing: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) From 79f7ee80e0e93edb069f3ef0c3a8313f67243a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 20:21:56 +0800 Subject: [PATCH 052/267] fix(skills): complete canonical agent compatibility --- .changes/996-canonical-agent-skills.md | 5 + build/npm/install.js | 174 +++++--- docs/rfc-skill-installation-and-upgrade.md | 24 +- internal/app/skill_command.go | 131 ++++-- internal/app/skill_command_test.go | 6 +- internal/app/skill_setup.go | 298 ++++++++++++-- .../skill_setup_canonical_failures_test.go | 115 ++++++ internal/app/skill_setup_canonical_test.go | 268 ++++++++++++- internal/app/skill_setup_plan_test.go | 4 +- internal/app/skill_setup_test.go | 3 +- internal/upgrade/canonical_skills_test.go | 374 +++++++++++++++++- internal/upgrade/coverage_edges_test.go | 5 +- internal/upgrade/paths.go | 258 ++++++++++-- internal/upgrade/paths_multi_test.go | 18 +- scripts/install-devapp.ps1 | 117 +++++- scripts/install-devapp.sh | 95 ++++- scripts/install-event.sh | 109 +++-- scripts/install-skills.sh | 132 ++++--- scripts/install.ps1 | 218 ++++++++-- scripts/install.sh | 136 ++++--- scripts/release/verify-package-managers.sh | 213 +++++++--- test/scripts/agent_registry_test.go | 258 ++++++++++++ test/scripts/install_js_smoke.mjs | 60 ++- test/scripts/install_script_test.go | 169 +++++++- test/scripts/package_script_test.go | 107 ++++- 25 files changed, 2875 insertions(+), 422 deletions(-) create mode 100644 .changes/996-canonical-agent-skills.md create mode 100644 internal/app/skill_setup_canonical_failures_test.go create mode 100644 test/scripts/agent_registry_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md new file mode 100644 index 00000000..c42462e8 --- /dev/null +++ b/.changes/996-canonical-agent-skills.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. diff --git a/build/npm/install.js b/build/npm/install.js index 8a7e94d4..e43d29fc 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -8,37 +8,106 @@ const os = require("os"); const path = require("path"); const childProcess = require("child_process"); -// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. -const AGENT_DIRS = [ - ".agents/skills", - ".claude/skills", - ".cursor/skills", - ".qoder/skills", - ".qoderwork/skills", - ".gemini/skills", - ".codex/skills", - ".zcode/skills", - ".github/skills", - ".windsurf/skills", - ".augment/skills", - ".cline/skills", - ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs, +// including canonical-direct and no-global-directory entries. The resolver +// below intentionally deduplicates shared effective roots only after this +// authoritative enumeration has been classified. +const UPSTREAM_AGENTS = [ + ["aider-desk", false, ".aider-desk/skills"], + ["amp", true, ".config/agents/skills"], + ["antigravity", true, ".gemini/antigravity/skills"], + ["antigravity-cli", true, ".gemini/antigravity-cli/skills"], + ["astrbot", false, ".astrbot/data/skills"], + ["autohand-code", false, ".autohand/skills"], + ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"], + ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"], + ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"], + ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"], + ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"], + ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"], + ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"], + ["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"], + ["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"], + ["droid", false, ".factory/skills"], ["eve", false, null], + ["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"], + ["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"], + ["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"], + ["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"], + ["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"], + ["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"], + ["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"], + ["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"], + ["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"], + ["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"], + ["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"], + ["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"], + ["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"], + ["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"], + ["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"], + ["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"], + ["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"], + ["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"], + ["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"], + ["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"], + ["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"], + ["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"], + ["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"], + ["pochi", false, ".pochi/skills"], ["promptscript", true, null], + ["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"], +].map(([id, universal, agentDir]) => ({ id, universal, agentDir })); + +// Paths emitted by beta.6 or older DWS installers that do not correspond to +// the upstream registry. They are cleanup-only migration targets. +const LEGACY_UNIVERSAL_AGENT_DIRS = [ + ".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills", ]; -// Same capability split as the `skills` CLI used by lark-cli. Universal -// Agents read ~/.agents/skills directly and must not receive a second copy. -const UNIVERSAL_AGENT_DIRS = new Set([ - ".cursor/skills", - ".gemini/skills", - ".codex/skills", - ".github/skills", - ".cline/skills", - ".amp/skills", -]); +function openClawSkillsDir(homeDir) { + for (const name of [".openclaw", ".clawdbot", ".moltbot"]) { + const root = path.join(homeDir, name); + if (fs.existsSync(root)) return path.join(root, "skills"); + } + return path.join(homeDir, ".openclaw", "skills"); +} + +function resolvedAgentTargets(homeDir) { + const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config"); + const targets = [ + ...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"), + // DWS compatibility target; qoderwork is not in upstream's registry. + { id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true }, + ...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })), + ].map((target) => { + const { agentDir } = target; + let baseDir = path.join(homeDir, agentDir); + if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) { + baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills"); + } else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) { + baseDir = path.join(process.env.CODEX_HOME.trim(), "skills"); + } else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) { + baseDir = path.join(process.env.HERMES_HOME.trim(), "skills"); + } else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) { + baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills"); + } else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) { + baseDir = path.join(process.env.GROK_HOME.trim(), "skills"); + } else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) { + baseDir = path.join(process.env.VIBE_HOME.trim(), "skills"); + } else if (agentDir === ".openclaw/skills") { + baseDir = openClawSkillsDir(homeDir); + } else if (agentDir.startsWith(".config/")) { + baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length)); + } + return { ...target, baseDir }; + }); + const seen = new Set(); + return targets.filter((target) => { + let key = path.resolve(target.baseDir); + if (process.platform === "win32") key = key.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", @@ -83,9 +152,6 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren if (err && err.code === "ENOENT") return true; throw err; } - if (!info.isDirectory() && !info.isSymbolicLink()) { - return true; - } const rel = path.relative(homeDir, dir); const name = rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) @@ -279,11 +345,11 @@ function installSkillsToHomes(skillRoot) { } if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -304,9 +370,10 @@ function installSkillsToHomes(skillRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -317,6 +384,10 @@ function installSkillsToHomes(skillRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); } fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true }); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and @@ -402,7 +473,9 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { function samePhysicalDir(left, right) { try { - return fs.realpathSync(left) === fs.realpathSync(right); + const leftReal = fs.realpathSync(left); + const rightReal = fs.realpathSync(right); + return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal; } catch (_) { return false; } @@ -410,6 +483,7 @@ function samePhysicalDir(left, right) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { fs.mkdirSync(baseDir, { recursive: true }); + const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); const staged = []; const backups = []; @@ -433,7 +507,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, continue; } const stagedPath = path.join(stageRoot, name); - const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target); + const realTarget = fs.realpathSync(target); + const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); } @@ -742,11 +817,11 @@ function installMultiSkillsToHomes(multiRoot) { else failed += 1; if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -770,9 +845,10 @@ function installMultiSkillsToHomes(multiRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -783,6 +859,10 @@ function installMultiSkillsToHomes(multiRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); } writeSkillsState(homeDir, multiRoot, skills); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi) @@ -897,6 +977,8 @@ if (require.main === module) { } module.exports = { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a33bc95f..bad9f3f9 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约 | 模式 | Agent 目录布局 | 选择方式 | |---|---|---| -| multi(默认) | `/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | -| mono(兼容) | `/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | +| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` | +| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in | 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 @@ -140,10 +140,21 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器对每个 Agent 目标执行: +升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的 +Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的 +非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical +的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 +自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 +`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 +`.moltbot` 必须按 Agent 实际优先级解析。 -- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback; -- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 +non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent +解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 +Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 +`.amp/skills`、`.cline/skills` 与 +`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; @@ -192,6 +203,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。 | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | +| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 | Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 @@ -209,6 +221,8 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; +- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; +- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8db71d07..e43484f6 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -117,38 +117,114 @@ type CliSkillDTO struct { // (skill_setup.go) MUST have a matching path value here — enforced by // TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ - // `agents` is the generic-agent sentinel: install scripts and `setup` - // special-case ~/.agents/skills as a no-checks-required fallback so a - // fresh machine without any IDE/agent registry still gets skills. - "agents": ".agents/skills", - "qoder": ".qoder/skills", - "qoderwork": ".qoderwork/skills", + // Universal agents. Those without an independent global directory map + // directly to the canonical ~/.agents/skills store. + "agents": ".agents/skills", + "amp": filepath.Join(".config", "agents", "skills"), + "antigravity": ".gemini/antigravity/skills", + "antigravity-cli": ".gemini/antigravity-cli/skills", + "codex": ".codex/skills", + "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", + "firebender": ".firebender/skills", + "gemini-cli": ".gemini/skills", + "github-copilot": ".copilot/skills", + "opencode": filepath.Join(".config", "opencode", "skills"), + "replit": filepath.Join(".config", "agents", "skills"), + "universal": filepath.Join(".config", "agents", "skills"), + "cline": ".agents/skills", + "dexto": ".agents/skills", + "kimi-code-cli": ".agents/skills", + "loaf": ".agents/skills", + "warp": ".agents/skills", + "zed": ".agents/skills", + + // Non-universal agents with global Skill directories. + "aider-desk": ".aider-desk/skills", + "astrbot": ".astrbot/data/skills", + "autohand-code": ".autohand/skills", + "augment": ".augment/skills", + "bob": ".bob/skills", + "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", + "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", + "codemaker": ".codemaker/skills", + "codestudio": ".codestudio/skills", + "command-code": ".commandcode/skills", + "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", + "crush": filepath.Join(".config", "crush", "skills"), + "devin": filepath.Join(".config", "devin", "skills"), + "droid": ".factory/skills", + "forgecode": ".forge/skills", + "goose": filepath.Join(".config", "goose", "skills"), + "grok": ".grok/skills", + "hermes-agent": ".hermes/skills", + "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", + "junie": ".junie/skills", + "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", + "kimchi": filepath.Join(".config", "kimchi", "harness", "skills"), + "kiro-cli": ".kiro/skills", + "kode": ".kode/skills", + "lingma": ".lingma/skills", + "mcpjam": ".mcpjam/skills", + "minimax-code": ".minimax/skills", + "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", + "mux": ".mux/skills", + "openhands": ".openhands/skills", + "ona": ".ona/skills", + "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", + "qoder-cn": ".qoder-cn/skills", + "qwen-code": ".qwen/skills", + "reasonix": ".reasonix/skills", + "rovodev": ".rovodev/skills", + "roo": ".roo/skills", + "tabnine-cli": ".tabnine/agent/skills", + "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", + "trae": ".trae/skills", + "trae-cn": ".trae-cn/skills", + "windsurf": ".codeium/windsurf/skills", + "zcode": ".zcode/skills", + "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", + "neovate": ".neovate/skills", + "pochi": ".pochi/skills", + "adal": ".adal/skills", + + // DWS compatibility aliases and DWS-only integrations. "claude": ".claude/skills", - "cursor": ".cursor/skills", - "codex": ".codex/skills", - "zcode": ".zcode/skills", - "opencode": filepath.Join(".config", "opencode", "skills"), - // IDE / agent registries also probed by `dws skill setup --target all`. - "gemini": ".gemini/skills", - "github": ".github/skills", - "windsurf": ".windsurf/skills", - "augment": ".augment/skills", - "cline": ".cline/skills", - "amp": ".amp/skills", - "kiro": ".kiro/skills", - "trae": ".trae/skills", - "openclaw": ".openclaw/skills", - "hermes": ".hermes/skills", + "gemini": ".gemini/skills", + "github": ".copilot/skills", + "hermes": ".hermes/skills", + "kiro": ".kiro/skills", + "qoderwork": ".qoderwork/skills", +} + +// Eve has project-scoped Skill directories but no upstream globalSkillsDir. +// Keep it in the advertised enumeration while failing explicitly instead of +// pretending that a global install configured Eve. +var unsupportedGlobalAgentTargets = map[string]string{ + "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills", + "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills", } // supportedTargets returns a sorted, comma-separated list of supported // targets. Sorted so help text and error messages stay stable across runs // (Go map iteration order is intentionally randomized). func supportedTargets() string { - targets := make([]string, 0, len(agentSkillPaths)+1) + targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1) for target := range agentSkillPaths { targets = append(targets, target) } + for target := range unsupportedGlobalAgentTargets { + targets = append(targets, target) + } sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") @@ -485,8 +561,13 @@ func resolveSkillTargetPath(target string) (string, error) { return os.Getwd() } - // Look up predefined agent paths - relPath, ok := agentSkillPaths[strings.ToLower(target)] + target = strings.ToLower(target) + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return "", errors.New(reason) + } + + // Look up predefined agent paths. + _, ok := agentSkillPaths[target] if !ok { return "", fmt.Errorf("unsupported target") } @@ -496,7 +577,7 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return filepath.Join(homeDir, relPath), nil + return resolveSkillSetupBase(homeDir, target), nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 381c87bc..4fcdb38d 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -477,8 +477,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { for _, p := range agentSkillPaths { paths[p] = true } + legacyCleanupOnly := map[string]bool{ + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } for _, home := range skillSetupAgentHomes { - if !paths[home] { + if !paths[home] && !legacyCleanupOnly[home] { t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ "add it to agentSkillPaths so users can address it via --target ", home) } diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 7f5fe2fb..94c8cbf5 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -23,22 +24,77 @@ import ( // agree on the install footprint. var skillSetupAgentHomes = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } const ( @@ -65,6 +121,8 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupLstat = os.Lstat + skillSetupGetenv = os.Getenv skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd @@ -85,6 +143,7 @@ var ( skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove skillSetupNow = time.Now + skillSetupFoldPathCase = runtime.GOOS == "windows" ) type skillSetupBackup struct { @@ -153,9 +212,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 -原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, -链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。 +DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装, +无需用户手动处理,也不会让同一个 Skill 重复出现。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -248,7 +307,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } } - // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。 @@ -355,7 +413,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) - fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") + fmt.Fprintln(out, " 统一安装位置:~/.agents/skills") + fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent") + fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。") return nil } @@ -934,17 +994,85 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { return detectExistingAgentHomes(home, mode), nil } - rel, ok := agentSkillPaths[target] + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return nil, errors.New(reason) + } + _, ok := agentSkillPaths[target] if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - dest := agentHomeForMode(filepath.Join(home, rel), mode) - if filepath.Clean(dest) == filepath.Clean(canonical) { + dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode) + if sameSkillSetupPath(dest, canonical) { return []string{canonical}, nil } return []string{canonical, dest}, nil } +func resolveOpenClawSetupBase(home string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(home, name) + if info, err := skillSetupStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(home, ".openclaw", "skills") +} + +func resolveSkillSetupBase(home, target string) string { + switch target { + case "claude", "claude-code": + if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + return filepath.Join(custom, "skills") + } + case "codex": + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "hermes", "hermes-agent": + if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "autohand-code": + if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "grok": + if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "mistral-vibe": + if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "openclaw": + return resolveOpenClawSetupBase(home) + case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi": + configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(home, ".config") + } + switch target { + case "opencode": + return filepath.Join(configHome, "opencode", "skills") + case "amp", "replit", "universal": + return filepath.Join(configHome, "agents", "skills") + case "crush": + return filepath.Join(configHome, "crush", "skills") + case "devin": + return filepath.Join(configHome, "devin", "skills") + case "goose": + return filepath.Join(configHome, "goose", "skills") + case "kimchi": + return filepath.Join(configHome, "kimchi", "harness", "skills") + } + case "github", "github-copilot": + return filepath.Join(home, ".copilot", "skills") + case "windsurf": + return filepath.Join(home, ".codeium", "windsurf", "skills") + } + return filepath.Join(home, agentSkillPaths[target]) +} + // agentHomeForMode appends the mode-specific tail segment to an agent home base. func agentHomeForMode(base, mode string) string { if mode == skillSetupModeMulti { @@ -956,16 +1084,57 @@ func agentHomeForMode(base, mode string) string { func detectExistingAgentHomes(home, mode string) []string { canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) dests := []string{canonical} + canonicalKey := skillSetupPathKey(canonical) + seen := map[string]bool{canonicalKey: true} + addDetected := func(base string) { + parent := filepath.Dir(base) + if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { + return + } + dest := agentHomeForMode(base, mode) + key := skillSetupPathKey(dest) + if !seen[key] { + seen[key] = true + dests = append(dests, dest) + } + } for i, rel := range skillSetupAgentHomes { if i == 0 { continue } base := filepath.Join(home, rel) - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { - continue + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".claude/skills": + base = resolveSkillSetupBase(home, "claude-code") + case ".codex/skills": + base = resolveSkillSetupBase(home, "codex") + case ".hermes/skills": + base = resolveSkillSetupBase(home, "hermes-agent") + case ".autohand/skills": + base = resolveSkillSetupBase(home, "autohand-code") + case ".grok/skills": + base = resolveSkillSetupBase(home, "grok") + case ".vibe/skills": + base = resolveSkillSetupBase(home, "mistral-vibe") + case ".openclaw/skills": + base = resolveSkillSetupBase(home, "openclaw") + case ".config/opencode/skills": + base = resolveSkillSetupBase(home, "opencode") + case ".config/agents/skills": + base = resolveSkillSetupBase(home, "amp") + case ".config/crush/skills": + base = resolveSkillSetupBase(home, "crush") + case ".config/devin/skills": + base = resolveSkillSetupBase(home, "devin") + case ".config/goose/skills": + base = resolveSkillSetupBase(home, "goose") + case ".config/kimchi/harness/skills": + base = resolveSkillSetupBase(home, "kimchi") } - dests = append(dests, agentHomeForMode(base, mode)) + addDetected(base) + } + for _, target := range []string{"github-copilot", "windsurf"} { + addDetected(resolveSkillSetupBase(home, target)) } return dests } @@ -978,10 +1147,26 @@ func skillSetupBaseForMode(dest, mode string) string { } func isUniversalSkillSetupBase(base string) bool { - base = filepath.ToSlash(filepath.Clean(base)) + cleanBase := filepath.Clean(base) + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) { + return true + } + if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" { + if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) || + sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) { + return true + } + } + base = filepath.ToSlash(cleanBase) for rel := range map[string]bool{ - ".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, - ".github/skills": true, ".cline/skills": true, ".amp/skills": true, + ".config/agents/skills": true, ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, ".codex/skills": true, + ".cursor/skills": true, ".deepagents/agent/skills": true, + ".firebender/skills": true, ".gemini/skills": true, + ".copilot/skills": true, ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only. + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, } { if strings.HasSuffix(base, "/"+rel) { return true @@ -990,6 +1175,18 @@ func isUniversalSkillSetupBase(base string) bool { return false } +func sameSkillSetupPath(left, right string) bool { + return skillSetupPathKey(left) == skillSetupPathKey(right) +} + +func skillSetupPathKey(path string) string { + clean := filepath.Clean(path) + if skillSetupFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + func canonicalSkillSetupBase(dests []string, mode string) string { for _, dest := range dests { base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) @@ -1003,7 +1200,7 @@ func canonicalSkillSetupBase(dests []string, mode string) string { func samePhysicalSkillSetupPath(left, right string) bool { leftReal, leftErr := skillSetupEvalSymlinks(left) rightReal, rightErr := skillSetupEvalSymlinks(right) - return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) + return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { @@ -1075,7 +1272,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } sort.Strings(plan.MultiSkillNames) sortedDests := append([]string(nil), dests...) - sort.Strings(sortedDests) + sort.Slice(sortedDests, func(i, j int) bool { + leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills") + rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills") + if leftCanonical != rightCanonical { + return leftCanonical + } + return sortedDests[i] < sortedDests[j] + }) managedNames := currentManagedSkillNames() canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { @@ -1134,16 +1338,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { continue } - info, statErr := skillSetupStat(path) + _, statErr := skillSetupLstat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue } return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr) } - if info.IsDir() { - add(path, skillSetupBackupReplace) - } + add(path, skillSetupBackupReplace) } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) if !target.CleanupOnly || len(target.Backups) > 0 { @@ -1200,14 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, " · %s\n", name) } } - fmt.Fprintln(out, " destinations:") + fmt.Fprintln(out, " 安装与适配位置:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination) } else if target.LinkCanonical { - fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) + fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination) } else { - fmt.Fprintf(out, " - %s\n", target.Destination) + fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination) } } fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):") @@ -1700,6 +1902,10 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } }() + realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent) + if realParentErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr) + } stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) @@ -1708,7 +1914,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s if samePhysicalSkillSetupPath(dest, canonicalTarget) { return nil } - relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget) + realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget) + if realTargetErr != nil { + return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr) + } + relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget) if relErr != nil { return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) } @@ -1819,7 +2029,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if plan.Mode == skillSetupModeMulti { perTarget = len(plan.MultiSkillNames) } + hasCanonicalDependents := false + for _, candidate := range plan.Targets { + if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { + hasCanonicalDependents = true + break + } + } for _, target := range plan.Targets { + isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) @@ -1833,6 +2051,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install continue } if len(target.Backups) > 0 && homeErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr) + } if plan.Mode == skillSetupModeMono { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) } else { @@ -1844,12 +2065,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) if stageErr != nil && target.LinkCanonical { - fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) + fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) fallback := target fallback.LinkCanonical = false stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) } if stageErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr) + } fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget continue @@ -1859,6 +2083,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr) + } fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) skipped += perTarget continue @@ -1869,6 +2096,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr != nil { publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) + } fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) skipped += perTarget continue diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go new file mode 100644 index 00000000..c1b82a19 --- /dev/null +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -0,0 +1,115 @@ +package app + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { + t.Helper() + home := t.TempDir() + src := t.TempDir() + skill := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skill, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + dependent := filepath.Join(home, ".claude", "skills") + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + return home, plan +} + +func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) { + t.Run("home", func(t *testing.T) { + _, plan := canonicalFailurePlan(t) + canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonical, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") { + t.Fatalf("home failure = %v", err) + } + }) + + t.Run("backup", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") { + t.Fatalf("backup failure = %v", err) + } + }) + + t.Run("publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { + t.Fatalf("publish failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { + home, plan := canonicalFailurePlan(t) + canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonicalTarget, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + linked := plan.Targets[1] + + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == linked.Destination { + return "", errors.New("parent denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical parent error = %v", err) + } + }) + + t.Run("canonical-target", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == canonicalTarget { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical target error = %v", err) + } + }) + + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative path error = %v", err) + } + }) + + _ = home +} diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 83a43d93..85f823a3 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -2,14 +2,16 @@ package app import ( "bytes" + "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { +func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupAgentHomes, []string{ @@ -47,6 +49,16 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { t.Fatal(err) } + claudeSkills := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeSkills, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil { + t.Fatal(err) + } plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) if err != nil { @@ -87,3 +99,257 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { } } } + +func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { + home := t.TempDir() + customClaude := filepath.Join(t.TempDir(), "claude") + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(name string) string { + switch name { + case "CLAUDE_CONFIG_DIR": + return customClaude + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".agents", "skills"), + filepath.Join(customClaude, "skills"), + filepath.Join(customCodex, "skills"), + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".moltbot", "skills"), + filepath.Join(home, ".copilot", "skills"), + filepath.Join(home, ".config", "opencode", "skills"), + filepath.Join(home, ".config", "agents", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + found := false + for _, got := range dests { + if sameSkillSetupPath(got, want) { + found = true + break + } + } + if !found { + t.Fatalf("resolved targets %v missing %s", dests, want) + } + } + if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) { + t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets") + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + oldClaude := filepath.Join(claude, "dingtalk-chat") + if err := os.MkdirAll(oldClaude, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil { + t.Fatal(err) + } + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("canonical failure = %v", err) + } + body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md")) + if readErr != nil || string(body) != "old remains" { + t.Fatalf("dependent Claude target changed: %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") }) + + plan, err := buildSkillSetupPlan( + skillSetupModeMulti, + src, + []string{canonical, claude}, + []string{"dingtalk-chat"}, + false, + ) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("human-readable fallback message missing: %s", errOut.String()) + } + info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat")) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } +} + +func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + + expected := map[string]string{ + "aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills", + "antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills", + "astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills", + "augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills", + "codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills", + "droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills", + "gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills", + "grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills", + "kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills", + "mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills", + "openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills", + "replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills", + "roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills", + "universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills", + "zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills", + } + if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 { + t.Fatalf("upstream agent enumeration = %d, want 76", got) + } + for target, rel := range expected { + mapped, ok := agentSkillPaths[target] + if !ok || filepath.Clean(mapped) != filepath.Clean(rel) { + t.Errorf("agent %s map = %q, want %q", target, mapped, rel) + } + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) { + t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel)) + } + } + for _, target := range []string{"eve", "promptscript"} { + if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil { + t.Errorf("%s unexpectedly resolved a global setup root", target) + } + if _, err := resolveSkillTargetPath(target); err == nil { + t.Errorf("%s unexpectedly resolved a marketplace install root", target) + } + } + if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") { + t.Fatalf("supported targets omit no-global upstream agents: %s", got) + } + + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"), + "CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"), + "HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"), + "XDG_CONFIG_HOME": filepath.Join(home, "xdg"), + } + testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] }) + customCases := map[string]string{ + "autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(custom["CODEX_HOME"], "skills"), + "grok": filepath.Join(custom["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"), + "amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + "devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + "goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + "kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + "opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for target, want := range customCases { + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) { + t.Errorf("custom %s root = %q, want %q", target, got, want) + } + } + for _, target := range []string{"codex", "amp", "opencode"} { + if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) { + t.Errorf("custom %s root not classified universal", target) + } + } +} + +func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) { + testseam.Swap(t, &skillSetupFoldPathCase, true) + if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) { + t.Fatal("case-insensitive platform path normalization failed") + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index e46339f2..8330d6e9 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -170,7 +170,7 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "universal Agent") { + if !strings.Contains(preview.String(), "改用统一安装位置") { t.Fatalf("universal cleanup preview missing: %s", preview.String()) } @@ -249,11 +249,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin t.Fatal(err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") { t.Fatalf("replacement stat error = %v", err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") { t.Fatalf("stale scan error = %v", err) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 1a37371e..f5e3eb28 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets( if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { t.Fatal(err) } - stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") if err != nil { t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) } - if !strings.Contains(stdout, "重新加载 Skills") { + if !strings.Contains(stdout, "请重启已打开的 Agent") { t.Fatalf("completion should tell the user to reload skills: %s", stdout) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 163598ad..c08aa96a 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -4,12 +4,13 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { +func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{ ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", @@ -67,7 +68,77 @@ func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testi } } -func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { +func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) { + home := withFakeHome(t) + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + externalClaude := filepath.Join(t.TempDir(), "claude") + for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { + switch name { + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeBase := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeBase, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("custom CODEX_HOME duplicate remains: %v", err) + } + for _, base := range []string{ + claudeBase, + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".clawdbot", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + path := filepath.Join(base, name) + info, statErr := os.Lstat(path) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr) + } + body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), name) { + t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr) + } + } + } +} + +func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { @@ -89,3 +160,302 @@ func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { t.Fatalf("copy fallback content missing: %v", err) } } + +func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { + home := t.TempDir() + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "custom-autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"), + "CODEX_HOME": filepath.Join(home, "custom-codex"), + "GROK_HOME": filepath.Join(home, "custom-grok"), + "HERMES_HOME": filepath.Join(home, "custom-hermes"), + "VIBE_HOME": filepath.Join(home, "custom-vibe"), + "XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"), + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] }) + roots := configuredSkillRoots(home) + if len(roots) != len(knownSkillDirs) { + t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs)) + } + byLabel := make(map[string]resolvedSkillRoot, len(roots)) + for _, root := range roots { + if _, duplicate := byLabel[root.label]; duplicate { + t.Fatalf("duplicate configured label %s", root.label) + } + byLabel[root.label] = root + } + wants := map[string]string{ + ".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + ".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + ".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"), + ".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"), + ".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"), + ".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"), + ".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + ".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + ".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + ".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + ".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + ".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for label, want := range wants { + if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) { + t.Errorf("configured %s = %q, want %q", label, got, want) + } + } + for _, label := range []string{ + ".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills", + ".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills", + ".gemini/skills", ".copilot/skills", ".config/opencode/skills", + } { + if !byLabel[label].universal { + t.Errorf("%s not classified universal", label) + } + } + if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal { + t.Fatal("canonical/non-universal classification drift") + } +} + +func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) + testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) + + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + } else { + src = writeMultiBundle(t, src, "dingtalk-chat") + } + result, err := UpgradeSkillLocations(src) + if err != nil { + t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err) + } + if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("result = %#v", result) + } + if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) { + t.Fatal("canonical-only configuration unexpectedly has a dependent root") + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + dest := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("mkdir", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") { + t.Fatalf("mkdir failure = %v", err) + } + }) + t.Run("temp", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") { + t.Fatalf("temp failure = %v", err) + } + }) + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == dest { + return "", errors.New("physical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical failure = %v", err) + } + }) + t.Run("canonical-target", func(t *testing.T) { + target := filepath.Join(canonical, "dingtalk-chat") + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == target { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical failure = %v", err) + } + }) + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + makeSource := func(t *testing.T) string { + t.Helper() + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + return src + } + return writeMultiBundle(t, src, "dingtalk-chat") + } + + t.Run("undetected-and-physical-alias", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped { + t.Fatalf("skip branches = %#v", result.Results) + } + }) + + t.Run("universal-retire-failure", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if mode == "multi" { + useUpgradeManagedNames(t, "dingtalk-chat") + } + origRename := upgradeRename + testseam.Swap(t, &upgradeRename, func(src, dst string) error { + if src == victim { + return errors.New("retire denied") + } + return origRename(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("retire failure = %#v, %v", result, err) + } + }) + + t.Run("universal-retire-success", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + useUpgradeManagedNames(t, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("retire success = %#v, %v", result, err) + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Fatalf("universal victim remains: %v", err) + } + }) + + t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) { + t.Run("victim-scan", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + origReadDir := upgradeReadDir + testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) { + if path == base { + return nil, errors.New("scan denied") + } + return origReadDir(path) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("victim scan = %#v, %v", result, err) + } + }) + + t.Run("fallback-copy", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") }) + origCopy := upgradeCopyDir + testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { + if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) { + return errors.New("copy denied") + } + return origCopy(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("fallback copy = %#v, %v", result, err) + } + }) + }) + }) + } +} + +func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) { + testseam.Swap(t, &upgradeFoldPathCase, true) + if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) { + t.Fatalf("case-folded path key = %q", got) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"}) + if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 { + t.Fatalf("deduplicated roots = %#v", roots) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 9794f290..061d7d68 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { }) home := t.TempDir() upgradeUserHomeDir = func() (string, error) { return home, nil } - knownSkillDirs = []string{".agents/skills"} + knownSkillDirs = []string{".agents/skills", ".claude/skills"} + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } calls := 0 upgradeCopyDir = func(src, dst string) error { calls++ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 12d89118..c922a428 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -42,22 +42,78 @@ const ( // copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", + // DWS-only target retained for product compatibility. ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only, never publish new copies. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } // universalSkillDirs are concrete Agent directories whose current clients @@ -66,12 +122,21 @@ var knownSkillDirs = []string{ // with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally // non-universal because it is a DWS-specific integration absent upstream. var universalSkillDirs = map[string]bool{ - ".cursor/skills": true, - ".gemini/skills": true, - ".codex/skills": true, - ".github/skills": true, - ".cline/skills": true, - ".amp/skills": true, + ".config/agents/skills": true, + ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, + ".codex/skills": true, + ".cursor/skills": true, + ".deepagents/agent/skills": true, + ".firebender/skills": true, + ".gemini/skills": true, + ".copilot/skills": true, + ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only as well. + ".github/skills": true, + ".windsurf/skills": true, + ".cline/skills": true, + ".amp/skills": true, } var ( @@ -87,11 +152,14 @@ var ( upgradeStat = os.Stat upgradeLstat = os.Lstat upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv upgradeBuildProvenance = skillprovenance.Build upgradeReadSkillState = skillstate.Read upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } upgradeWriteSkillState = skillstate.Write upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -102,21 +170,19 @@ const skillBackupSubdir = ".dws/skill-backups" // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it, and returns the backup path. It is -// fail-safe: a directory that cannot be backed up is NOT removed and the +// fail-safe: a path that cannot be backed up is NOT removed and the // error is returned so the caller can surface it (and never install the -// opposite layout next to it silently). Missing paths and regular files are -// no-ops ("", nil). +// opposite layout next to it silently). Missing paths are no-ops. Directories, +// links (including dangling links), and ordinary files are all preserved: +// an unexpected file at a managed destination must never be overwritten. func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeLstat(dir) + _, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { - return "", nil - } rel, relErr := filepath.Rel(homeDir, dir) if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { rel = filepath.Base(dir) @@ -544,8 +610,87 @@ func isUniversalSkillRoot(agentDir string) bool { return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] } -func skillRootDetected(homeDir, agentDir string) bool { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) +type resolvedSkillRoot struct { + base string + label string + universal bool + canonical bool +} + +func resolveOpenClawSkillBase(homeDir string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(homeDir, name) + if info, err := upgradeStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(homeDir, ".openclaw", "skills") +} + +func configuredSkillRoots(homeDir string) []resolvedSkillRoot { + var roots []resolvedSkillRoot + seen := map[string]bool{} + configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(homeDir, ".config") + } + add := func(base, label string, universal, canonical bool) { + key := skillRootPathKey(base) + if seen[key] { + return + } + seen[key] = true + roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical}) + } + for _, agentDir := range knownSkillDirs { + base := filepath.Join(homeDir, agentDir) + switch filepath.ToSlash(filepath.Clean(agentDir)) { + case ".claude/skills": + if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".codex/skills": + if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".hermes/skills": + if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".autohand/skills": + if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".grok/skills": + if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".vibe/skills": + if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".openclaw/skills": + base = resolveOpenClawSkillBase(homeDir) + case ".config/agents/skills": + base = filepath.Join(configHome, "agents", "skills") + case ".config/opencode/skills": + base = filepath.Join(configHome, "opencode", "skills") + case ".config/crush/skills": + base = filepath.Join(configHome, "crush", "skills") + case ".config/devin/skills": + base = filepath.Join(configHome, "devin", "skills") + case ".config/goose/skills": + base = filepath.Join(configHome, "goose", "skills") + case ".config/kimchi/harness/skills": + base = filepath.Join(configHome, "kimchi", "harness", "skills") + } + add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir)) + } + return roots +} + +func skillRootDetectedBase(base string) bool { + parentGate := filepath.Dir(base) info, err := upgradeStat(parentGate) return err == nil && info.IsDir() } @@ -553,7 +698,30 @@ func skillRootDetected(homeDir, agentDir string) bool { func samePhysicalSkillRoot(left, right string) bool { leftReal, leftErr := upgradeEvalSymlinks(left) rightReal, rightErr := upgradeEvalSymlinks(right) - return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) + if leftErr != nil || rightErr != nil { + return false + } + return skillRootPathKey(leftReal) == skillRootPathKey(rightReal) +} + +func skillRootPathKey(path string) string { + clean := filepath.Clean(path) + if upgradeFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + +func hasDependentSkillRoot(homeDir, canonicalBase string) bool { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) { + continue + } + if !samePhysicalSkillRoot(root.base, canonicalBase) { + return true + } + } + return false } func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { @@ -581,9 +749,17 @@ func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) _ = upgradeRemoveAll(stageRoot) } }() + realDestBase, realErr := upgradeEvalSymlinks(destBase) + if realErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr) + } for _, name := range names { target := filepath.Join(canonicalBase, name) - relTarget, relErr := filepath.Rel(destBase, target) + realTarget, targetErr := upgradeEvalSymlinks(target) + if targetErr != nil { + return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr) + } + relTarget, relErr := upgradeRel(realDestBase, realTarget) if relErr != nil { return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) } @@ -631,21 +807,24 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e canonicalDest := filepath.Join(canonicalBase, "dws") if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + if hasDependentSkillRoot(homeDir, canonicalBase) { + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + return result, nil } result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) + destBase := root.base destDir := filepath.Join(destBase, "dws") - if isBlacklisted(agentDir) { + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -653,7 +832,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) } else { @@ -701,20 +880,23 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk canonicalBase := filepath.Join(homeDir, ".agents", "skills") if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + if hasDependentSkillRoot(homeDir, canonicalBase) { + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + return result, nil } result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) - if isBlacklisted(agentDir) { + destBase := root.base + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } @@ -722,7 +904,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) } else { diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e9abb256..e238b0ca 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -503,6 +503,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) { // mono-only package path). func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { t.Fatal(err) @@ -598,6 +601,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * // installing multi next to the stale skills. func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") staleDir := filepath.Join(agentsBase, "dingtalk-stale") if err := os.MkdirAll(staleDir, 0o755); err != nil { @@ -639,7 +645,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe -// contract of the backup helper: non-dir paths are no-ops, a colliding backup +// contract of the backup helper: all lexical paths are preserved, a colliding backup // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable // collision) leaves the original directory untouched with an error. func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { @@ -660,16 +666,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err) } - // Regular file: no-op, no backup. + // Regular file: preserve it instead of allowing a later rename to overwrite it. filePath := filepath.Join(home, "not-a-dir") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatal(err) } - if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { - t.Fatalf("regular file = (%q, %v), want no-op", got, err) + if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil { + t.Fatalf("regular file = (%q, %v), want backup", got, err) } - if _, err := os.Stat(filePath); err != nil { - t.Fatalf("regular file must survive: %v", err) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("regular file must move into backup: %v", err) } // A skill directory outside home falls back to its basename instead of diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 894e4f5e..c5fdb3b4 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -22,9 +22,91 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat $NoSkills = $env:DWS_NO_SKILLS -eq "1" $SkillName = "dingtalk-misc" +# vercel-labs/skills agents.ts (c6f69c6). Each row is +# id|universal|effective global directory; '-' means no global directory. +$AgentRegistryRows = @( + "aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills", + "antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills", + "astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills", + "bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills", + "cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills", + "codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills", + "command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills", + "crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills", + "devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-", + "firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills", + "github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills", + "hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills", + "junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills", + "kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills", + "kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills", + "minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills", + "mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills", + "ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills", + "qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills", + "rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills", + "terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills", + "trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills", + "zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills", + "zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills", + "promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills" +) +$LegacyAgentCleanupRows = @( + "dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills", + "dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills", + "dws-legacy-windsurf|1|.windsurf\skills" +) + function Say($m) { Write-Host " $m" } function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } +function Resolve-AgentBase([string]$row) { + $parts = $row.Split('|') + $id = $parts[0] + if ($parts[2] -eq "-") { return $null } + switch ($id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } } + "codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } } + "grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } } + "hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } } + "mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } } + "openclaw" { + foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $HOME $legacy + if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" } + } + } + { $_ -in @("amp", "replit", "universal") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + return Join-Path $xdg "agents\skills" + } + { $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + $child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } } + return Join-Path $xdg $child + } + } + return Join-Path $HOME $parts[2] +} + +function Test-PathLexically([string]$path) { + try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} + $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf + if (!(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) +} + +function Backup-DevSkill([string]$path) { + if (!(Test-PathLexically $path)) { return } + $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" + New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null + $target = Join-Path $backupRoot (Split-Path $path -Leaf) + while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } + Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop +} + function Get-Arch { if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } try { @@ -99,22 +181,27 @@ if (-not $NoSkills) { New-Item -ItemType Directory -Path $cache -Force | Out-Null Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force - $agentDirs = @( - ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", - ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", - ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", - ".hermes\skills", ".config\opencode\skills" - ) - $installed = 0; $idx = 0 - foreach ($d in $agentDirs) { - $base = Join-Path $HOME $d - $parent = Split-Path $base -Parent - if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $canonicalBase = Join-Path $HOME ".agents\skills" + $canonical = Join-Path $canonicalBase $SkillName + Backup-DevSkill $canonical + New-Item -ItemType Directory -Path $canonical -Force | Out-Null + Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + $installed = 1 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { + $parts = $row.Split('|'); $universal = $parts[1] -eq "1" + $base = Resolve-AgentBase $row + if ([string]::IsNullOrWhiteSpace($base)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) + if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } + if (-not (Test-Path (Split-Path $base -Parent))) { continue } $dest = Join-Path $base $SkillName - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - New-Item -ItemType Directory -Path $dest -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force - $installed++; $idx++ + if ($universal) { Backup-DevSkill $dest; continue } + New-Item -ItemType Directory -Path $base -Force | Out-Null + Backup-DevSkill $dest + try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null } + catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force } + $installed++ } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 1e7c5d7f..4a360638 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,80 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +copy_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage" "$dest"; then return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 + while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"; done + mkdir -p "$backup_root" || return 1 + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + need_cmd curl detect_os() { @@ -83,17 +157,16 @@ install_skill() { cache="$HOME/.dws/skills/multi/$SKILL_NAME" rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" - installed=0; idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi - dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" - installed=$((installed + 1)); idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$SKILL_NAME" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 + installed=$((installed + 1)) done say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 3402a333..a45870e7 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -92,37 +92,98 @@ resolve_event_version() { copy_tree() { src="$1" dest="$2" - rm -rf "$dest" - mkdir -p "$dest" - cp -R "$src/." "$dest/" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage" "$dest"; then return 0; fi + rm -rf "$stage" + [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp" + backup="$backup_root/$name" + i=0 + while [ -e "$backup" ] || [ -L "$backup" ]; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + done + mkdir -p "$backup_root" || return 1 + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + return 1 } install_skill_to_homes() { src="$1" skill_name="$2" - - installed=0 - idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then - idx=$((idx + 1)) - continue - fi - copy_tree "$src" "$base/$skill_name" + canonical="$HOME/.agents/skills/$skill_name" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 installed=$((installed + 1)) - idx=$((idx + 1)) done - - if [ "$installed" -eq 0 ]; then - copy_tree "$src" "$HOME/.agents/skills/$skill_name" - installed=1 - fi printf '%s\n' "$installed" } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 97a4b093..563582ec 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -53,13 +53,13 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" _bed_target="$_bed_root/$_bed_stamp/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then @@ -206,7 +206,7 @@ restore_multi_skill_set() { if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi @@ -413,11 +413,62 @@ multi_tree_has_skills() { # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in - ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; *) return 1 ;; esac } +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +agent_skill_dirs() { + printf '%s\n' \ + ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ + ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ + ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ + ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ + ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ + ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ + ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ + ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ + ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ + ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ + ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ + ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ + ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ + ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ + ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ + ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ + ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -432,7 +483,7 @@ retire_agent_skill_root() { _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -450,6 +501,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } @@ -466,11 +518,13 @@ link_canonical_skills_to_base() { _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi - ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null && pwd)" || return 1 @@ -667,7 +722,7 @@ retire_agent_skill_root() { _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -687,6 +742,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" _lcs_published="$_lcs_stage/.published" @@ -706,11 +762,13 @@ link_canonical_skills_to_base() { if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue fi - ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -111,11 +153,41 @@ trap cleanup EXIT INT TERM seed_specific_agent_homes() { home_root="$1" - for parent in $HOME_SPECIFIC_AGENT_PARENTS; do - mkdir -p "$home_root/$parent" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + mkdir -p "${base%/skills}" done } +resolve_agent_base() { + home_root="$1"; id="$2"; default_path="$3" + case "$id" in + autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;; + claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;; + codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;; + grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;; + hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;; + mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;; + amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;; + crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;; + openclaw) + for alias in .openclaw .clawdbot .moltbot; do + if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi + done + printf '%s\n' "$home_root/.openclaw/skills" + ;; + *) printf '%s\n' "$home_root/$default_path" ;; + esac +} + verify_skill_base() { home_root="$1" base="$2" @@ -124,30 +196,50 @@ verify_skill_base() { [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" } +verify_compatible_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + target="$base/$name" + if [ -L "$target" ]; then + linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" + canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" + [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" + elif [ -d "$target" ]; then + diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target" + else + err "canonical Skill compatibility target missing at $target" + fi + done + [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" +} + verify_skill_targets() { home_root="$1" # lark-cli/skills-compatible canonical store is always present, regardless # of which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" - for base in $HOME_SPECIFIC_SKILL_BASES; do - parent="${base%/skills}" - [ -d "$home_root/$parent" ] || continue - universal=0 - for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do - [ "$base" = "$universal_base" ] && universal=1 - done + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + [ "$base" != "$home_root/.agents/skills" ] || continue + [ -d "${base%/skills}" ] || continue if [ "$universal" -eq 1 ]; then for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/$base/$name" ] || \ - err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" done continue fi - verify_skill_base "$home_root" "$base" - for name in dingtalk-shared dingtalk-misc; do - [ -L "$home_root/$base/$name" ] || \ - err "expected canonical Skill link not found at $home_root/$base/$name" - done + verify_compatible_skill_base "$home_root" "$base" done } @@ -189,6 +281,7 @@ verify_npm() { need_cmd node need_cmd tar need_cmd unzip + need_cmd diff need_file "$NPM_STAGE_DIR/package.json" tarball_name="$( diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go new file mode 100644 index 00000000..35a647b0 --- /dev/null +++ b/test/scripts/agent_registry_test.go @@ -0,0 +1,258 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +type packagedAgent struct { + universal bool + path string +} + +var upstreamAgentIDs = strings.Fields(` +aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob +claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex +command-code continue cortex crush cursor deepagents devin dexto droid eve +firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh +jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam +minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn +qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn +warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal +`) + +func TestAgentRegistriesMatchUpstream76(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1")) + verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh")) + + wantIDs := append([]string(nil), upstreamAgentIDs...) + sort.Strings(wantIDs) + registries := map[string]map[string]packagedAgent{ + "install.ps1": parseInstallPowerShellRegistry(t, installPS), + "install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")), + "verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")), + } + + for name, registry := range registries { + gotIDs := make([]string, 0, len(registry)) + universal := 0 + for id, agent := range registry { + gotIDs = append(gotIDs, id) + if agent.universal { + universal++ + } + } + sort.Strings(gotIDs) + if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") { + t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs) + } + if universal != 19 || len(registry)-universal != 57 { + t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal) + } + } + + canonical := registries["install.ps1"] + for name, got := range registries { + for id, want := range canonical { + if got[id] != want { + t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want) + } + } + } + + for id, wantPath := range map[string]string{ + "amp": ".config/agents/skills", + "github-copilot": ".copilot/skills", + "opencode": ".config/opencode/skills", + "windsurf": ".codeium/windsurf/skills", + "cline": ".agents/skills", + "eve": "-", + "promptscript": "-", + } { + if got := canonical[id].path; got != wantPath { + t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath) + } + } +} + +func TestDWSCompatibilityAgentTargets(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as") + if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) { + t.Error("DWS Qoderwork compatibility target must remain non-universal") + } + for _, id := range []string{ + "dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf", + } { + if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) { + t.Errorf("legacy cleanup registry missing cleanup-only target %s", id) + } + } + for _, id := range upstreamAgentIDs { + if strings.HasPrefix(id, "dws-") { + t.Fatalf("DWS compatibility target %s leaked into upstream registry", id) + } + } +} + +func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) { + t.Parallel() + installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1")) + for _, want := range []string{ + `$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`, + `New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`, + `Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`, + `Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`, + } { + if !strings.Contains(installPS, want) { + t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want) + } + } +} + +func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + cut := strings.LastIndex(installPS, "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + home := t.TempDir() + custom := filepath.Join(t.TempDir(), "custom roots") + xdg := filepath.Join(custom, "xdg") + clawdbot := filepath.Join(home, ".clawdbot") + if err := os.MkdirAll(clawdbot, 0o755); err != nil { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode") +foreach ($id in $ids) { + $agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent)) +} +exit 0 +` + harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1") + mustWriteFile(t, harness, []byte(prefix), 0o644) + + envRoots := map[string]string{ + "AUTOHAND_HOME": filepath.Join(custom, "autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"), + "CODEX_HOME": filepath.Join(custom, "codex"), + "GROK_HOME": filepath.Join(custom, "grok"), + "HERMES_HOME": filepath.Join(custom, "hermes"), + "VIBE_HOME": filepath.Join(custom, "vibe"), + "XDG_CONFIG_HOME": xdg, + } + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + for key, value := range envRoots { + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output) + } + want := map[string]string{ + "amp": filepath.Join(xdg, "agents", "skills"), + "autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(envRoots["CODEX_HOME"], "skills"), + "grok": filepath.Join(envRoots["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"), + "openclaw": filepath.Join(clawdbot, "skills"), + "goose": filepath.Join(xdg, "goose", "skills"), + "kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"), + "opencode": filepath.Join(xdg, "opencode", "skills"), + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "|", 2) + if len(parts) == 2 { + got[parts[0]] = filepath.Clean(parts[1]) + } + } + for id, path := range want { + if got[id] != filepath.Clean(path) { + t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output) + } + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(b) +} + +func between(t *testing.T, text, start, end string) string { + t.Helper() + i := strings.Index(text, start) + if i < 0 { + t.Fatalf("start marker %q missing", start) + } + i += len(start) + j := strings.Index(text[i:], end) + if j < 0 { + t.Fatalf("end marker %q missing", end) + } + return text[i : i+j] +} + +func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent { + t.Helper() + block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets") + re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if path == "" { + path = "-" + } + result[match[1]] = packagedAgent{universal: match[2] == "true", path: path} + } + return result +} + +func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent { + t.Helper() + re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if _, exists := result[match[1]]; exists { + t.Fatalf("duplicate agent %q in pipe registry", match[1]) + } + result[match[1]] = packagedAgent{universal: match[2] == "1", path: path} + } + return result +} diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 29be067a..945a8e61 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -62,10 +62,20 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), const installJsSource = path.join(repoRoot, "build", "npm", "install.js"); const require = createRequire(import.meta.url); const { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = require(installJsSource); + +assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned"); +assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); +assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -133,8 +143,8 @@ function stagePkg(zipEntries, emptyDirs = []) { return { tmp, pkg, home: path.join(tmp, "home") }; } -function runInstall(pkg, home, skillMode) { - const env = { ...process.env, HOME: home }; +function runInstall(pkg, home, skillMode, extraEnv = {}) { + const env = { ...process.env, HOME: home, ...extraEnv }; if (skillMode !== undefined) { env.DWS_SKILL_MODE = skillMode; } else { @@ -255,6 +265,52 @@ scenario("ZCode links its Agent root to the canonical store", () => { } }); +scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const xdg = path.join(tmp, "xdg config"); + const autohand = path.join(tmp, "autohand home"); + const codex = path.join(tmp, "codex home"); + try { + writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); + writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); + writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); + writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(codex, "config.toml"), "model=test\n"); + writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); + writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: xdg, + AUTOHAND_HOME: autohand, + CODEX_HOME: codex, + }); + assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); + + for (const linked of [ + path.join(xdg, "goose", "skills", "dingtalk-chat"), + path.join(autohand, "skills", "dingtalk-chat"), + path.join(home, ".qoderwork", "skills", "dingtalk-chat"), + ]) { + assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); + assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + } + for (const retired of [ + path.join(xdg, "agents", "skills", "dingtalk-chat"), + path.join(codex, "skills", "dingtalk-chat"), + path.join(home, ".amp", "skills", "dingtalk-chat"), + ]) { + assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => { const { tmp, pkg, home } = stagePkg({ "SKILL.md": "# mono root copy\n", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index ac685fb5..5ab08442 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -381,17 +381,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { } expectedSkills := map[string]string{ - ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", - ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", - ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", + ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for _, duplicate := range []string{ + filepath.Join(fakeHome, ".codex", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".config", "opencode", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".codex", "skills", "dws"), + } { + if _, err := os.Lstat(duplicate); !os.IsNotExist(err) { + t.Fatalf("universal Agent duplicate remains at %s: %v", duplicate, err) + } } for rel, marker := range expectedSkills { p := filepath.Join(fakeHome, filepath.FromSlash(rel)) @@ -1343,6 +1349,147 @@ func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { } } +func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + tmp := t.TempDir() + xdg := filepath.Join(tmp, "xdg config") + autohand := filepath.Join(tmp, "autohand home") + codex := filepath.Join(tmp, "codex home") + source := filepath.Join(tmp, "multi") + mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "goose", "config.yaml"), []byte("enabled: true\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("old amp copy\n"), 0o644) + mustWriteFile(t, filepath.Join(autohand, "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "config.toml"), []byte("model=test\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "skills", "dingtalk-chat", "SKILL.md"), []byte("old codex copy\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".qoderwork", "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), []byte("old DWS path\n"), 0o644) + + installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"` + if scriptName == "install-skills.sh" { + installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"` + } + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +`+installCall+` +`) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "XDG_CONFIG_HOME="+xdg, + "AUTOHAND_HOME="+autohand, + "CODEX_HOME="+codex, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s upstream-root harness failed: %v\n%s", scriptName, err, output) + } + + for _, linked := range []string{ + filepath.Join(xdg, "goose", "skills", "dingtalk-chat"), + filepath.Join(autohand, "skills", "dingtalk-chat"), + filepath.Join(home, ".qoderwork", "skills", "dingtalk-chat"), + } { + info, err := os.Lstat(linked) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a canonical symlink: %#v, %v", linked, info, err) + } + target, err := os.Readlink(linked) + if err != nil || filepath.IsAbs(target) { + t.Fatalf("%s link must be relative: target=%q err=%v", linked, target, err) + } + } + for _, retired := range []string{ + filepath.Join(xdg, "agents", "skills", "dingtalk-chat"), + filepath.Join(codex, "skills", "dingtalk-chat"), + filepath.Join(home, ".amp", "skills", "dingtalk-chat"), + } { + if _, err := os.Lstat(retired); !os.IsNotExist(err) { + t.Fatalf("%s universal/legacy duplicate remains: %v", retired, err) + } + } + }) + } +} + +func TestInstallerShellPinsCompleteUpstreamAgentRegistry(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +upstream_agent_registry +`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s registry failed: %v\n%s", scriptName, err, output) + } + lines := strings.Fields(strings.TrimSpace(string(output))) + if len(lines) != 76 { + t.Fatalf("%s registry has %d Agents, want 76", scriptName, len(lines)) + } + ids := map[string]bool{} + roots := map[string]bool{} + universal, nonUniversal, noGlobal, canonicalDirect := 0, 0, 0, 0 + for _, line := range lines { + parts := strings.Split(line, "|") + if len(parts) != 3 || ids[parts[0]] { + t.Fatalf("%s invalid or duplicate registry record %q", scriptName, line) + } + ids[parts[0]] = true + switch parts[1] { + case "U": + universal++ + case "N": + nonUniversal++ + default: + t.Fatalf("%s invalid classification in %q", scriptName, line) + } + switch parts[2] { + case "-": + noGlobal++ + case ".agents/skills": + canonicalDirect++ + default: + roots[parts[2]] = true + } + } + if universal != 19 || nonUniversal != 57 || noGlobal != 2 || canonicalDirect != 6 || len(roots) != 65 { + t.Fatalf("%s registry classification U=%d N=%d no-global=%d canonical=%d roots=%d", scriptName, universal, nonUniversal, noGlobal, canonicalDirect, len(roots)) + } + }) + } +} + func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index fd3746ba..cc43e8cf 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{ ".augment/skills/dingtalk-shared", ".cline/skills/dingtalk-shared", ".amp/skills/dingtalk-shared", + ".copilot/skills/dingtalk-shared", + ".config/opencode/skills/dingtalk-shared", + ".config/agents/skills/dingtalk-shared", + ".codeium/windsurf/skills/dingtalk-shared", ".kiro/skills/dingtalk-shared", ".trae/skills/dingtalk-shared", ".openclaw/skills/dingtalk-shared", @@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") } for _, want := range []string{ - "HOME_SPECIFIC_SKILL_BASES=", - `$base/dingtalk-shared/SKILL.md`, - `$base/dingtalk-misc/SKILL.md`, + "UPSTREAM_AGENT_REGISTRY=", + "verify_compatible_skill_base", + `diff -qr "$canonical/$name" "$target"`, + "broken canonical Skill link", + "Skill link does not resolve to canonical source", + "Skill copy fallback differs from canonical source", "unexpected mono Skill layout", `verify_npm_install "$tarball_path" "specific-agent-roots"`, `verify_npm_install "$tarball_path" "generic-fallback"`, @@ -140,6 +147,100 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } } +func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + text := string(data) + cut := strings.Index(text, "\nverify_npm_install()") + if cut < 0 { + t.Fatal("verify-package-managers.sh helper boundary missing") + } + library := filepath.Join(t.TempDir(), "verifier-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + for _, tc := range []struct { + name string + prepare func(t *testing.T, home, base string) + wantOK bool + }{ + { + name: "correct-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: true, + }, + { + name: "complete-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + }, + wantOK: true, + }, + { + name: "wrong-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + wrong := filepath.Join(home, "wrong", "dingtalk-shared") + mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "broken-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "different-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644) + }, + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".claude", "skills") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + tc.prepare(t, home, base) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base) + output, err := cmd.CombinedOutput() + if tc.wantOK && err != nil { + t.Fatalf("verifier rejected valid target: %v\n%s", err, output) + } + if !tc.wantOK && err == nil { + t.Fatalf("verifier accepted invalid target\n%s", output) + } + }) + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) From 0af5751d75d85706b852eac48cb227b11417241c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 21:22:57 +0800 Subject: [PATCH 053/267] fix(skills): harden canonical agent installation --- README.md | 4 +- build/npm/install.js | 20 ++- docs/rfc-skill-installation-and-upgrade.md | 9 +- internal/app/skill_command.go | 25 +++- internal/app/skill_command_test.go | 35 ++++- internal/app/skill_setup.go | 122 +++++++++++++++--- internal/app/skill_setup_canonical_test.go | 36 ++++++ ...ll_setup_event_migration_universal_test.go | 87 +++++++++++++ .../app/skill_setup_full_coverage_test.go | 20 ++- internal/upgrade/canonical_skills_test.go | 42 ++++++ internal/upgrade/paths.go | 35 ++++- internal/upgrade/paths_multi_test.go | 10 ++ scripts/install-devapp.ps1 | 13 +- scripts/install-devapp.sh | 12 +- scripts/install-event.sh | 12 +- scripts/install-skills.sh | 20 ++- scripts/install.ps1 | 18 ++- scripts/install.sh | 20 ++- scripts/release/verify-package-managers.sh | 34 ++++- test/scripts/agent_registry_test.go | 21 +++ test/scripts/install_js_smoke.mjs | 42 +++++- test/scripts/package_script_test.go | 9 +- 22 files changed, 571 insertions(+), 75 deletions(-) create mode 100644 internal/app/skill_setup_event_migration_universal_test.go diff --git a/README.md b/README.md index 132ebf9d..f02ba088 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index e43d29fc..f091b4a2 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -109,6 +109,21 @@ function resolvedAgentTargets(homeDir) { }); } +function agentTargetDetected(target) { + const parent = path.dirname(target.baseDir); + switch (target.id) { + case "kimchi": + case "tabnine-cli": + return fs.existsSync(path.dirname(parent)); + case "zcode": + return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app"); + case "minimax-code": + return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app"); + default: + return fs.existsSync(parent); + } +} + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -347,7 +362,7 @@ function installSkillsToHomes(skillRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -819,7 +834,7 @@ function installMultiSkillsToHomes(multiRoot) { if (installed > 0) { for (const target of resolvedAgentTargets(homeDir)) { const { agentDir, baseDir, universal } = target; - if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { try { @@ -979,6 +994,7 @@ if (require.main === module) { module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index bad9f3f9..0f530c5d 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -140,21 +140,24 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的 -Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的 +升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 +universal 的 Agent 不再保留 Agent 私有副本;检测到的 非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical 的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 `GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 `.moltbot` 必须按 Agent 实际优先级解析。 -Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交 `c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent 解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 `.amp/skills`、`.cline/skills` 与 `.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 +对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过 +Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native +路径,不作为 universal symlink 模式的发布目标。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index e43484f6..e0087209 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -258,11 +258,28 @@ func formatAgentSkillPathsForHelp() string { sort.Strings(names) var b strings.Builder for _, n := range names { - fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + installPath := agentSkillPaths[n] + if isUniversalSkillInstallTarget(n) { + installPath = ".agents/skills" + } + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath) } return b.String() } +// Universal Agents discover the shared ~/.agents/skills store directly. A +// marketplace install addressed to one of those Agent IDs must therefore +// publish to canonical instead of recreating an Agent-private duplicate. +func isUniversalSkillInstallTarget(target string) bool { + rel, ok := agentSkillPaths[target] + if !ok { + return false + } + base := filepath.Join("__home__", rel) + canonical := filepath.Join("__home__", ".agents", "skills") + return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base) +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -577,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return resolveSkillSetupBase(homeDir, target), nil + destination := resolveSkillSetupBase(homeDir, target) + if isUniversalSkillInstallTarget(target) { + destination = filepath.Join(homeDir, ".agents", "skills") + } + return destination, nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 4fcdb38d..20288bae 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -28,6 +28,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestResolveSkillTargetPath(t *testing.T) { @@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) { { name: "cursor target", target: "cursor", - wantSuffix: filepath.Join(".cursor", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "codex target", target: "codex", - wantSuffix: filepath.Join(".codex", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "opencode target", target: "opencode", - wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { @@ -118,6 +119,34 @@ func TestResolveSkillTargetPath(t *testing.T) { } } +func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + for _, target := range []string{ + "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", + "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", + "github-copilot", "kimi-code-cli", "loaf", "opencode", "replit", + "universal", "warp", "zed", + } { + got, err := resolveSkillTargetPath(target) + if err != nil { + t.Fatalf("resolve %s: %v", target, err) + } + if want := filepath.Join(home, ".agents", "skills"); got != want { + t.Errorf("resolve %s = %s, want canonical %s", target, got, want) + } + } + for target, want := range map[string]string{ + "claude": filepath.Join(home, ".claude", "skills"), + "qoder": filepath.Join(home, ".qoder", "skills"), + "zcode": filepath.Join(home, ".zcode", "skills"), + } { + if got, err := resolveSkillTargetPath(target); err != nil || got != want { + t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want) + } + } +} + func TestResolveSkillTargetPathCurrentDir(t *testing.T) { // Test "." target returns current working directory cwd, err := os.Getwd() diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 94c8cbf5..ef728093 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,6 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir + skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupMkdirAll = os.MkdirAll @@ -379,6 +380,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 { + retiredNames := append([]string(nil), multiSkillNames...) + if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) { + retiredNames = append(retiredNames, multiMiscSkill) + } + if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { + return retireErr + } + } if skipped > 0 { return fmt.Errorf( "Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill", @@ -1086,9 +1096,32 @@ func detectExistingAgentHomes(home, mode string) []string { dests := []string{canonical} canonicalKey := skillSetupPathKey(canonical) seen := map[string]bool{canonicalKey: true} - addDetected := func(base string) { - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { + systemHome, _ := skillSetupSystemHomeDir() + allowSystemApps := sameSkillSetupPath(home, systemHome) + addDetected := func(rel, base string) { + detectedDir := filepath.Dir(base) + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".config/kimchi/harness/skills", ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + } + if detectedDir != "" { + if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() { + return + } + } + if base == "" { return } dest := agentHomeForMode(base, mode) @@ -1131,10 +1164,10 @@ func detectExistingAgentHomes(home, mode string) []string { case ".config/kimchi/harness/skills": base = resolveSkillSetupBase(home, "kimchi") } - addDetected(base) + addDetected(rel, base) } for _, target := range []string{"github-copilot", "windsurf"} { - addDetected(resolveSkillSetupBase(home, target)) + addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target)) } return dests } @@ -1394,6 +1427,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins } } +func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error { + home, err := skillSetupUserHomeDir() + if err != nil { + return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err) + } + seen := map[string]bool{} + var victims []skillSetupBackup + for _, target := range targets { + if !isUniversalSkillSetupBase(target) { + continue + } + for _, name := range names { + path := filepath.Join(target, name) + if seen[path] { + continue + } + seen[path] = true + victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + } + sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path }) + if _, err := backupSkillSetupTarget(home, victims, out); err != nil { + return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err) + } + return nil +} + func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source) if plan.Mode == skillSetupModeMulti { @@ -1580,8 +1640,12 @@ func installMultiSkillsWithEventMigration( } migrationSet := make(map[string]struct{}, len(migrationTargets)) + physicalMigrationTargets := make([]string, 0, len(migrationTargets)) for _, dest := range migrationTargets { migrationSet[dest] = struct{}{} + if !isUniversalSkillSetupBase(dest) { + physicalMigrationTargets = append(physicalMigrationTargets, dest) + } } var ordinaryTargets []string for _, dest := range dests { @@ -1590,23 +1654,47 @@ func installMultiSkillsWithEventMigration( } } - if len(ordinaryTargets) > 0 { + var canonicalTargets, otherOrdinaryTargets []string + for _, dest := range ordinaryTargets { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti))) + if strings.HasSuffix(base, "/.agents/skills") { + canonicalTargets = append(canonicalTargets, dest) + } else { + otherOrdinaryTargets = append(otherOrdinaryTargets, dest) + } + } + installOrdinary := func(names, targets []string) error { + if len(targets) == 0 { + return nil + } var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered) + n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered) installed += n skipped += nSkipped if err != nil { - return installed, skipped, err + return err } if nSkipped > 0 { - return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) + return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) } + return nil + } + canonicalNames := append([]string(nil), skillNames...) + if !containsSkillName(canonicalNames, multiMiscSkill) { + canonicalNames = append(canonicalNames, multiMiscSkill) + sort.Strings(canonicalNames) + } + if err := installOrdinary(canonicalNames, canonicalTargets); err != nil { + return installed, skipped, err + } + if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil { + return installed, skipped, err } // The folded pair is excluded from the ordinary best-effort installer. All // other selected skills (especially dingtalk-shared) must succeed before the // old Event route is touched. - for _, dest := range migrationTargets { + for _, dest := range physicalMigrationTargets { if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil { return installed, skipped + len(skillNames), cleanupErr } @@ -1617,9 +1705,9 @@ func installMultiSkillsWithEventMigration( prerequisiteNames = append(prerequisiteNames, name) } } - if len(prerequisiteNames) > 0 { + if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 { var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true) + n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true) installed += n skipped += nSkipped if err != nil { @@ -1630,7 +1718,7 @@ func installMultiSkillsWithEventMigration( } } - migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut) + migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut) installed += migrated if migrationErr != nil { return installed, skipped, migrationErr @@ -2025,10 +2113,6 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() - perTarget := 1 - if plan.Mode == skillSetupModeMulti { - perTarget = len(plan.MultiSkillNames) - } hasCanonicalDependents := false for _, candidate := range plan.Targets { if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { @@ -2037,6 +2121,10 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } } for _, target := range plan.Targets { + perTarget := 1 + if plan.Mode == skillSetupModeMulti { + perTarget = len(plan.MultiSkillNames) + } isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 85f823a3..45f9aff4 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -100,6 +100,42 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * } } +func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := skillSetupStat + testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + dests := detectExistingAgentHomes(home, skillSetupModeMulti) + for _, target := range []string{ + filepath.Join(home, ".config", "kimchi", "harness", "skills"), + filepath.Join(home, ".tabnine", "agent", "skills"), + filepath.Join(home, ".zcode", "skills"), + filepath.Join(home, ".minimax", "skills"), + } { + if !containsSkillName(dests, target) { + t.Errorf("detected targets %v missing %s", dests, target) + } + } +} + func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { home := t.TempDir() customClaude := filepath.Join(t.TempDir(), "claude") diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go new file mode 100644 index 00000000..7800bb74 --- /dev/null +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies +// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal +// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the +// Event/misc migration must not leave physical duplicates there. Universal +// homes read ~/.agents/skills directly, so their old folded copies are retired +// (backed up) and the split standalone event + clean misc land in canonical. +func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + // beta.6 physical copies alongside the folded misc. + for _, name := range []string{multiEventSkill, multiSharedSkill} { + if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + canonical := filepath.Join(home, ".agents", "skills") + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + + // P0: the universal home must not retain any physical dingtalk-* copy. + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) { + t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err) + } + } + // canonical owns the new standalone event + clean misc (+ shared). + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical missing %s: %v", name, err) + } + } + if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil { + t.Fatalf("canonical misc still contains folded Event content: %v", err) + } + if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil { + t.Fatalf("idempotent rerun failed: %v", err) + } +} + +func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + canonical := filepath.Join(home, ".agents", "skills") + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + writeOldStandaloneEvent(t, codexSkills) + chat := filepath.Join(codexSkills, "dingtalk-chat") + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil { + t.Fatal(err) + } + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"}) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes") + if err != nil { + t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if strings.Contains(stdout, "Event 原子迁移") { + t.Fatalf("unrelated selection migrated Event/misc: %s", stdout) + } + assertOldEventMiscPair(t, codexSkills) + if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" { + t.Fatalf("unselected chat changed: body=%q err=%v", body, err) + } +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index 02da441f..b5e38e9c 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -523,15 +523,25 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) - t.Run("ordinary and prerequisite install errors", func(t *testing.T) { + t.Run("ordinary install error", func(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill}) + copyCalls := 0 + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail }) + migration := filepath.Join(t.TempDir(), "migration") + ordinary := filepath.Join(t.TempDir(), "ordinary") + if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") { + t.Fatalf("ordinary install failure = %v", err) + } + if copyCalls == 0 { + t.Fatal("ordinary staging failure seam was not exercised") + } + }) + + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }) migration := filepath.Join(t.TempDir(), "migration") - ordinary := filepath.Join(t.TempDir(), "ordinary") - if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { - t.Fatalf("ordinary install failure = %v", err) - } if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { t.Fatalf("prerequisite install failure = %v", err) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index c08aa96a..79c0bbbe 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -161,6 +161,48 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) } } +func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + originalStat := upgradeStat + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + return appInfo, nil + } + return originalStat(path) + }) + want := map[string]bool{ + ".config/kimchi/harness/skills": false, + ".tabnine/agent/skills": false, + ".zcode/skills": false, + ".minimax/skills": false, + } + for _, root := range configuredSkillRoots(home) { + label := filepath.ToSlash(root.label) + if _, ok := want[label]; ok { + want[label] = skillRootDetectedBase(home, root) + } + } + for label, detected := range want { + if !detected { + t.Errorf("root %s was not detected", label) + } + } +} + func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { home := t.TempDir() custom := map[string]string{ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index c922a428..644b3f96 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -36,7 +36,7 @@ const ( // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // // The first entry (.agents/skills) is the canonical global Skill directory, -// matching the mechanism used by lark-cli's upstream `skills` installer. +// following the universal .agents/skills convention. // Agents that understand the universal .agents convention read it directly; // detected non-universal Agents receive relative links to this canonical // copy (with a direct-copy fallback when links are unavailable). @@ -141,6 +141,7 @@ var universalSkillDirs = map[string]bool{ var ( upgradeUserHomeDir = os.UserHomeDir + upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir @@ -689,9 +690,29 @@ func configuredSkillRoots(homeDir string) []resolvedSkillRoot { return roots } -func skillRootDetectedBase(base string) bool { - parentGate := filepath.Dir(base) - info, err := upgradeStat(parentGate) +func skillRootDetectedBase(homeDir string, root resolvedSkillRoot) bool { + detectedDir := filepath.Dir(root.base) + systemHome, _ := upgradeSystemHomeDir() + allowSystemApps := skillRootPathKey(homeDir) == skillRootPathKey(systemHome) + switch filepath.ToSlash(filepath.Clean(root.label)) { + case ".config/kimchi/harness/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".zcode/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true + } + } + case ".minimax/skills": + if allowSystemApps { + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true + } + } + } + info, err := upgradeStat(detectedDir) return err == nil && info.IsDir() } @@ -714,7 +735,7 @@ func skillRootPathKey(path string) string { func hasDependentSkillRoot(homeDir, canonicalBase string) bool { for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(homeDir, root) { continue } if !samePhysicalSkillRoot(root.base, canonicalBase) { @@ -824,7 +845,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -896,7 +917,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(destBase) { + if !skillRootDetectedBase(homeDir, root) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e238b0ca..dfee1cae 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -22,6 +22,16 @@ func withFakeHome(t *testing.T) string { t.Helper() home := t.TempDir() testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil }) + // Host-installed macOS apps must not make a fake HOME appear to have extra + // detected Agents. Tests that exercise application-based detection can + // override this seam explicitly. + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + applications := filepath.Join(string(filepath.Separator), "Applications") + if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") { + return nil, os.ErrNotExist + } + return os.Stat(path) + }) return home } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index c5fdb3b4..2ed13bee 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -90,6 +90,17 @@ function Resolve-AgentBase([string]$row) { return Join-Path $HOME $parts[2] } +function Test-AgentBaseDetected([string]$id, [string]$base) { + $parent = Split-Path $base -Parent + switch ($id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-PathLexically([string]$path) { try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf @@ -194,7 +205,7 @@ if (-not $NoSkills) { if ([string]::IsNullOrWhiteSpace($base)) { continue } $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } - if (-not (Test-Path (Split-Path $base -Parent))) { continue } + if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 4a360638..cb4c6802 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -85,6 +85,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -162,7 +172,7 @@ install_skill() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a45870e7..a046b865 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -155,6 +155,16 @@ resolve_agent_skill_base() { printf '%s\n' "$base" } +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + link_or_copy_skill() { canonical="$1"; src="$2"; dest="$3" same_physical_skill "$dest" "$canonical" && return 0 @@ -178,7 +188,7 @@ install_skill_to_homes() { installed=1 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" - [ -e "$(dirname "$base")" ] || continue + agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 563582ec..2659cd60 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -408,8 +408,8 @@ multi_tree_has_skills() { return 1 } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -469,6 +469,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -557,8 +567,7 @@ install_multi_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -750,8 +759,7 @@ install_skills_to_root() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/install.ps1 b/scripts/install.ps1 index eb90752f..7d6cb4c5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -582,6 +582,18 @@ function Resolve-AgentSkillBase { return (Join-Path $Root $Agent.Dir) } +function Test-AgentSkillBaseDetected { + param([string]$BaseDir, $Agent) + $parent = Split-Path $BaseDir -Parent + switch ($Agent.Id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + function Test-SamePhysicalSkillRoot { param([string]$Left, [string]$Right) if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } @@ -1005,8 +1017,7 @@ function Install-SkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { @@ -1073,8 +1084,7 @@ function Install-MultiSkillsToHomes { if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) if (!$seenBases.Add($baseKey)) { continue } - $parentGate = Split-Path $baseDir -Parent - if (!(Test-Path $parentGate)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { diff --git a/scripts/install.sh b/scripts/install.sh index 3475b866..84df3ea5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -643,8 +643,8 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the -# canonical store. Universal Agents read it directly; other Agents receive +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in @@ -708,6 +708,16 @@ resolve_agent_skill_base() { printf '%s\n' "$_ras_root/$_ras_agent" } +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 @@ -810,8 +820,7 @@ install_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then @@ -875,8 +884,7 @@ install_multi_skills_to_homes() { for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" - parent_gate="$(dirname "$base_dir")" - [ -e "$parent_gate" ] || continue + agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 4bfd2fb3..3e2cd6b4 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -188,6 +188,26 @@ resolve_agent_base() { esac } +# Package verification must never inherit the runner's real Agent roots. In +# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two +# parallel verification jobs would then publish links into the same directory +# and make one another's links appear broken. Keep every supported override +# inside the scenario-specific temporary HOME instead. +with_isolated_agent_env() ( + isolated_home=$1 + shift + HOME="$isolated_home" + AUTOHAND_HOME="$isolated_home/.autohand" + CLAUDE_CONFIG_DIR="$isolated_home/.claude" + CODEX_HOME="$isolated_home/.codex" + GROK_HOME="$isolated_home/.grok" + HERMES_HOME="$isolated_home/.hermes" + VIBE_HOME="$isolated_home/.vibe" + XDG_CONFIG_HOME="$isolated_home/.config" + export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME + "$@" +) + verify_skill_base() { home_root="$1" base="$2" @@ -217,8 +237,8 @@ verify_compatible_skill_base() { verify_skill_targets() { home_root="$1" - # lark-cli/skills-compatible canonical store is always present, regardless - # of which concrete Agent homes existed at install time. + # The universal-convention canonical store is always present, regardless of + # which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" seen='' for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do @@ -251,15 +271,15 @@ verify_npm_install() { npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" if [ "$scenario" = "specific-agent-roots" ]; then - seed_specific_agent_homes "$npm_home" + with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home" fi say "==> verifying npm package install ($scenario)" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" @@ -270,9 +290,9 @@ verify_npm_install() { if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" fi - [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null } diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go index 35a647b0..4b414380 100644 --- a/test/scripts/agent_registry_test.go +++ b/test/scripts/agent_registry_test.go @@ -123,6 +123,27 @@ func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing. } } +func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) { + t.Parallel() + checks := map[string][]string{ + "build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + } + for rel, wants := range checks { + body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel))) + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s missing detection contract %q", rel, want) + } + } + } +} + func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 945a8e61..04533c5a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -64,6 +64,7 @@ const require = createRequire(import.meta.url); const { UPSTREAM_AGENTS, resolvedAgentTargets, + agentTargetDetected, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, @@ -76,6 +77,14 @@ assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, " assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); +const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-")); +fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true }); +fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true }); +const detectionTargets = resolvedAgentTargets(detectionHome); +for (const id of ["kimchi", "tabnine-cli"]) { + assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`); +} +fs.rmSync(detectionHome, { recursive: true, force: true }); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -203,15 +212,32 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => { +scenario("pinned universal global topology installs canonical and retires private copies", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", "multi/dws-shared/SKILL.md": "# dws-shared\n", }); try { - writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); - writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n"); + // The pinned upstream installer treats global universal Agents as + // canonical-only even when their registry also retains a distinct + // globalSkillsDir for legacy discovery/removal. Exercise every unique + // non-canonical universal root so this distinction cannot drift again. + const retiredUniversalRoots = [ + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".codex/skills", + ".cursor/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".gemini/skills", + ".copilot/skills", + ".config/opencode/skills", + ]; + for (const root of retiredUniversalRoots) { + writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`); + } writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -223,10 +249,12 @@ scenario("Codex reads the universal canonical root and beta.6 copies are migrate fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), "universal canonical Skill installed", ); - assert.ok( - !fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")), - "beta.6 Codex duplicate migrated", - ); + for (const root of retiredUniversalRoots) { + assert.ok( + !fs.existsSync(path.join(home, root, "dingtalk-chat")), + `beta.6 universal duplicate migrated from ${root}`, + ); + } assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy nested duplicate retired", diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index cc43e8cf..97ee3235 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -135,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } verifyCmd := exec.Command("sh", verifierPath, "--npm-only") - verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + hostXDG := filepath.Join(t.TempDir(), "host-xdg") + verifyCmd.Env = replaceTestEnv(os.Environ(), + "DWS_PACKAGE_DIST_DIR", distDir, + "XDG_CONFIG_HOME", hostXDG, + ) output, err := verifyCmd.CombinedOutput() if err != nil { t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) } + if _, err := os.Stat(hostXDG); !os.IsNotExist(err) { + t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err) + } for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output) From 301429e3aafd8d4f1dd34e17f2d9bfe16deb4127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 21:49:35 +0800 Subject: [PATCH 054/267] test(ci): cover canonical skill platform branches --- internal/app/skill_command_test.go | 3 ++ internal/app/skill_setup.go | 3 -- internal/app/skill_setup_canonical_test.go | 5 +- ...ll_setup_event_migration_universal_test.go | 47 +++++++++++++++++++ .../app/skill_setup_full_coverage_test.go | 20 ++++++++ internal/upgrade/canonical_skills_test.go | 5 +- 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 20288bae..b8406cb0 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -122,6 +122,9 @@ func TestResolveSkillTargetPath(t *testing.T) { func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + if isUniversalSkillInstallTarget("missing-agent") { + t.Fatal("unknown Agent target classified as universal") + } for _, target := range []string{ "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index ef728093..92c52f12 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -1121,9 +1121,6 @@ func detectExistingAgentHomes(home, mode string) []string { return } } - if base == "" { - return - } dest := agentHomeForMode(base, mode) key := skillSetupPathKey(dest) if !seen[key] { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 45f9aff4..c308298b 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -103,6 +103,7 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -116,9 +117,11 @@ func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *te if err != nil { t.Fatal(err) } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") originalStat := skillSetupStat testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { - if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + if path == zcodeApp || path == minimaxApp { return appInfo, nil } return originalStat(path) diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go index 7800bb74..902aec6a 100644 --- a/internal/app/skill_setup_event_migration_universal_test.go +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -1,6 +1,8 @@ package app import ( + "errors" + "io" "os" "path/filepath" "strings" @@ -85,3 +87,48 @@ func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFold t.Fatalf("unselected chat changed: body=%q err=%v", body, err) } } + +func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) { + failure := errors.New("retirement denied") + + t.Run("home", func(t *testing.T) { + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) + codex := filepath.Join(t.TempDir(), ".codex", "skills") + if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) { + t.Fatalf("home failure = %v, want %v", err, failure) + } + }) + + t.Run("deduplicate", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + if err := retireMigratedUniversalSkills( + []string{codex, codex}, + []string{multiEventSkill, multiEventSkill}, + io.Discard, + ); err != nil { + t.Fatalf("deduplicated retirement failed: %v", err) + } + }) + + t.Run("backup propagates through command", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codex) + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { + return "", failure + }) + _, _, err := executeMultiSkillSetupTest( + t, + src, + []string{filepath.Join(home, ".agents", "skills"), codex}, + "--skill", "event", "--yes", + ) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "退役 universal Agent") { + t.Fatalf("retirement backup failure = %v, want wrapped %v", err, failure) + } + }) +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index b5e38e9c..8ead0740 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -537,6 +537,26 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) + t.Run("canonical ordinary install error", func(t *testing.T) { + testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { + return 0, 0, fail + }) + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + universalMigration := filepath.Join(home, ".codex", "skills") + if _, _, err := installMultiSkillsWithEventMigration( + "src", + []string{multiEventSkill}, + []string{canonical, universalMigration}, + []string{universalMigration}, + true, + io.Discard, + io.Discard, + ); !errors.Is(err, fail) { + t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail) + } + }) + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 79c0bbbe..17f27a27 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -164,6 +164,7 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { home := t.TempDir() testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -177,9 +178,11 @@ func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t if err != nil { t.Fatal(err) } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") originalStat := upgradeStat testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { - if path == "/Applications/ZCode.app" || path == "/Applications/MiniMax Code.app" { + if path == zcodeApp || path == minimaxApp { return appInfo, nil } return originalStat(path) From a39ad4e6afe9922226a21115e58b6ec600b3a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:42:54 +0800 Subject: [PATCH 055/267] fix(skills): make backups cross-filesystem safe --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 286 +++++++++-- build/npm/package.json.tmpl | 2 +- docs/rfc-skill-installation-and-upgrade.md | 21 +- internal/app/skill_setup.go | 5 +- .../app/skill_setup_cleanup_coverage_test.go | 15 +- internal/upgrade/paths.go | 11 +- internal/upgrade/paths_multi_test.go | 41 ++ internal/upgrade/skill_path_move.go | 342 +++++++++++++ internal/upgrade/skill_path_move_test.go | 449 +++++++++++++++++ internal/upgrade/skill_path_move_unix.go | 12 + internal/upgrade/skill_path_move_unix_test.go | 12 + internal/upgrade/skill_path_move_windows.go | 13 + .../upgrade/skill_path_move_windows_test.go | 20 + scripts/install-devapp.ps1 | 229 ++++++++- scripts/install-devapp.sh | 34 +- scripts/install-event.sh | 24 + scripts/install-skills.sh | 24 + scripts/install.ps1 | 205 +++++++- scripts/install.sh | 46 +- test/scripts/install_js_smoke.mjs | 212 ++++++++ test/scripts/install_script_test.go | 465 ++++++++++++++++++ 22 files changed, 2356 insertions(+), 114 deletions(-) create mode 100644 internal/upgrade/skill_path_move.go create mode 100644 internal/upgrade/skill_path_move_test.go create mode 100644 internal/upgrade/skill_path_move_unix.go create mode 100644 internal/upgrade/skill_path_move_unix_test.go create mode 100644 internal/upgrade/skill_path_move_windows.go create mode 100644 internal/upgrade/skill_path_move_windows_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index c42462e8..2608ccd6 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index f091b4a2..5550d449 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -153,18 +153,222 @@ function backupStamp() { ); } +function pathExistsLexicallySync(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function copyPathLexicallySync(src, dest) { + const info = fs.lstatSync(src); + if (info.isDirectory()) { + fs.mkdirSync(dest, { mode: 0o700 }); + for (const name of fs.readdirSync(src)) { + copyPathLexicallySync(path.join(src, name), path.join(dest, name)); + } + fs.chmodSync(dest, info.mode & 0o777); + return; + } + if (info.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + return; + } + if (!info.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, info.mode & 0o777); +} + +function restoreDirectoryModesSync(modes, chmodFn) { + const failures = []; + for (let i = modes.length - 1; i >= 0; i -= 1) { + const item = modes[i]; + try { + if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode); + } catch (err) { + failures.push(`restore source directory mode ${item.path}: ${err.message}`); + } + } + if (failures.length > 0) throw new Error(failures.join("; ")); +} + +function prepareTreeRemovalSync(root, chmodFn) { + const modes = []; + const visit = (target) => { + const info = fs.lstatSync(target); + if (!info.isDirectory()) return; + const mode = info.mode & 0o777; + if ((mode & 0o700) !== 0o700) { + chmodFn(target, mode | 0o700); + modes.push({ path: target, mode }); + } + for (const name of fs.readdirSync(target)) visit(path.join(target, name)); + }; + try { + visit(root); + return modes; + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function makeTreeWritableBestEffortSync(root) { + try { + const info = fs.lstatSync(root); + if (!info.isDirectory()) return; + try { fs.chmodSync(root, 0o700); } catch {} + let names = []; + try { names = fs.readdirSync(root); } catch {} + for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name)); + } catch {} +} + +function removePublishedSourceSync(src, removeFn, chmodFn) { + const modes = prepareTreeRemovalSync(src, chmodFn); + try { + removeFn(src); + if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal"); + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function fileDigestSync(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function verifyPathCopySync(src, dest) { + const srcInfo = fs.lstatSync(src); + const destInfo = fs.lstatSync(dest); + const srcType = srcInfo.mode & fs.constants.S_IFMT; + const destType = destInfo.mode & fs.constants.S_IFMT; + if (srcType !== destType) { + throw new Error(`Skill path type mismatch: ${src} != ${dest}`); + } + if (srcInfo.isDirectory()) { + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`); + } + const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right)); + const srcNames = fs.readdirSync(src).sort(bytewise); + const destNames = fs.readdirSync(dest).sort(bytewise); + if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) { + throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`); + } + for (const name of srcNames) { + verifyPathCopySync(path.join(src, name), path.join(dest, name)); + } + return; + } + if (srcInfo.isSymbolicLink()) { + if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) { + throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`); + } + return; + } + if (!srcInfo.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`); + } + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill file mode mismatch: ${src} != ${dest}`); + } + if (srcInfo.size !== destInfo.size) { + throw new Error(`Skill file size mismatch: ${src} != ${dest}`); + } + if (fileDigestSync(src) !== fileDigestSync(dest)) { + throw new Error(`Skill file digest mismatch: ${src} != ${dest}`); + } +} + +function movePathRecoverablySync(src, dest, options = {}) { + const renameFn = options.renameFn || fs.renameSync; + const copyFn = options.copyFn || copyPathLexicallySync; + const verifyFn = options.verifyFn || verifyPathCopySync; + const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync; + const chmodFn = options.chmodFn || fs.chmodSync; + if (pathExistsLexicallySync(dest)) { + throw new Error(`move destination already exists: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + try { + renameFn(src, dest); + return; + } catch (err) { + if (!err || err.code !== "EXDEV") throw err; + } + + const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); + const stage = path.join(stageRoot, "payload"); + let stageCleaned = false; + try { + copyFn(src, stage); + verifyFn(src, stage); + const stageInfo = fs.lstatSync(stage); + const stageMode = stageInfo.mode & 0o777; + if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); + renameFn(stage, dest); + if (stageInfo.isDirectory()) chmodFn(dest, stageMode); + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + try { + removePublishedSourceSync(src, removeFn, chmodFn); + } catch (err) { + throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`); + } + if (pathExistsLexicallySync(src)) { + throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`); + } + } catch (err) { + if (!stageCleaned) { + try { + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + } catch (cleanupErr) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } + } + throw err; + } +} + +function skillMoveOptions(options = {}) { + return { + renameFn: options.renameFn, + copyFn: options.backupCopyFn, + verifyFn: options.backupVerifyFn, + removeFn: options.backupRemoveFn, + mkdirTempFn: options.backupMkdirTempFn, + }; +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it (non-interactive // installs cannot confirm, so removals must stay reversible). Missing paths // are a no-op success. On any backup failure the directory is left in place -// and false is returned so callers skip that target rather than silently -// deleting data. -function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - let info; +// and an error is returned so callers cannot silently delete data. +function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { try { - info = fs.lstatSync(dir); + fs.lstatSync(dir); } catch (err) { - if (err && err.code === "ENOENT") return true; + if (err && err.code === "ENOENT") return ""; throw err; } const rel = path.relative(homeDir, dir); @@ -172,30 +376,29 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("-") : path.basename(dir); - const stamp = backupStamp(); + const stamp = (options.backupStampFn || backupStamp)(); const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; fs.existsSync(target); i++) { + for (let i = 1; pathExistsLexicallySync(target); i++) { if (i > 1000) { - console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`); - return false; + throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } targetRoot = path.join(backupRoot, `${stamp}-${i}`); target = path.join(targetRoot, name); } try { fs.mkdirSync(targetRoot, { recursive: true }); - renameFn(dir, target); + movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); - return false; + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); } if (backups) { backups.push({ original: dir, backup: target }); } console.log(` × 已备份并移除 ${dir} → ${target}`); - return true; + return target; } function findBinary(root) { @@ -453,7 +656,8 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireManagedSkillRoot(homeDir, baseDir, managedNames) { +function retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) { + const moveOptions = skillMoveOptions(options); const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { @@ -465,16 +669,14 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { const backups = []; try { for (const victim of victims) { - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } } catch (err) { const restoreErrors = []; for (let i = backups.length - 1; i >= 0; i -= 1) { try { fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); } catch (restoreErr) { restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`); } @@ -496,7 +698,10 @@ function samePhysicalDir(left, right) { } } -function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { + const moveOptions = skillMoveOptions(options); + const publishRenameFn = options.publishRenameFn || fs.renameSync; + const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -505,12 +710,24 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const published = []; const correctLinks = new Set(); const restore = () => { + const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { - fs.rmSync(published[i], { recursive: true, force: true }); + try { + publishRemoveFn(published[i]); + } catch (err) { + restoreErrors.push(`remove ${published[i]}: ${err.message}`); + } } for (let i = backups.length - 1; i >= 0; i -= 1) { - fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + try { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); + } catch (err) { + restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`); + } + } + if (restoreErrors.length > 0) { + throw new Error(restoreErrors.join("; ")); } }; try { @@ -532,12 +749,10 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const normalized = path.resolve(victim); if (seen.has(normalized) || correctLinks.has(normalized)) continue; seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - fs.renameSync(item.staged, item.dest); + publishRenameFn(item.staged, item.dest); published.push(item.dest); } } catch (err) { @@ -592,6 +807,7 @@ function publishManagedMultiSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const staged = []; @@ -611,7 +827,7 @@ function publishManagedMultiSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -635,9 +851,7 @@ function publishManagedMultiSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { @@ -669,6 +883,7 @@ function publishManagedMonoSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stagedDir = path.join(stageRoot, "dws"); @@ -689,7 +904,7 @@ function publishManagedMonoSkillSetAtomically( const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -709,9 +924,7 @@ function publishManagedMonoSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } published.push(destDir); @@ -995,7 +1208,12 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + copyPathLexicallySync, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + verifyPathCopySync, }; diff --git a/build/npm/package.json.tmpl b/build/npm/package.json.tmpl index f1d70d18..b7d34154 100644 --- a/build/npm/package.json.tmpl +++ b/build/npm/package.json.tmpl @@ -32,6 +32,6 @@ "README.md" ], "engines": { - "node": ">=16" + "node": ">=16.7.0" } } diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index 0f530c5d..56e70758 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -165,6 +165,14 @@ Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别 4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录; 5. 仅在没有目标失败且至少一个目标成功时更新状态快照。 +旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入 +`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子 +rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并 +保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、 +文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标 +再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除 +失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。 + Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前; 备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同 Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与 @@ -173,11 +181,19 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 ## 9. 备份合同 - 路径:`~/.dws/skill-backups//...`; -- 主要操作:同一文件系统内使用 rename 移动; +- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的 + copy → verify → publish → remove 回退; - 失败语义:备份失败时原目录保持不变,目标安装失败; +- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在, + 但恢复必须失败并明确提示两份均被保留; - 可见性:计划和执行日志显示原路径与备份路径; - 保留策略:自动修剪,仅保留最近 5 批。 +跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统 +原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell +入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 +上述验证和失败合同。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 @@ -226,6 +242,9 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - Windows、macOS、Linux 的路径和覆盖率门禁; - symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; - Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; +- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 + 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; +- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 92c52f12..6c0becf4 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -130,6 +130,7 @@ var ( skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir + skillSetupRestoreBackup = upgrade.RestoreSkillPath skillSetupMkdirAll = os.MkdirAll skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel @@ -2048,7 +2049,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir } for i := len(backups) - 1; i >= 0; i-- { item := backups[i] - if _, err := skillSetupStat(item.original); err == nil { + if _, err := skillSetupLstat(item.original); err == nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup)) continue } else if !errors.Is(err, os.ErrNotExist) { @@ -2059,7 +2060,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup)) continue } - if err := skillSetupPublishRename(item.backup, item.original); err != nil { + if err := skillSetupRestoreBackup(item.backup, item.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup)) } } diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 957496d4..6fea3de5 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -445,14 +445,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("stat", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") { t.Fatalf("restore stat error = %v", err) } }) t.Run("mkdir", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") { @@ -460,9 +460,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("rename", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") { t.Fatalf("restore rename error = %v", err) @@ -479,10 +479,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } return "", failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) restoreErr := errors.New("restore failure") - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr }) _, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard) if !errors.Is(err, failure) || !errors.Is(err, restoreErr) { t.Fatalf("backup rollback error = %v", err) @@ -491,7 +491,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("publish rollback failure", func(t *testing.T) { testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) err := publishSkillSetupTarget( []skillSetupStagedDir{{staged: "staged", dest: "dest"}}, diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 644b3f96..f0424ae6 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -193,8 +193,11 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) target := filepath.Join(backupRoot, name) for i := 1; ; i++ { - if _, err := os.Stat(target); os.IsNotExist(err) { - break + if _, err := upgradeLstat(target); err != nil { + if os.IsNotExist(err) { + break + } + return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) } backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) target = filepath.Join(backupRoot, name) @@ -205,7 +208,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } - if err := upgradeRename(dir, target); err != nil { + if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } // Keep the backup directory bounded; a prune failure must not fail the @@ -503,7 +506,7 @@ func restoreSkillSet(published []string, backups []backedUpSkillDir) error { restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err)) continue } - if err := upgradeRename(backup.backup, backup.original); err != nil { + if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err)) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index dfee1cae..a0d67135 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -733,6 +734,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("victim must be gone after backup, stat err=%v", err) } + // A dangling link at the first backup target is still an occupied lexical + // path and must select a numbered target rather than being overwritten. + danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision") + if err := os.MkdirAll(danglingVictim, 0o755); err != nil { + t.Fatal(err) + } + danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision") + if err := os.Symlink("missing-backup", danglingTaken); err == nil { + got, err = backupAndRemoveSkillDir(home, danglingVictim) + if err != nil { + t.Fatalf("backup with dangling collision error = %v", err) + } + want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision") + if got != want { + t.Fatalf("dangling collision backup path = %q, want %q", got, want) + } + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + // An unreadable target probe is not equivalent to a free path. + probeVictim := filepath.Join(home, "probe-victim") + if err := os.MkdirAll(probeVictim, 0o755); err != nil { + t.Fatal(err) + } + probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim") + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { + if path == probeTarget { + return nil, errors.New("target probe denied") + } + return os.Lstat(path) + }) + if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") { + t.Fatalf("target probe error = %v", err) + } + if _, err := os.Stat(probeVictim); err != nil { + t.Fatalf("probe victim must survive target check failure: %v", err) + } + testseam.Swap(t, &upgradeLstat, os.Lstat) + // Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir. victim2 := filepath.Join(home, "victim2") if err := os.MkdirAll(victim2, 0o755); err != nil { diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go new file mode 100644 index 00000000..605f9056 --- /dev/null +++ b/internal/upgrade/skill_path_move.go @@ -0,0 +1,342 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +var ( + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod +) + +// moveSkillPathRecoverably moves one managed Skill path without weakening the +// backup contract when source and destination are on different filesystems. +// The cross-device path publishes a fully copied and verified sibling staging +// path before removing the source. Any failure before source removal leaves the +// source intact; a removal failure deliberately leaves both verified copies. +func moveSkillPathRecoverably(src, dst string) (err error) { + if _, statErr := os.Lstat(dst); statErr == nil { + return fmt.Errorf("目标已存在: %s", dst) + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) + } + if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { + return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) + } + if renameErr := skillPathRename(src, dst); renameErr == nil { + return nil + } else if !isCrossDeviceError(renameErr) { + return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) + } + + stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-") + if err != nil { + return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err) + } + stage := filepath.Join(stageRoot, "payload") + stageCleaned := false + defer func() { + if stageCleaned { + return + } + makeSkillPathTreeWritable(stageRoot) + if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + }() + + if err := skillPathCopy(src, stage); err != nil { + return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err) + } + if err := skillPathVerify(src, stage); err != nil { + return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) + } + stageInfo, err := os.Lstat(stage) + if err != nil { + return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) + } + if stageInfo.IsDir() { + // Darwin refuses to rename a read-only directory even when both parent + // directories are writable. The exact source mode was verified above; + // temporarily add owner access for publication, then restore it before + // the final verification and before the source is removed. + if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil { + return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) + } + } + if err := skillPathRename(stage, dst); err != nil { + return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) + } + if stageInfo.IsDir() { + if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + } + } + if err := skillPathVerify(src, dst); err != nil { + return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + } + if err := skillPathRemoveAll(stageRoot); err != nil { + return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + } + stageCleaned = true + if err := removePublishedSkillSource(src); err != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err) + } + return nil +} + +type skillPathDirMode struct { + path string + mode os.FileMode +} + +func removePublishedSkillSource(src string) error { + dirModes, err := prepareSkillPathTreeRemoval(src) + if err != nil { + return err + } + removeErr := skillPathRemoveAll(src) + if removeErr == nil { + if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + return nil + } else if statErr == nil { + removeErr = errors.New("源路径仍存在") + } else { + removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr) + } + } + return errors.Join(removeErr, restoreSkillPathDirModes(dirModes)) +} + +func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { + var modes []skillPathDirMode + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0o700 == 0o700 { + return nil + } + if err := skillPathChmod(path, mode|0o700); err != nil { + return err + } + modes = append(modes, skillPathDirMode{path: path, mode: mode}) + return nil + }) + if err != nil { + return nil, errors.Join(err, restoreSkillPathDirModes(modes)) + } + return modes, nil +} + +func restoreSkillPathDirModes(modes []skillPathDirMode) error { + var restoreErr error + for i := len(modes) - 1; i >= 0; i-- { + item := modes[i] + if _, err := os.Lstat(item.path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := skillPathChmod(item.path, item.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err)) + } + } + return restoreErr +} + +func makeSkillPathTreeWritable(root string) { + // Best effort only: RemoveAll below remains the source of truth and its + // error is returned. This preparation lets it traverse read-only staging + // directories left by a copy or verification failure. + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr == nil && entry.IsDir() { + _ = os.Chmod(path, 0o700) + } + return nil + }) +} + +// RestoreSkillPath restores a previously recorded backup using the same +// rename-first, verified cross-device fallback as backup publication. +func RestoreSkillPath(backup, original string) error { + return moveSkillPathRecoverably(backup, original) +} + +func copySkillPathLexically(src, dst string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + switch { + case info.IsDir(): + // Staging must remain writable while children are copied. Restore the + // source mode only after the directory is complete so read-only Skill + // directories (for example 0555) can still be migrated lexically. + if err := os.Mkdir(dst, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(src) + if err != nil { + return err + } + return os.Symlink(target, dst) + case info.Mode().IsRegular(): + return copyRegularSkillFile(src, dst, info.Mode().Perm()) + default: + return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode()) + } +} + +func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, in.Close()) }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { err = errors.Join(err, out.Close()) }() + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Sync(); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func verifySkillPathCopy(src, dst string) error { + srcInfo, err := os.Lstat(src) + if err != nil { + return err + } + dstInfo, err := os.Lstat(dst) + if err != nil { + return err + } + if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType { + return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode()) + } + switch { + case srcInfo.IsDir(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + srcNames, err := skillPathEntryNames(src) + if err != nil { + return err + } + dstNames, err := skillPathEntryNames(dst) + if err != nil { + return err + } + if len(srcNames) != len(dstNames) { + return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames)) + } + for i, name := range srcNames { + if name != dstNames[i] { + return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i]) + } + if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil { + return err + } + } + return nil + case srcInfo.Mode()&os.ModeSymlink != 0: + srcTarget, err := os.Readlink(src) + if err != nil { + return err + } + dstTarget, err := os.Readlink(dst) + if err != nil { + return err + } + if srcTarget != dstTarget { + return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget) + } + return nil + case srcInfo.Mode().IsRegular(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size()) + } + srcDigest, err := skillPathFileDigest(src) + if err != nil { + return err + } + dstDigest, err := skillPathFileDigest(dst) + if err != nil { + return err + } + if srcDigest != dstDigest { + return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst) + } + return nil + default: + return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode()) + } +} + +func skillPathEntryNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func skillPathFileDigest(path string) ([sha256.Size]byte, error) { + f, err := os.Open(path) + if err != nil { + return [sha256.Size]byte{}, err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return [sha256.Size]byte{}, err + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go new file mode 100644 index 00000000..5567d1c2 --- /dev/null +++ b/internal/upgrade/skill_path_move_test.go @@ -0,0 +1,449 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { + t.Run("directory file and lexical links", func(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + linksCreated := true + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + linksCreated = false + } + if linksCreated { + if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil { + t.Fatal(err) + } + } + + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { + t.Fatalf("copied file = %q, %v", got, err) + } + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil || info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v, %v", info, err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(dst, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("regular file root", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source-file") + dst := filepath.Join(root, "backup", "source-file") + if err := os.WriteFile(src, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "state" { + t.Fatalf("backup file = %q, %v", got, err) + } + }) + + t.Run("read-only directory mode is restored after copying children", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "backup", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("copied directory mode = %v, %v", info, err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" { + t.Fatalf("copied read-only file = %q, %v", got, err) + } + }) + + t.Run("rollback uses the same cross-device contract", func(t *testing.T) { + root := t.TempDir() + backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, backup, original) + if err := RestoreSkillPath(backup, original); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed after verified restore: %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { + t.Run("non cross-device rename never copies", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + copied := false + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + if copied { + t.Fatal("permission failure must not enter cross-device copy fallback") + } + assertSkillSourcePreserved(t, src) + }) + + for _, tc := range []struct { + name string + copy func(string, string) error + verify func(string, string) error + }{ + {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }}, + {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + if tc.copy != nil { + testseam.Swap(t, &skillPathCopy, tc.copy) + } + if tc.verify != nil { + testseam.Swap(t, &skillPathVerify, tc.verify) + } + if err := moveSkillPathRecoverably(src, dst); err == nil { + t.Fatal("expected failure") + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("formal backup must not publish: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + } + + t.Run("staging publish failure", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + if to == dst { + return errors.New("publish failed") + } + return originalRename(from, to) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("published verification failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("verified backup candidate must remain: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("source removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("formal backup must remain: %v", err) + } + }) + + t.Run("read-only source mode is restored after removal failure", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("source directory mode after failed removal = %v, %v", info, err) + } + if err := os.Chmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("source permission preparation failure preserves both", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == src { + return errors.New("chmod failed") + } + return originalChmod(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + if err := originalChmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("lying remover is detected", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return nil + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) + + t.Run("same-filesystem fast path", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source still exists: %v", err) + } + }) + + t.Run("existing destination is never overwritten", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) +} + +func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { + t.Run("type", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") { + t.Fatalf("error = %v", err) + } + }) + t.Run("file size and digest", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") { + t.Fatalf("error = %v", err) + } + }) + t.Run("symlink target", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Symlink("one", src); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation unavailable") + } + t.Fatal(err) + } + if err := os.Symlink("two", dst); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") { + t.Fatalf("error = %v", err) + } + }) +} + +func forceCrossDeviceRename(t *testing.T, src, dst string) { + t.Helper() + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(from, to string) error { + if from == src && to == dst { + return testCrossDeviceError() + } + return originalRename(from, to) + }) +} + +func makeSkillMoveFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + return src, dst +} + +func assertSkillSourcePreserved(t *testing.T, src string) { + t.Helper() + if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("source content = %q, %v", got, err) + } +} + +func assertNoCrossDeviceStage(t *testing.T, dst string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(dst)) + if err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } + prefix := "." + filepath.Base(dst) + ".cross-device-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("cross-device staging leaked: %s", entry.Name()) + } + } +} diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go new file mode 100644 index 00000000..587c8217 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go new file mode 100644 index 00000000..e5ccde16 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "os" + "syscall" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} +} diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go new file mode 100644 index 00000000..f43fe688 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package upgrade + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go new file mode 100644 index 00000000..401e4ed0 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -0,0 +1,20 @@ +//go:build windows + +package upgrade + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} +} + +func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { + if !isCrossDeviceError(testCrossDeviceError()) { + t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") + } +} diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 2ed13bee..e8cc3bce 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -109,13 +109,201 @@ function Test-PathLexically([string]$path) { Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) } -function Backup-DevSkill([string]$path) { +function Move-DevSkillPath([string]$Source, [string]$Destination) { + Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} + +function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { + $exception = $Record.Exception + while ($null -ne $exception) { + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-DevSkillMetadata($SourceItem, [string]$Destination) { + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-DevSkillPermissionFingerprint([string]$Path) { + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) { + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name) + } + Copy-DevSkillMetadata $item $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-DevSkillMetadata $item $Destination +} + +function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) { + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill path type mismatch: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill link target mismatch: $Source != $Destination" + } + return + } + if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) { + throw "Skill path permissions mismatch: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" } + Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" } + if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne + (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) { + throw "Skill file digest mismatch: $Source != $Destination" + } +} + +function Remove-DevSkillPathLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-DevSkillPath $Source $Destination + return + } catch { + if (!(Test-DevCrossDeviceMoveError $_)) { throw } + } + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { Remove-DevSkillPathLexically $Source } catch { + throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" + } + if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop } catch { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot`: $_" + } + } + throw $failure + } +} + +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + if ($null -ne $BackupPath) { $BackupPath.Value = "" } if (!(Test-PathLexically $path)) { return } $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null $target = Join-Path $backupRoot (Split-Path $path -Leaf) while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } - Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop + Move-DevSkillPathRecoverably $path $target + if ($null -ne $BackupPath) { $BackupPath.Value = $target } +} + +function Restore-DevSkillBackup([string]$Destination, [string]$Backup) { + if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } + if (![string]::IsNullOrWhiteSpace($Backup)) { + Move-DevSkillPathRecoverably $Backup $Destination + } +} + +function Publish-DevSkillCopy([string]$Source, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + Assert-DevSkillPathCopy $Source $Destination + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +function Publish-DevSkillJunction([string]$Target, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup } catch { + throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + } + throw $publishFailure + } + } finally { + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + } } function Get-Arch { @@ -133,6 +321,26 @@ function Get-Arch { } } +function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) { + $checksums = Join-Path $TempDir "checksums.txt" + if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) { + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" ` + -OutFile $checksums -UseBasicParsing -ErrorAction Stop + } catch { + Die "Could not download checksums.txt; refusing unverified release assets." + } + } + $line = Get-Content -LiteralPath $checksums | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $line) { Die "$AssetName is missing from checksums.txt." } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." } + Say "SHA256 checksum verified: $AssetName" +} + # Read the releases list (newest first) and take the top tag, so this also works # if a release is ever published as a prerelease (which /releases/latest skips). if (-not $Version) { @@ -163,6 +371,7 @@ try { Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` -OutFile $zip -UseBasicParsing } catch { Die "Binary download failed - does release $Version have $asset?" } +Assert-DevReleaseAssetChecksum $zip $asset $tmp Expand-Archive -Path $zip -DestinationPath $tmp -Force $exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 @@ -177,6 +386,7 @@ if (-not $NoSkills) { $skzip = Join-Path $tmp "dws-skills.zip" Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` -OutFile $skzip -UseBasicParsing + Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp $skdir = Join-Path $tmp "sk" Expand-Archive -Path $skzip -DestinationPath $skdir -Force @@ -188,15 +398,11 @@ if (-not $NoSkills) { if ($src) { # cache so `dws skill setup --mode multi` can find a source later $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" - if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } - New-Item -ItemType Directory -Path $cache -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + Publish-DevSkillCopy $src $cache $canonicalBase = Join-Path $HOME ".agents\skills" $canonical = Join-Path $canonicalBase $SkillName - Backup-DevSkill $canonical - New-Item -ItemType Directory -Path $canonical -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + Publish-DevSkillCopy $src $canonical $installed = 1 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { @@ -209,9 +415,8 @@ if (-not $NoSkills) { $dest = Join-Path $base $SkillName if ($universal) { Backup-DevSkill $dest; continue } New-Item -ItemType Directory -Path $base -Force | Out-Null - Backup-DevSkill $dest - try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null } - catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force } + try { Publish-DevSkillJunction $canonical $dest } + catch { Publish-DevSkillCopy $canonical $dest } $installed++ } Say "Skill dingtalk-misc -> $installed agent dir(s)" @@ -219,7 +424,7 @@ if (-not $NoSkills) { Say "(dingtalk-misc not found in skills bundle; skipped)" } } catch { - Say "(skill install skipped: $($_.Exception.Message))" + Die "Skill install failed: $($_.Exception.Message)" } } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index cb4c6802..70a631ad 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "✅ SHA256 checksum verified: ${name}" +} + copy_tree() { src="$1"; dest="$2"; parent="$(dirname "$dest")" mkdir -p "$parent" || return 1 @@ -33,7 +55,10 @@ copy_tree() { if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage" "$dest"; then return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -106,7 +131,10 @@ link_or_copy_skill() { if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -202,6 +230,7 @@ main() { say "⬇ Downloading ${asset} ..." curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" else @@ -215,6 +244,7 @@ main() { # 2) dev skill from the release's skills bundle if [ "$NO_SKILLS" != "1" ]; then if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + verify_release_asset "dws-skills.zip" "$tmp/skills.zip" mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" diff --git a/scripts/install-event.sh b/scripts/install-event.sh index a046b865..58c56cda 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "SHA256 checksum verified: ${name}" +} + detect_os() { case "$(uname -s)" in Linux*) echo linux ;; @@ -283,6 +305,7 @@ install_binary() { say "Downloading ${asset} ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then extract_zip "$tmp/$asset" "$tmp/bin" @@ -311,6 +334,7 @@ install_skills() { say "Downloading dws-skills.zip ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip" mkdir -p "$tmp/skills" extract_zip "$tmp/dws-skills.zip" "$tmp/skills" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 2659cd60..4a7c486b 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -123,6 +123,29 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } + curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { + printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 + return 1 + } + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { + printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 + return 1 + } + [ "$_checksum_actual" = "$_checksum_expected" ] || { + printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 + return 1 + } + printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -813,6 +836,7 @@ main() { [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" + verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7d6cb4c5..795ee1be 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -261,6 +261,161 @@ function Move-SkillPath { Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop } +function Test-CrossDeviceMoveError { + param([System.Management.Automation.ErrorRecord]$Record) + $exception = $Record.Exception + while ($null -ne $exception) { + # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as + # the low word of an IOException HRESULT. + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-SkillPathMetadata { + param($SourceItem, [string]$Destination) + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-SkillPathPermissionFingerprint { + param([string]$Path) + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl + } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name) + } + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { + throw "不支持复制特殊 Skill 路径 $Source" + } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination +} + +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill 路径类型不一致: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill 链接目标不一致: $Source != $Destination" + } + return + } + if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne + (Get-SkillPathPermissionFingerprint -Path $Destination)) { + throw "Skill 路径权限不一致: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { + throw "Skill 目录项数量不一致: $Source != $Destination" + } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { + throw "Skill 目录项不一致: $Source != $Destination" + } + Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { + throw "Skill 文件大小不一致: $Source != $Destination" + } + $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash + $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash + if ($sourceHash -ne $destinationHash) { + throw "Skill 文件内容摘要不一致: $Source != $Destination" + } +} + +function Remove-SkillPathLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on +# the destination filesystem, copy links lexically, verify, publish, then +# remove the source. Before source removal every failure keeps the original; +# a removal failure deliberately leaves both verified copies and fails loud. +function Move-SkillPathRecoverably { + param([string]$Source, [string]$Destination) + if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-SkillPath -Source $Source -Destination $Destination + return + } catch { + if (!(Test-CrossDeviceMoveError -Record $_)) { throw } + } + + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-SkillPathLexically -Source $Source -Destination $stage + Assert-SkillPathCopy -Source $Source -Destination $stage + Move-SkillPath -Source $stage -Destination $Destination + Assert-SkillPathCopy -Source $Source -Destination $Destination + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + try { + Remove-SkillPathLexically -Path $Source + } catch { + throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_" + } + if (Test-SkillPathLexically -Path $Source) { + throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)" + } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + try { + Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + } catch { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot`: $_" + } + } + throw $failure + } +} + function Get-Arch { # Allow manual override via environment variable if ($env:DWS_ARCH) { @@ -340,6 +495,26 @@ function Get-GiteeAssetUrl { return "" } +function Assert-ReleaseAssetChecksum { + param([string]$AssetPath, [string]$AssetName, [string]$TempDir) + if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } + if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." } + $checksumPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop + } catch { + Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName." + } + $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." } + $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." } + Write-Say "✅ SHA256 checksum verified: $AssetName" +} + function Resolve-Source { # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. if ($GiteeRepo -ne "") { return } @@ -482,7 +657,7 @@ function Backup-SkillDir { $name = Split-Path $Dir -Leaf $target = Join-Path (Join-Path $backupRoot $stamp) $name $i = 1 - while (Test-Path $target) { + while (Test-SkillPathLexically -Path $target) { $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name $i++ if ($i -gt 1000) { @@ -492,9 +667,9 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $Dir -Destination $target + Move-SkillPathRecoverably -Source $Dir -Destination $target } catch { - Write-Say "⚠️ 备份失败,保留原目录 $Dir" + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } @@ -525,7 +700,7 @@ function Restore-MultiSkillSet { throw "恢复目标仍存在" } New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $item.Backup -Destination $item.Original + Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original } catch { Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_" $ok = $false @@ -811,26 +986,7 @@ function Install-Binary { $archivePath = Join-Path $tmpDir $archiveName Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing - # Download and verify SHA256 checksum - if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } - try { - $checksumPath = Join-Path $tmpDir "checksums.txt" - Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing - $checksumContent = Get-Content $checksumPath - $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) } - if ($expectedLine) { - $expected = ($expectedLine -split '\s+')[0] - $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting." - } - Write-Say "✅ SHA256 checksum verified" - } else { - Write-Say "⚠️ Archive not found in checksums.txt; skipping verification" - } - } catch { - Write-Say "⚠️ Could not download checksums.txt; skipping verification" - } + Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir Write-Say "📦 Extracting..." Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force @@ -1256,6 +1412,7 @@ function Install-Skills { Write-Err "Cannot download skills from GitHub and no local source checkout found." } } + Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force diff --git a/scripts/install.sh b/scripts/install.sh index 84df3ea5..b494a402 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -141,6 +141,23 @@ sha256_stdin() { fi } +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}." + download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \ + err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}." + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt." + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \ + err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl." + [ "$_checksum_actual" = "$_checksum_expected" ] || \ + err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}." + say "✅ SHA256 checksum verified: ${_checksum_asset}" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -1085,32 +1102,7 @@ install_binary() { download "$download_url" "$tmpdir/$archive_name" - # Download and verify SHA256 checksum - checksum_url="$(asset_url checksums.txt)" - if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then - expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")" - if [ -n "$expected" ]; then - if need_cmd sha256sum; then - actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')" - elif need_cmd shasum; then - actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')" - else - actual="" - fi - if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then - err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting." - fi - if [ -n "$actual" ]; then - say "✅ SHA256 checksum verified" - else - say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification" - fi - else - say "⚠️ Archive not found in checksums.txt; skipping verification" - fi - else - say "⚠️ Could not download checksums.txt; skipping verification" - fi + verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir" say "📦 Extracting..." tar xzf "$tmpdir/$archive_name" -C "$tmpdir" @@ -1175,6 +1167,8 @@ install_skills() { fi fi + verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills" + extract_root="$tmpdir_skills/skills" mkdir -p "$extract_root" if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 04533c5a..ba0ad970 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -65,7 +65,10 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + backupAndRemoveSkillDir, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = require(installJsSource); @@ -126,6 +129,175 @@ function writeManagedState(home, names) { writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`); } +function crossDeviceError() { + return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); +} + +function runCrossDeviceMoveContract() { + const roots = []; + const fixture = (name) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`)); + roots.push(root); + const src = path.join(root, "external", "skill"); + const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill"); + writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640); + return { root, src, dest }; + }; + const injectedRename = (source, target) => { + if (path.basename(source) === "skill" && path.basename(target) === "skill") { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }; + try { + { + const { src, dest } = fixture("success"); + fs.symlinkSync("SKILL.md", path.join(src, "skill-link")); + fs.symlinkSync("missing.md", path.join(src, "dangling-link")); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.lstatSync(dest).isDirectory(), true); + assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640); + assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md"); + assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md"); + assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last"); + + const restored = path.join(path.dirname(src), "restored-skill"); + movePathRecoverablySync(dest, restored, { + renameFn(source, target) { + if (source === dest && target === restored) throw crossDeviceError(); + fs.renameSync(source, target); + }, + }); + assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup"); + } + + for (const [name, options] of [ + ["copy", { copyFn() { throw new Error("copy failed"); } }], + ["verify", { verifyFn() { throw new Error("verify failed"); } }], + ]) { + const { root, src, dest } = fixture(name); + assert.throws( + () => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }), + new RegExp(`${name} failed`), + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false); + const backupParent = path.dirname(dest); + if (fs.existsSync(backupParent)) { + assert.equal( + fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")), + false, + `${name} failure cleans destination-filesystem staging`, + ); + } + assert.ok(root); + } + + { + const { src, dest } = fixture("remove"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("read-only"); + fs.chmodSync(src, 0o555); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("read-only-remove-failure"); + fs.chmodSync(src, 0o555); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode"); + fs.chmodSync(src, 0o755); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("permission"); + let copied = false; + const permission = Object.assign(new Error("permission denied"), { code: "EACCES" }); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn() { throw permission; }, + copyFn() { copied = true; }, + }), + /permission denied/, + ); + assert.equal(copied, false, "EACCES must not be mistaken for EXDEV"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); + roots.push(root); + const home = path.join(root, "home"); + const victim = path.join(home, ".agents", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + const stamp = "20260813-150000"; + const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat"); + fs.mkdirSync(path.dirname(occupied), { recursive: true }); + fs.symlinkSync("missing-backup", occupied); + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }); + assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling"); + assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-")); + roots.push(root); + const home = path.join(root, "home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const victim = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], { + renameFn(source, target) { + if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }, + publishRenameFn() { throw new Error("link publish failed"); }, + }), + /link publish failed/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n"); + } + } finally { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + } +} + // stagePkg builds a fake npm package whose assets/dws-skills.zip contains // exactly the given zip entries ({ "relative/path": "content" }) plus any // listed empty directories. Returns { tmp, pkg, home }. @@ -300,33 +472,54 @@ scenario("upstream Agent roots honor XDG and custom homes with relative links", }); const xdg = path.join(tmp, "xdg config"); const autohand = path.join(tmp, "autohand home"); + const claude = path.join(tmp, "claude config"); const codex = path.join(tmp, "codex home"); + const hermes = path.join(tmp, "hermes home"); try { writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(claude, "config.json"), "{}\n"); + writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n"); writeFile(path.join(codex, "config.toml"), "model=test\n"); writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(hermes, "config.json"), "{}\n"); + writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n"); writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); const res = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: xdg, AUTOHAND_HOME: autohand, + CLAUDE_CONFIG_DIR: claude, CODEX_HOME: codex, + HERMES_HOME: hermes, }); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); for (const linked of [ path.join(xdg, "goose", "skills", "dingtalk-chat"), path.join(autohand, "skills", "dingtalk-chat"), + path.join(claude, "skills", "dingtalk-chat"), + path.join(hermes, "skills", "dingtalk-chat"), path.join(home, ".qoderwork", "skills", "dingtalk-chat"), ]) { assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); } + const backupText = []; + const collectBackupText = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) collectBackupText(full); + else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + } + }; + collectBackupText(path.join(home, ".dws", "skill-backups")); + assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); + assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); for (const retired of [ path.join(xdg, "agents", "skills", "dingtalk-chat"), path.join(codex, "skills", "dingtalk-chat"), @@ -518,6 +711,13 @@ scenario("multi set publish failure restores the complete previous set", () => { [first, second], { renameFn(src, dest) { + if ( + src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second)) + ) { + throw crossDeviceError(); + } if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { throw new Error("injected second publish failure"); } @@ -601,6 +801,14 @@ for (const failureKind of ["backup", "publish"]) { if (failureKind === "backup" && src === second) { throw new Error("injected second backup failure"); } + if ( + failureKind === "publish" && + (src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second))) + ) { + throw crossDeviceError(); + } if ( failureKind === "publish" && src.includes(".dws-mono-set.tmp-") && @@ -626,6 +834,10 @@ for (const failureKind of ["backup", "publish"]) { }); } +process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); +runCrossDeviceMoveContract(); +process.stdout.write("ok\n"); + for (const [name, fn] of scenarios) { process.stdout.write(`• ${name} ... `); fn(); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 5ab08442..44620d0a 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" @@ -214,13 +216,41 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) { } } +func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required string + forbidden string + }{ + {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + if !strings.Contains(text, check.required) { + t.Errorf("%s missing explicit failure contract %q", check.path, check.required) + } + if strings.Contains(text, check.forbidden) { + t.Errorf("%s still contains silent failure path %q", check.path, check.forbidden) + } + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), filepath.Join("..", "..", "scripts", "install-event.sh"), + filepath.Join("..", "..", "scripts", "install-devapp.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { scriptPath, err := filepath.Abs(rel) @@ -237,6 +267,9 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { if !strings.Contains(text, "releases/download") || !strings.Contains(text, "dws-skills.zip") { t.Fatalf("%s should download dws-skills.zip from GitHub Releases", scriptPath) } + if !strings.Contains(text, "checksums.txt") || !strings.Contains(strings.ToLower(text), "sha256") { + t.Fatalf("%s should verify dws-skills.zip against release checksums", scriptPath) + } if strings.Contains(text, "archive/refs/heads/main.tar.gz") || strings.Contains(text, "archive/refs/tags/") { t.Fatalf("%s should not download skills from repository archive refs", scriptPath) } @@ -319,6 +352,7 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", "SKILL.md": "legacy mono root\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, dir := range []string{ @@ -439,6 +473,7 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) @@ -484,6 +519,7 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { "multi/dingtalk-shared/SKILL.md": "new shared\n", "mono/SKILL.md": "new mono\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") @@ -518,6 +554,46 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { } } +func TestInstallEventChecksumMismatchPreservesExistingSkills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + root := t.TempDir() + home := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "multi/dingtalk-misc/SKILL.md": "new misc\n", + "mono/SKILL.md": "new mono\n", + }) + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Repeat("0", 64)+" dws-skills.zip\n"), 0o644) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + existing := filepath.Join(home, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, runErr := cmd.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum mismatch") { + t.Fatalf("checksum mismatch result = %v\n%s", runErr, output) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "old event stays\n" { + t.Fatalf("existing Skill after checksum mismatch = %q, %v", got, err) + } +} + func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -540,6 +616,7 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) @@ -596,6 +673,7 @@ func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") @@ -1195,6 +1273,82 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" } } +func TestInstallSkillsShellMvCrossFilesystemBackupAndRestore(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real cross-filesystem mv fixture uses Linux /dev/shm") + } + sharedMemoryRoot, err := os.MkdirTemp("/dev/shm", "dws-shell-exdev-") + if err != nil { + t.Skipf("/dev/shm is unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sharedMemoryRoot) }) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-skills.sh final main invocation not found") + } + library := filepath.Join(t.TempDir(), "install-skills-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + source := filepath.Join(t.TempDir(), "dingtalk-chat") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + probe := filepath.Join(sharedMemoryRoot, "rename-probe") + if err := os.Rename(source, probe); err == nil { + if restoreErr := os.Rename(probe, source); restoreErr != nil { + t.Fatal(restoreErr) + } + t.Skip("/dev/shm and the test temp directory share a filesystem") + } else if !strings.Contains(strings.ToLower(err.Error()), "cross-device") { + t.Skipf("could not establish a cross-filesystem fixture: %v", err) + } + + home := filepath.Join(sharedMemoryRoot, "home") + manifest := filepath.Join(sharedMemoryRoot, "backups.manifest") + harness := `. "$DWS_TEST_LIBRARY" +backup_and_remove_skill_dir "$DWS_TEST_SOURCE" +backup=$DWS_LAST_SKILL_BACKUP +[ -n "$backup" ] && [ -d "$backup" ] && [ ! -e "$DWS_TEST_SOURCE" ] +printf '%s\n%s\n' "$DWS_TEST_SOURCE" "$backup" > "$DWS_TEST_MANIFEST" +restore_multi_skill_set /dev/null "$DWS_TEST_MANIFEST" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_MANIFEST="+manifest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-filesystem shell backup/restore failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored shell content = %q, %v", got, err) + } + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(source, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("shell %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(source, name)); err != nil || got != want { + t.Fatalf("shell %s target = %q, %v; want %q", name, got, err, want) + } + } +} + func TestInstallSkillsShellPreservesUnregisteredDingtalkSkill(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) if err != nil { @@ -2196,6 +2350,295 @@ exit 0 } } +func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + + for _, phase := range []string{"success", "copy", "verify", "remove", "permission"} { + phase := phase + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "skill") + destination := filepath.Join(home, ".dws", "skill-backups", "stamp", "skill") + restored := filepath.Join(root, "external", "restored-skill") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + linksCreated := false + if phase == "success" { + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + } + + harness := prefix + ` +$script:OriginalCopySkillPathLexically = ${function:Copy-SkillPathLexically} +$script:OriginalAssertSkillPathCopy = ${function:Assert-SkillPathCopy} +$script:OriginalRemoveSkillPathLexically = ${function:Remove-SkillPathLexically} +function New-CrossDeviceError { return [System.IO.IOException]::new("injected cross-device rename", -2147024879) } +function Move-SkillPath { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "permission" -and $Source -eq $env:DWS_TEST_SOURCE) { + throw [System.UnauthorizedAccessException]::new("permission denied") + } + if (($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) -or + ($Source -eq $env:DWS_TEST_DESTINATION -and $Destination -eq $env:DWS_TEST_RESTORED)) { + throw (New-CrossDeviceError) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "copy") { throw "copy failed" } + if ($env:DWS_TEST_PHASE -eq "permission") { + [System.IO.File]::WriteAllText($env:DWS_TEST_COPY_MARKER, "called") + } + & $script:OriginalCopySkillPathLexically -Source $Source -Destination $Destination +} +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination +} +function Remove-SkillPathLexically { + param([string]$Path) + if ($env:DWS_TEST_PHASE -eq "remove" -and $Path -eq $env:DWS_TEST_SOURCE) { throw "remove failed" } + & $script:OriginalRemoveSkillPathLexically -Path $Path +} +try { + Move-SkillPathRecoverably -Source $env:DWS_TEST_SOURCE -Destination $env:DWS_TEST_DESTINATION + if ($env:DWS_TEST_PHASE -ne "success") { exit 2 } + Move-SkillPathRecoverably -Source $env:DWS_TEST_DESTINATION -Destination $env:DWS_TEST_RESTORED + exit 0 +} catch { + if ($env:DWS_TEST_PHASE -eq "success") { Write-Error $_; exit 3 } + if ($env:DWS_TEST_PHASE -eq "copy" -and $_ -notmatch "copy failed") { Write-Error $_; exit 4 } + if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } + if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } + if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + exit 0 +} +` + harnessPath := filepath.Join(t.TempDir(), "install-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + copyMarker := filepath.Join(root, "copy-called") + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + "DWS_TEST_RESTORED="+restored, + "DWS_TEST_COPY_MARKER="+copyMarker, + "DWS_TEST_PHASE="+phase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell %s EXDEV harness failed: %v\n%s", phase, err, output) + } + + if phase == "success" { + if got, err := os.ReadFile(filepath.Join(restored, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed by rollback: %v", err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(restored, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + return + } + + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("source after %s failure = %q, %v", phase, got, err) + } + if phase == "remove" { + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("published backup after remove failure = %q, %v", got, err) + } + } else if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination after %s failure = %v", phase, err) + } + if phase == "permission" { + if _, err := os.Stat(copyMarker); !os.IsNotExist(err) { + t.Fatalf("permission error must not trigger copy fallback: %v", err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".skill.cross-device-*")); err != nil || len(matches) != 0 { + t.Fatalf("PowerShell %s staging leftovers = %v, %v", phase, matches, err) + } + }) + } +} + +func TestInstallDevappPowerShellCrossDeviceBackup(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old dev skill\n"), 0o640) + linksCreated := false + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +Backup-DevSkill $env:DWS_TEST_SOURCE +if (Test-PathLexically $env:DWS_TEST_SOURCE) { exit 2 } +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_SOURCE="+source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp EXDEV backup failed: %v\n%s", err, output) + } + backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n") + if backup == "" { + t.Fatal("PowerShell devapp backup missing") + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(backup, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("devapp %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(backup, name)); err != nil || got != want { + t.Fatalf("devapp %s target = %q, %v; want %q", name, got, err, want) + } + } + } +} + +func TestInstallDevappPowerShellPublishFailureRestoresAcrossDevice(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -match "[\\/].dws-dev-copy-[^\\/]+[\\/]payload$") { throw "injected publish failure" } + if ($Source -match "[\\/]skill-backups[\\/]" -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rollback", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_ -notmatch "injected publish failure") { Write-Error $_; exit 3 } +} +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publish-rollback-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publish rollback failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old dev skill\n" { + t.Fatalf("restored devapp content = %q, %v", got, err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".dws-dev-copy-*")); err != nil || len(matches) != 0 { + t.Fatalf("devapp publish staging leftovers = %v, %v", matches, err) + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { @@ -2416,12 +2859,34 @@ done case "$url" in *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *"/checksums.txt") cp "$FAKE_RELEASE_DIR/checksums.txt" "$out" ;; *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; esac ` mustWriteFile(t, path, []byte(script), 0o755) } +func writeInstallerFixtureChecksums(t *testing.T, releaseDir string) { + t.Helper() + entries, err := os.ReadDir(releaseDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", releaseDir, err) + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "checksums.txt" { + continue + } + data, err := os.ReadFile(filepath.Join(releaseDir, entry.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", entry.Name(), err) + } + digest := sha256.Sum256(data) + lines = append(lines, hex.EncodeToString(digest[:])+" "+entry.Name()) + } + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + func writeFakeGH(t *testing.T, path, version string) { t.Helper() script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" From fc18f8fd04822ef47ca5c3fa4d6cc19da278e85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:50:14 +0800 Subject: [PATCH 056/267] fix(skills): preserve read-only backup trees --- internal/upgrade/skill_path_move.go | 56 +- .../upgrade/skill_path_move_errors_test.go | 485 ++++++++++++++++++ 2 files changed, 518 insertions(+), 23 deletions(-) create mode 100644 internal/upgrade/skill_path_move_errors_test.go diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 605f9056..2c28be90 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -18,6 +18,16 @@ var ( skillPathMkdirAll = os.MkdirAll skillPathMkdirTemp = os.MkdirTemp skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathReadDir = os.ReadDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the @@ -26,7 +36,7 @@ var ( // path before removing the source. Any failure before source removal leaves the // source intact; a removal failure deliberately leaves both verified copies. func moveSkillPathRecoverably(src, dst string) (err error) { - if _, statErr := os.Lstat(dst); statErr == nil { + if _, statErr := skillPathLstat(dst); statErr == nil { return fmt.Errorf("目标已存在: %s", dst) } else if !os.IsNotExist(statErr) { return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) @@ -62,7 +72,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathVerify(src, stage); err != nil { return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) } - stageInfo, err := os.Lstat(stage) + stageInfo, err := skillPathLstat(stage) if err != nil { return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) } @@ -108,7 +118,7 @@ func removePublishedSkillSource(src string) error { } removeErr := skillPathRemoveAll(src) if removeErr == nil { - if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) { return nil } else if statErr == nil { removeErr = errors.New("源路径仍存在") @@ -121,7 +131,7 @@ func removePublishedSkillSource(src string) error { func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { var modes []skillPathDirMode - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -152,7 +162,7 @@ func restoreSkillPathDirModes(modes []skillPathDirMode) error { var restoreErr error for i := len(modes) - 1; i >= 0; i-- { item := modes[i] - if _, err := os.Lstat(item.path); os.IsNotExist(err) { + if _, err := skillPathLstat(item.path); os.IsNotExist(err) { continue } else if err != nil { restoreErr = errors.Join(restoreErr, err) @@ -169,9 +179,9 @@ func makeSkillPathTreeWritable(root string) { // Best effort only: RemoveAll below remains the source of truth and its // error is returned. This preparation lets it traverse read-only staging // directories left by a copy or verification failure. - _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr == nil && entry.IsDir() { - _ = os.Chmod(path, 0o700) + _ = skillPathChmod(path, 0o700) } return nil }) @@ -184,7 +194,7 @@ func RestoreSkillPath(backup, original string) error { } func copySkillPathLexically(src, dst string) error { - info, err := os.Lstat(src) + info, err := skillPathLstat(src) if err != nil { return err } @@ -193,10 +203,10 @@ func copySkillPathLexically(src, dst string) error { // Staging must remain writable while children are copied. Restore the // source mode only after the directory is complete so read-only Skill // directories (for example 0555) can still be migrated lexically. - if err := os.Mkdir(dst, 0o700); err != nil { + if err := skillPathMkdir(dst, 0o700); err != nil { return err } - entries, err := os.ReadDir(src) + entries, err := skillPathReadDir(src) if err != nil { return err } @@ -207,11 +217,11 @@ func copySkillPathLexically(src, dst string) error { } return os.Chmod(dst, info.Mode().Perm()) case info.Mode()&os.ModeSymlink != 0: - target, err := os.Readlink(src) + target, err := skillPathReadlink(src) if err != nil { return err } - return os.Symlink(target, dst) + return skillPathSymlink(target, dst) case info.Mode().IsRegular(): return copyRegularSkillFile(src, dst, info.Mode().Perm()) default: @@ -220,31 +230,31 @@ func copySkillPathLexically(src, dst string) error { } func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { - in, err := os.Open(src) + in, err := skillPathOpen(src) if err != nil { return err } defer func() { err = errors.Join(err, in.Close()) }() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } defer func() { err = errors.Join(err, out.Close()) }() - if _, err := io.Copy(out, in); err != nil { + if _, err := skillPathCopyBytes(out, in); err != nil { return err } - if err := out.Sync(); err != nil { + if err := skillPathSync(out); err != nil { return err } return os.Chmod(dst, mode) } func verifySkillPathCopy(src, dst string) error { - srcInfo, err := os.Lstat(src) + srcInfo, err := skillPathLstat(src) if err != nil { return err } - dstInfo, err := os.Lstat(dst) + dstInfo, err := skillPathLstat(dst) if err != nil { return err } @@ -277,11 +287,11 @@ func verifySkillPathCopy(src, dst string) error { } return nil case srcInfo.Mode()&os.ModeSymlink != 0: - srcTarget, err := os.Readlink(src) + srcTarget, err := skillPathReadlink(src) if err != nil { return err } - dstTarget, err := os.Readlink(dst) + dstTarget, err := skillPathReadlink(dst) if err != nil { return err } @@ -314,7 +324,7 @@ func verifySkillPathCopy(src, dst string) error { } func skillPathEntryNames(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) + entries, err := skillPathReadDir(dir) if err != nil { return nil, err } @@ -327,13 +337,13 @@ func skillPathEntryNames(dir string) ([]string, error) { } func skillPathFileDigest(path string) ([sha256.Size]byte, error) { - f, err := os.Open(path) + f, err := skillPathOpen(path) if err != nil { return [sha256.Size]byte{}, err } defer f.Close() hash := sha256.New() - if _, err := io.Copy(hash, f); err != nil { + if _, err := skillPathCopyBytes(hash, f); err != nil { return [sha256.Size]byte{}, err } var digest [sha256.Size]byte diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go new file mode 100644 index 00000000..5c151934 --- /dev/null +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -0,0 +1,485 @@ +package upgrade + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { + t.Run("destination probe", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst { + return nil, os.ErrPermission + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination parent", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("staging creation", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("failed staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") }) + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing copied staging", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil }) + testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") { + t.Fatalf("error = %v", err) + } + }) + + for _, tc := range []struct { + name string + path func(src, dst string) string + want string + }{ + {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"}, + {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") { + return errors.New("chmod failed") + } + if tc.name == "restore published mode" && path == dst { + return errors.New("chmod failed") + } + return original(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v", err) + } + }) + } + + t.Run("published staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".cross-device-") { + return errors.New("cleanup failed") + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source deletion cannot be confirmed", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + removalAttempted := false + originalLstat := skillPathLstat + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + removalAttempted = true + return nil + } + return originalRemove(path) + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && removalAttempted { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("walk error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, nil, errors.New("walk failed")) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry info error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, skillPathErrorDirEntry{}, nil) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing restored directory", func(t *testing.T) { + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("restore probe error", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("restore chmod error", func(t *testing.T) { + path := t.TempDir() + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) { + t.Run("source lstat", func(t *testing.T) { + if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination mkdir", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copySkillPathLexically(src, dst); err == nil { + t.Fatal("expected mkdir failure") + } + }) + + t.Run("source readdir", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("nested copy", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "child" { + return nil, os.ErrPermission + } + return original(path) + }) + if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("readlink", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "link") + if err := os.Symlink("target", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("special path", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular open", func(t *testing.T) { + if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular destination open", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyRegularSkillFile(src, dst, 0o600); err == nil { + t.Fatal("expected exclusive create failure") + } + }) + + for _, tc := range []struct { + name string + swap func(*testing.T) + }{ + {name: "copy", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") }) + }}, + {name: "sync", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tc.swap(t) + if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { + root := t.TempDir() + + t.Run("source and destination lstat", func(t *testing.T) { + if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) { + t.Fatalf("source error = %v", err) + } + src := filepath.Join(root, "source") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) { + t.Fatalf("destination error = %v", err) + } + }) + + t.Run("directory and file modes", func(t *testing.T) { + for _, tc := range []struct { + name string + dir bool + }{ + {name: "directory", dir: true}, + {name: "file"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst") + if tc.dir { + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o755); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("directory read errors", func(t *testing.T) { + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("nested mismatch", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("symlink reads", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Symlink("one", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := os.Symlink("one", dst); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathReadlink + testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) { + if (path == dst) == failDest { + return "", os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("file digest opens", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathOpen + testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("special path", func(t *testing.T) { + fake := skillPathFakeInfo{mode: os.ModeNamedPipe} + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil }) + if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry names", func(t *testing.T) { + if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("digest open and read", func(t *testing.T) { + if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) { + t.Fatalf("open error = %v", err) + } + file := filepath.Join(root, "digest-file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") }) + if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + }) +} + +type skillPathFakeInfo struct { + mode os.FileMode +} + +func (skillPathFakeInfo) Name() string { return "fake" } +func (skillPathFakeInfo) Size() int64 { return 0 } +func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode } +func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} } +func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() } +func (skillPathFakeInfo) Sys() any { return nil } + +type skillPathErrorDirEntry struct{} + +func (skillPathErrorDirEntry) Name() string { return "broken" } +func (skillPathErrorDirEntry) IsDir() bool { return true } +func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } +func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } From 567ea5d77c905bcd9e3e78cb8147cda6b8b4d17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:08:40 +0800 Subject: [PATCH 057/267] test(skills): make mode checks portable on Windows --- .../upgrade/skill_path_move_errors_test.go | 28 +++++++------------ internal/upgrade/skill_path_move_test.go | 6 ++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 5c151934..46b3a60c 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -313,28 +313,20 @@ func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { t.Run("directory and file modes", func(t *testing.T) { for _, tc := range []struct { name string - dir bool + src os.FileMode + dst os.FileMode }{ - {name: "directory", dir: true}, - {name: "file"}, + {name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755}, + {name: "file", src: 0o600, dst: 0o644}, } { t.Run(tc.name, func(t *testing.T) { - src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst") - if tc.dir { - if err := os.Mkdir(src, 0o700); err != nil { - t.Fatal(err) + src, dst := "src", "dst" + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src { + return skillPathFakeInfo{mode: tc.src}, nil } - if err := os.Mkdir(dst, 0o755); err != nil { - t.Fatal(err) - } - } else { - if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - } + return skillPathFakeInfo{mode: tc.dst}, nil + }) if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { t.Fatalf("error = %v", err) } diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 5567d1c2..4140fd6a 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -49,8 +49,10 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { t.Fatalf("copied file = %q, %v", got, err) } - if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil || info.Mode().Perm() != 0o640 { - t.Fatalf("copied file mode = %v, %v", info, err) + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil { + t.Fatalf("copied file stat = %v, %v", info, err) + } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm()) } if linksCreated { for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { From 8f3a9e9d4a041319c3eb7cc63029a95162752188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:22:03 +0800 Subject: [PATCH 058/267] test(skills): cover Windows permission preparation seams --- .../upgrade/skill_path_move_errors_test.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 46b3a60c..ca3c959d 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -134,6 +134,15 @@ func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { } func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("source preparation error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error { + return errors.New("prepare failed") + }) + if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") { + t.Fatalf("error = %v", err) + } + }) + t.Run("walk error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, nil, errors.New("walk failed")) @@ -143,6 +152,25 @@ func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) } }) + t.Run("writable mode preparation", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + var chmodMode os.FileMode + testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error { + chmodMode = mode + return nil + }) + modes, err := prepareSkillPathTreeRemoval("source") + if err != nil { + t.Fatal(err) + } + if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 { + t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes) + } + }) + t.Run("entry info error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, skillPathErrorDirEntry{}, nil) @@ -475,3 +503,14 @@ func (skillPathErrorDirEntry) Name() string { return "broken" } func (skillPathErrorDirEntry) IsDir() bool { return true } func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } + +type skillPathModeDirEntry struct { + mode os.FileMode +} + +func (skillPathModeDirEntry) Name() string { return "directory" } +func (skillPathModeDirEntry) IsDir() bool { return true } +func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() } +func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) { + return skillPathFakeInfo{mode: entry.mode}, nil +} From 09fc5d993dd35ffcc29eb4914b3deece92b6cde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 10:32:20 +0800 Subject: [PATCH 059/267] test(skills): cover Windows chmod failure branch --- internal/upgrade/skill_path_move_errors_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index ca3c959d..b9d00850 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -171,6 +171,19 @@ func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) } }) + t.Run("writable mode preparation chmod error", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { + return errors.New("chmod failed") + }) + if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + }) + t.Run("entry info error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { return fn(root, skillPathErrorDirEntry{}, nil) From c0da89e6742fec71a92a41c4abf491a02317a936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:33:55 +0800 Subject: [PATCH 060/267] fix(skills): make app-bundle detection gate HOME-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in production: systemHome came from os.UserHomeDir, which honors the $HOME env override just like homeDir, so the two were always equal and the gate never fired when $HOME was overridden. ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix), which is independent of $HOME, falling back to $HOME only when the user record cannot be resolved. Production behavior is unchanged (a real $HOME still matches); an isolated/overridden HOME now correctly skips machine-wide /Applications discovery for zcode/minimax. The app surface references the same shared resolver. This is the correct fix for the hermeticity concern (machine-wide state leaking into an isolated HOME): there is no cross-surface production inconsistency to port — script installers always operate on the real user HOME in practice, so they need no gate. --- internal/app/skill_setup.go | 2 +- internal/upgrade/paths.go | 20 +++++++++++- internal/upgrade/system_home_test.go | 49 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 internal/upgrade/system_home_test.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 6c0becf4..2d2abf24 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = os.UserHomeDir + skillSetupSystemHomeDir = upgrade.ResolveSystemHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f0424ae6..d0edb495 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "os/user" "path/filepath" "runtime" "sort" @@ -139,9 +140,26 @@ var universalSkillDirs = map[string]bool{ ".amp/skills": true, } +// upgradeCurrentUser is the test seam backing ResolveSystemHomeDir. +var upgradeCurrentUser = user.Current + +// ResolveSystemHomeDir returns the operating-system user's real home directory +// independent of the $HOME environment variable. $HOME may be overridden in +// isolated or verifier contexts, so the app-bundle detection gate +// (allowSystemApps = homeDir == systemHome) must compare against a HOME- +// independent source to actually mean "operating on the real user profile". +// It prefers the OS user database (getpwuid on Unix) and falls back to $HOME +// only when the user record cannot be resolved. +func ResolveSystemHomeDir() (string, error) { + if u, err := upgradeCurrentUser(); err == nil && u.HomeDir != "" { + return u.HomeDir, nil + } + return os.UserHomeDir() +} + var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = os.UserHomeDir + upgradeSystemHomeDir = ResolveSystemHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir diff --git a/internal/upgrade/system_home_test.go b/internal/upgrade/system_home_test.go new file mode 100644 index 00000000..7870742f --- /dev/null +++ b/internal/upgrade/system_home_test.go @@ -0,0 +1,49 @@ +package upgrade + +import ( + "errors" + "os/user" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSystemHomeIndependentOfHOME pins that the app-bundle +// detection gate compares against a HOME-independent system home: ResolveSystemHomeDir +// prefers the OS user record (getpwuid on Unix) and only falls back to $HOME when that +// record cannot be resolved. Without this, allowSystemApps = homeDir == systemHome is a +// no-op whenever $HOME is overridden, because both sides would honor the same override. +func TestCrossPlatformCoverageSystemHomeIndependentOfHOME(t *testing.T) { + // Success path: the OS user database home is returned as-is. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return &user.User{HomeDir: "/tmp/dws-real-system-home"}, nil + }) + got, err := ResolveSystemHomeDir() + if err != nil || got != "/tmp/dws-real-system-home" { + t.Fatalf("ResolveSystemHomeDir success = (%q, %v), want /tmp/dws-real-system-home", got, err) + } + + // Fallback path: when the user record is unavailable, fall back to $HOME. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return nil, errors.New("user record unavailable") + }) + got, err = ResolveSystemHomeDir() + if err != nil { + t.Fatalf("ResolveSystemHomeDir fallback returned error: %v", err) + } + if got == "" { + t.Fatalf("ResolveSystemHomeDir fallback returned empty home") + } + + // Empty HomeDir is also treated as "unresolved" and falls back. + testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { + return &user.User{HomeDir: ""}, nil + }) + got, err = ResolveSystemHomeDir() + if err != nil { + t.Fatalf("ResolveSystemHomeDir empty-home fallback returned error: %v", err) + } + if got == "" { + t.Fatalf("ResolveSystemHomeDir empty-home fallback returned empty home") + } +} From 9abcdb4debce690463d9fefff44b112741d36440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:43:57 +0800 Subject: [PATCH 061/267] Revert "fix(skills): make app-bundle detection gate HOME-independent" This reverts commit 37cd6293352b6928da6ecd095dbd31e500be3338. --- internal/app/skill_setup.go | 2 +- internal/upgrade/paths.go | 20 +----------- internal/upgrade/system_home_test.go | 49 ---------------------------- 3 files changed, 2 insertions(+), 69 deletions(-) delete mode 100644 internal/upgrade/system_home_test.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 2d2abf24..6c0becf4 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,7 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = upgrade.ResolveSystemHomeDir + skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index d0edb495..f0424ae6 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "os" - "os/user" "path/filepath" "runtime" "sort" @@ -140,26 +139,9 @@ var universalSkillDirs = map[string]bool{ ".amp/skills": true, } -// upgradeCurrentUser is the test seam backing ResolveSystemHomeDir. -var upgradeCurrentUser = user.Current - -// ResolveSystemHomeDir returns the operating-system user's real home directory -// independent of the $HOME environment variable. $HOME may be overridden in -// isolated or verifier contexts, so the app-bundle detection gate -// (allowSystemApps = homeDir == systemHome) must compare against a HOME- -// independent source to actually mean "operating on the real user profile". -// It prefers the OS user database (getpwuid on Unix) and falls back to $HOME -// only when the user record cannot be resolved. -func ResolveSystemHomeDir() (string, error) { - if u, err := upgradeCurrentUser(); err == nil && u.HomeDir != "" { - return u.HomeDir, nil - } - return os.UserHomeDir() -} - var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = ResolveSystemHomeDir + upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir diff --git a/internal/upgrade/system_home_test.go b/internal/upgrade/system_home_test.go deleted file mode 100644 index 7870742f..00000000 --- a/internal/upgrade/system_home_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package upgrade - -import ( - "errors" - "os/user" - "testing" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" -) - -// TestCrossPlatformCoverageSystemHomeIndependentOfHOME pins that the app-bundle -// detection gate compares against a HOME-independent system home: ResolveSystemHomeDir -// prefers the OS user record (getpwuid on Unix) and only falls back to $HOME when that -// record cannot be resolved. Without this, allowSystemApps = homeDir == systemHome is a -// no-op whenever $HOME is overridden, because both sides would honor the same override. -func TestCrossPlatformCoverageSystemHomeIndependentOfHOME(t *testing.T) { - // Success path: the OS user database home is returned as-is. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return &user.User{HomeDir: "/tmp/dws-real-system-home"}, nil - }) - got, err := ResolveSystemHomeDir() - if err != nil || got != "/tmp/dws-real-system-home" { - t.Fatalf("ResolveSystemHomeDir success = (%q, %v), want /tmp/dws-real-system-home", got, err) - } - - // Fallback path: when the user record is unavailable, fall back to $HOME. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return nil, errors.New("user record unavailable") - }) - got, err = ResolveSystemHomeDir() - if err != nil { - t.Fatalf("ResolveSystemHomeDir fallback returned error: %v", err) - } - if got == "" { - t.Fatalf("ResolveSystemHomeDir fallback returned empty home") - } - - // Empty HomeDir is also treated as "unresolved" and falls back. - testseam.Swap(t, &upgradeCurrentUser, func() (*user.User, error) { - return &user.User{HomeDir: ""}, nil - }) - got, err = ResolveSystemHomeDir() - if err != nil { - t.Fatalf("ResolveSystemHomeDir empty-home fallback returned error: %v", err) - } - if got == "" { - t.Fatalf("ResolveSystemHomeDir empty-home fallback returned empty home") - } -} From 04f78bcb154bbac77acfc20c733b3ba6f533ec01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 11:58:27 +0800 Subject: [PATCH 062/267] fix(skills): remove ineffective app detection gate --- internal/app/skill_setup.go | 17 ++++++--------- internal/app/skill_setup_canonical_test.go | 3 ++- internal/upgrade/canonical_skills_test.go | 5 +++-- internal/upgrade/paths.go | 25 +++++++++------------- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 6c0becf4..9a4ebfb8 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -127,7 +127,6 @@ var ( skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir - skillSetupSystemHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir skillSetupRestoreBackup = upgrade.RestoreSkillPath @@ -1097,24 +1096,20 @@ func detectExistingAgentHomes(home, mode string) []string { dests := []string{canonical} canonicalKey := skillSetupPathKey(canonical) seen := map[string]bool{canonicalKey: true} - systemHome, _ := skillSetupSystemHomeDir() - allowSystemApps := sameSkillSetupPath(home, systemHome) addDetected := func(rel, base string) { detectedDir := filepath.Dir(base) switch filepath.ToSlash(filepath.Clean(rel)) { case ".config/kimchi/harness/skills", ".tabnine/agent/skills": detectedDir = filepath.Dir(filepath.Dir(base)) case ".zcode/skills": - if allowSystemApps { - if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { - detectedDir = "" - } + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so setup matches npm, Shell, and PowerShell. + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" } case ".minimax/skills": - if allowSystemApps { - if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { - detectedDir = "" - } + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" } } if detectedDir != "" { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index c308298b..1932e55b 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -101,8 +101,9 @@ func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t * } func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. home := t.TempDir() - testseam.Swap(t, &skillSetupSystemHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 17f27a27..56d22520 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -162,8 +162,9 @@ func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) } func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. home := t.TempDir() - testseam.Swap(t, &upgradeSystemHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -196,7 +197,7 @@ func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t for _, root := range configuredSkillRoots(home) { label := filepath.ToSlash(root.label) if _, ok := want[label]; ok { - want[label] = skillRootDetectedBase(home, root) + want[label] = skillRootDetectedBase(root) } } for label, detected := range want { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f0424ae6..7b0ecc49 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -141,7 +141,6 @@ var universalSkillDirs = map[string]bool{ var ( upgradeUserHomeDir = os.UserHomeDir - upgradeSystemHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir @@ -693,26 +692,22 @@ func configuredSkillRoots(homeDir string) []resolvedSkillRoot { return roots } -func skillRootDetectedBase(homeDir string, root resolvedSkillRoot) bool { +func skillRootDetectedBase(root resolvedSkillRoot) bool { detectedDir := filepath.Dir(root.base) - systemHome, _ := upgradeSystemHomeDir() - allowSystemApps := skillRootPathKey(homeDir) == skillRootPathKey(systemHome) switch filepath.ToSlash(filepath.Clean(root.label)) { case ".config/kimchi/harness/skills": detectedDir = filepath.Dir(filepath.Dir(root.base)) case ".tabnine/agent/skills": detectedDir = filepath.Dir(filepath.Dir(root.base)) case ".zcode/skills": - if allowSystemApps { - if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { - return true - } + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so upgrade matches npm, Shell, and PowerShell. + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true } case ".minimax/skills": - if allowSystemApps { - if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { - return true - } + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true } } info, err := upgradeStat(detectedDir) @@ -738,7 +733,7 @@ func skillRootPathKey(path string) string { func hasDependentSkillRoot(homeDir, canonicalBase string) bool { for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(homeDir, root) { + if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root) { continue } if !samePhysicalSkillRoot(root.base, canonicalBase) { @@ -848,7 +843,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(homeDir, root) { + if !skillRootDetectedBase(root) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -920,7 +915,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetectedBase(homeDir, root) { + if !skillRootDetectedBase(root) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } From 437dd234b2a76b6b05e72d9a0a5b7941603d9ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 14:27:57 +0800 Subject: [PATCH 063/267] fix(skills): fail upgrade unconditionally when canonical publish fails A failed canonical publish only failed the upgrade when hasDependentSkillRoot reported a non-universal link target; that helper explicitly skipped universal agents, which are exactly the direct consumers of ~/.agents/skills. On a universal-only machine (e.g. only Codex installed), UpgradeSkillLocations* returned a nil error with nothing installed, contradicting the documented "canonical publication is mandatory and fails the upgrade loudly" contract. Canonical publish failures now return an error unconditionally in both the mono and multi branches, and hasDependentSkillRoot is removed. The test that pinned the old standalone-does-not-fail-fast behavior now asserts error propagation in both modes. --- internal/upgrade/canonical_skills_test.go | 17 ++++++------- internal/upgrade/paths.go | 29 +++++++---------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 56d22520..eee92fa4 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -263,10 +263,14 @@ func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t } } -func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) { +func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) { for _, mode := range []string{"mono", "multi"} { t.Run(mode, func(t *testing.T) { - home := withFakeHome(t) + withFakeHome(t) + // Canonical-only configuration (no non-universal dependents): a failed + // canonical publish must still fail the whole upgrade. Universal agents + // read canonical directly, so silently succeeding here would leave the + // machine without any usable Skill while reporting success. testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) @@ -279,15 +283,12 @@ func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents src = writeMultiBundle(t, src, "dingtalk-chat") } result, err := UpgradeSkillLocations(src) - if err != nil { - t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("canonical publish failure must propagate an error, got (%v)", err) } - if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { t.Fatalf("result = %#v", result) } - if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) { - t.Fatal("canonical-only configuration unexpectedly has a dependent root") - } }) } } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 7b0ecc49..1eab7cf1 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -731,18 +731,6 @@ func skillRootPathKey(path string) string { return clean } -func hasDependentSkillRoot(homeDir, canonicalBase string) bool { - for _, root := range configuredSkillRoots(homeDir) { - if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root) { - continue - } - if !samePhysicalSkillRoot(root.base, canonicalBase) { - return true - } - } - return false -} - func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { @@ -826,10 +814,11 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e canonicalDest := filepath.Join(canonicalBase, "dws") if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) - if hasDependentSkillRoot(homeDir, canonicalBase) { - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) - } - return result, nil + // Canonical is the mandatory store: universal agents read it directly, so a + // failed canonical publish means nobody received the upgrade. Always fail + // loud — including universal-only machines — instead of reporting success + // with nothing installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) } result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) @@ -899,10 +888,10 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk canonicalBase := filepath.Join(homeDir, ".agents", "skills") if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) - if hasDependentSkillRoot(homeDir, canonicalBase) { - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) - } - return result, nil + // Canonical is the mandatory store (see the mono branch): fail loud even + // on universal-only machines instead of reporting success with nothing + // installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) } result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) From 7b64576ea1966ab4f59f045f3e79a1cdcac539e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 15:35:25 +0800 Subject: [PATCH 064/267] fix(skills): protect shell link rollback from races --- scripts/install-skills.sh | 77 +++++++++++++++++++++-- scripts/install.sh | 83 +++++++++++++++++++++++-- test/scripts/install_script_test.go | 95 +++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 12 deletions(-) diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 4a7c486b..33f80429 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -238,6 +238,49 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then + rm -f "$_rls_dest" || _rls_ok=0 + else + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest" + _rls_ok=0 + fi + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + rm -f "$_cnsl_nested" + fi +} + # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir @@ -537,6 +580,7 @@ link_canonical_skills_to_base() { _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } if [ "$_lcs_mode" = "mono" ]; then @@ -553,7 +597,8 @@ link_canonical_skills_to_base() { if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j> "$_lcs_published" || { - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1; + _lcs_dest="$_lcs_base/$_lcs_name" + _lcs_stage_name="${_lcs_stage_token}.${_lcs_name}" + _lcs_staged="$_lcs_stage/$_lcs_stage_name" + _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi - printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name" + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + if ! rm -f "$_lcs_staged"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_dest" done rm -rf "$_lcs_stage" } diff --git a/scripts/install.sh b/scripts/install.sh index b494a402..a6586676 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -257,6 +257,49 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then + rm -f "$_rls_dest" || _rls_ok=0 + else + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rls_dest" + _rls_ok=0 + fi + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + rm -f "$_cnsl_nested" + fi +} + # publish_skill_cache # Copies a complete cache into a sibling staging directory, then publishes it # with rename. Copy/publish failures retain the previous cache; if restoration @@ -773,6 +816,7 @@ link_canonical_skills_to_base() { _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } if [ "$_lcs_mode" = "mono" ]; then @@ -791,7 +835,8 @@ link_canonical_skills_to_base() { fi _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j> "$_lcs_published" || { - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + _lcs_dest="$_lcs_base/$_lcs_name" + _lcs_stage_name="${_lcs_stage_token}.${_lcs_name}" + _lcs_staged="$_lcs_stage/$_lcs_stage_name" + _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then - restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 fi - say " ↪ Skills → $_lcs_base/$_lcs_name" + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! rm -f "$_lcs_staged"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_dest" done rm -rf "$_lcs_stage" } diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 44620d0a..d23f5e44 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1503,6 +1503,101 @@ func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { } } +func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +ln() { + if [ "$1" = -P ] && [ "$3" = "$DWS_TEST_SECOND" ]; then + rm -f "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" + printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" + fi + command ln "$@" +} +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then + exit 2 +fi +identity_dest="$DWS_TEST_BASE/identity-link" +identity_manifest="$HOME/identity.manifest" +command ln -s ../same-target "$identity_dest" +identity_inode="$(skill_link_inode "$identity_dest")" +printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" +rm -f "$identity_dest" +command ln -s ../same-target "$identity_dest" +if restore_linked_skill_set "$identity_manifest" /dev/null; then + exit 3 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not report identity-protected rollback:\n%s", scriptName, output) + } + identityDest := filepath.Join(base, "identity-link") + if target, err := os.Readlink(identityDest); err != nil || target != "../same-target" { + t.Fatalf("%s replacement link was removed: target=%q, err=%v", scriptName, target, err) + } + for path, want := range map[string]string{ + filepath.Join(first, "user.txt"): "first-user-data\n", + filepath.Join(second, "user.txt"): "second-user-data\n", + } { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s concurrent data at %s = %q, err=%v", scriptName, path, got, err) + } + } + for _, dir := range []string{first, second} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("%s read concurrent directory %s: %v", scriptName, dir, err) + } + if len(entries) != 1 || entries[0].Name() != "user.txt" { + t.Fatalf("%s transaction artifacts remain in %s: %v", scriptName, dir, entries) + } + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-link-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { From dc180f6d07f4674639dc9c81e0804a7cf8b81d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 15:45:51 +0800 Subject: [PATCH 065/267] fix(skills): retain link identity anchors through rollback --- scripts/install-skills.sh | 21 ++++++++++----------- scripts/install.sh | 27 ++++++++++++--------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 33f80429..c5b9a276 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -276,7 +276,8 @@ restore_linked_skill_set() { cleanup_nested_staged_link() { _cnsl_nested="$1/$2" _cnsl_target="$3" - if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then rm -f "$_cnsl_nested" fi } @@ -618,20 +619,18 @@ link_canonical_skills_to_base() { _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } + # Keep the staged hardlink alive until the whole transaction finishes. It + # pins the inode, so a concurrently recreated link cannot reuse the + # identity recorded in the publication manifest. + _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } # Hard-link the staged symlink itself. This never overwrites a # concurrently-created destination. If that destination became a # directory, the unique staged basename can only be linked inside it; # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 - fi - _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { - if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 - } - if ! rm -f "$_lcs_staged"; then - if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then diff --git a/scripts/install.sh b/scripts/install.sh index a6586676..45e5a688 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -295,7 +295,8 @@ restore_linked_skill_set() { cleanup_nested_staged_link() { _cnsl_nested="$1/$2" _cnsl_target="$3" - if skill_link_matches "$_cnsl_nested" "$_cnsl_target"; then + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then rm -f "$_cnsl_nested" fi } @@ -861,24 +862,20 @@ link_canonical_skills_to_base() { rm -rf "$_lcs_stage" return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" || true - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true - rm -rf "$_lcs_stage" - return 1 - fi - _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { - if skill_link_matches "$_lcs_dest" "$_lcs_link_target"; then rm -f "$_lcs_dest" || true; fi + # Keep the staged hardlink alive until the whole transaction finishes. It + # pins the inode, so a concurrently recreated link cannot reuse the + # identity recorded in the publication manifest. + _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - if ! rm -f "$_lcs_staged"; then - if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + # Hard-link the staged symlink itself. This never overwrites a + # concurrently-created destination. If that destination became a + # directory, the unique staged basename can only be linked inside it; + # post-validation detects that case and removes only our link. + if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 From b080b6e7c55dfb15ad2d9dc8c09a145641bcfc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:09:24 +0800 Subject: [PATCH 066/267] test(skills): retain rollback identity anchor --- test/scripts/install_script_test.go | 212 +++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 2 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d23f5e44..fdbe8b5d 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,6 +226,7 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -242,6 +243,210 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required []string + forbidden []string + }{ + { + filepath.Join("..", "..", "scripts", "install.ps1"), + []string{ + "function Remove-LinkStageRoot", + // Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse; published junctions must be removed + // lexically so rollback never deletes canonical content. + "Remove-SkillPathLexically -Path $Published[$i]", + "Remove-LinkStageRoot -StageRoot $stageRoot", + }, + []string{"Remove-Item -LiteralPath $Published[$i] -Recurse -Force"}, + }, + { + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), + []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + }, + []string{}, + }, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + for _, want := range check.required { + if !strings.Contains(text, want) { + t.Errorf("%s missing junction-safe removal contract %q", check.path, want) + } + } + for _, avoid := range check.forbidden { + if strings.Contains(text, avoid) { + t.Errorf("%s still removes junction-capable rollback paths recursively: %q", check.path, avoid) + } + } + } +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$link = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $link -Target $canonical | Out-Null +if (Test-SamePhysicalSkillRoot -Left $link -Right $canonical) { exit 0 } +exit 3 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := probeCmd.CombinedOutput(); err != nil { + t.Fatalf("junction must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(output)) + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1544,15 +1749,18 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_dest" -identity_inode="$(skill_link_inode "$identity_dest")" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi +rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From 3f39a9ddb1713271ee0b6d477fc91b544918cfe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:11:13 +0800 Subject: [PATCH 067/267] Revert "test(skills): retain rollback identity anchor" This reverts commit ce73a5b4529c0df1de34b6397e6eb146fa35f8d2. --- test/scripts/install_script_test.go | 212 +--------------------------- 1 file changed, 2 insertions(+), 210 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index fdbe8b5d..d23f5e44 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,7 +226,6 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, - {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -243,210 +242,6 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } -func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { - t.Parallel() - - checks := []struct { - path string - required []string - forbidden []string - }{ - { - filepath.Join("..", "..", "scripts", "install.ps1"), - []string{ - "function Remove-LinkStageRoot", - // Windows PowerShell 5.1 follows reparse points during - // Remove-Item -Recurse; published junctions must be removed - // lexically so rollback never deletes canonical content. - "Remove-SkillPathLexically -Path $Published[$i]", - "Remove-LinkStageRoot -StageRoot $stageRoot", - }, - []string{"Remove-Item -LiteralPath $Published[$i] -Recurse -Force"}, - }, - { - filepath.Join("..", "..", "scripts", "install-devapp.ps1"), - []string{ - "function Remove-DevLinkStageRoot", - "Remove-DevLinkStageRoot $stageRoot", - }, - []string{}, - }, - } - for _, check := range checks { - data, err := os.ReadFile(check.path) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", check.path, err) - } - text := string(data) - for _, want := range check.required { - if !strings.Contains(text, want) { - t.Errorf("%s missing junction-safe removal contract %q", check.path, want) - } - } - for _, avoid := range check.forbidden { - if strings.Contains(text, avoid) { - t.Errorf("%s still removes junction-capable rollback paths recursively: %q", check.path, avoid) - } - } - } -} - -func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { - t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("POSIX shell installer test is for unix-like hosts") - } - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skipf("unsupported test os %s", runtime.GOOS) - } - if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { - t.Skipf("unsupported test arch %s", runtime.GOARCH) - } - - root := t.TempDir() - fakeHome := filepath.Join(root, "home") - installDir := filepath.Join(root, "bin") - releaseDir := filepath.Join(root, "release") - stubRoot := filepath.Join(root, "stubs") - - assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" - if err := os.MkdirAll(releaseDir, 0o755); err != nil { - t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) - } - writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ - "dws": "fake-event-binary\n", - }) - writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ - "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", - "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", - "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", - "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", - }) - writeInstallerFixtureChecksums(t, releaseDir) - writeFakeCurl(t, filepath.Join(stubRoot, "curl")) - - // A regular file where an agent skill base belongs makes that one agent - // target uninstallable, while later agents (.zcode) stay reachable. - mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) - mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) - - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) - if err != nil { - t.Fatalf("Abs(install-event.sh) error = %v", err) - } - cmd := exec.Command("sh", scriptPath) - cmd.Env = append(os.Environ(), - "HOME="+fakeHome, - "PATH="+stubRoot+":"+os.Getenv("PATH"), - "EVENT_VERSION=v1.0.51", - "DWS_INSTALL_DIR="+installDir, - "FAKE_RELEASE_DIR="+releaseDir, - "FAKE_ASSET_NAME="+assetName, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) - } - got := string(output) - for _, want := range []string{ - "Agent 目标安装失败,已跳过", - "dingtalk-event 分发到 Agent 目录失败", - } { - if !strings.Contains(got, want) { - t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) - } - } - // The failed agent must not abort the loop: later agents still get links. - if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { - t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) - } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { - t.Fatalf("canonical Skill missing: %v", err) - } -} - -func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { - pwsh, err := exec.LookPath("pwsh") - if err != nil { - if runtime.GOOS == "windows" { - pwsh, err = exec.LookPath("powershell") - } - if err != nil { - t.Skip("PowerShell is not available") - } - } - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(scriptPath) - if err != nil { - t.Fatal(err) - } - cut := strings.LastIndex(string(data), "# ── Main") - if cut < 0 { - t.Fatal("install.ps1 main section not found") - } - prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") - probe := prefix + ` -$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' -New-Item -ItemType Directory -Path $canonical -Force | Out-Null -$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' -New-Item -ItemType Directory -Path $linkParent -Force | Out-Null -$link = Join-Path $linkParent 'dingtalk-chat' -New-Item -ItemType Junction -Path $link -Target $canonical | Out-Null -if (Test-SamePhysicalSkillRoot -Left $link -Right $canonical) { exit 0 } -exit 3 -` - probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") - mustWriteFile(t, probePath, []byte(probe), 0o644) - home := t.TempDir() - probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) - probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) - if output, err := probeCmd.CombinedOutput(); err != nil { - t.Fatalf("junction must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(output)) - } - - // A rerun over an already-linked agent base must be idempotent: the - // junction is recognized as published and never re-backed-up. - install := prefix + ` -if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } -exit 0 -` - installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") - mustWriteFile(t, installPath, []byte(install), 0o644) - multi := filepath.Join(t.TempDir(), "multi") - mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) - mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) - - runInstall := func() string { - cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) - cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) - } - return string(output) - } - countBackups := func() int { - entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) - if err != nil { - return 0 - } - return len(entries) - } - runInstall() - first := countBackups() - runInstall() - if second := countBackups(); second != first { - t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) - } - linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) - if err != nil || string(linkData) != "new chat\n" { - t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) - } -} - func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1749,18 +1544,15 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" -identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_anchor" -command ln -P "$identity_anchor" "$identity_dest" -identity_inode="$(skill_link_inode "$identity_anchor")" +command ln -s ../same-target "$identity_dest" +identity_inode="$(skill_link_inode "$identity_dest")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi -rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From e4a1feccf00a0364979fbbf81928224a3c97cb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:11:48 +0800 Subject: [PATCH 068/267] test(skills): retain rollback identity anchor --- test/scripts/install_script_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d23f5e44..f05ff704 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1544,15 +1544,18 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_dest" -identity_inode="$(skill_link_inode "$identity_dest")" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi +rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(), From a789a2eea7333fb17ce7a8b1ac532bee61ec5952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 16:47:28 +0800 Subject: [PATCH 069/267] fix(skills): junction-safe PowerShell rollback and per-agent degrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.ps1: remove published junctions lexically in Restore-MultiSkillSet (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse and could delete canonical store contents); clean link staging dirs lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably - install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns recognize already-published junctions instead of backup churn - install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with the loud backup-retained failure contract already enforced for devapp - event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade per agent like install.sh (skip loudly, continue, report at the end) instead of aborting mid-loop or swallowing errors - tests: junction-lexical removal contract, event per-agent degrade integration test, pwsh junction physical-root recognition + rerun idempotency (no backup churn) --- scripts/install-devapp.ps1 | 49 +++++++++++++++++++++++---- scripts/install-devapp.sh | 32 +++++++++++++++--- scripts/install-event.sh | 51 +++++++++++++++++++++++----- scripts/install.ps1 | 69 +++++++++++++++++++++++++++++++++----- 4 files changed, 173 insertions(+), 28 deletions(-) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index e8cc3bce..ce88a4a3 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -202,6 +202,20 @@ function Remove-DevSkillPathLexically([string]$Path) { } } +# Removes a staging directory that may hold junction/symlink children. Link +# children must be removed non-recursively first: Windows PowerShell 5.1 can +# follow reparse points during Remove-Item -Recurse and would delete the +# canonical store's contents through the staged link. +function Remove-DevLinkStageRoot([string]$StageRoot) { + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false } + } + try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false } + return $ok +} + function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } $destinationParent = Split-Path $Destination -Parent @@ -220,7 +234,9 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { Assert-DevSkillPathCopy $Source $stage Move-DevSkillPath $stage $Destination Assert-DevSkillPathCopy $Source $Destination - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "Skill staging cleanup failed (published content untouched): $stageRoot" + } try { Remove-DevSkillPathLexically $Source } catch { throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" } @@ -228,8 +244,8 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { } catch { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { - try { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop } catch { - throw "$failure; cross-device Skill staging cleanup failed $stageRoot`: $_" + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" } } throw $failure @@ -276,7 +292,7 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { throw $publishFailure } } finally { - if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + Remove-DevLinkStageRoot $stageRoot | Out-Null } } @@ -302,7 +318,10 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { throw $publishFailure } } finally { - if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } + # The staged payload is a junction to the canonical store; it must be + # removed lexically or Windows PowerShell 5.1 recursion could delete + # canonical content through the reparse point. + Remove-DevLinkStageRoot $stageRoot | Out-Null } } @@ -404,6 +423,7 @@ if (-not $NoSkills) { $canonical = Join-Path $canonicalBase $SkillName Publish-DevSkillCopy $src $canonical $installed = 1 + $failed = 0 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { $parts = $row.Split('|'); $universal = $parts[1] -eq "1" @@ -413,12 +433,27 @@ if (-not $NoSkills) { if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName - if ($universal) { Backup-DevSkill $dest; continue } + if ($universal) { + try { Backup-DevSkill $dest } catch { + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录: $dest ($($_.Exception.Message))" + $failed++ + } + continue + } New-Item -ItemType Directory -Path $base -Force | Out-Null + # Per-agent degrade like install.sh: a failed link→copy fallback + # skips that agent loudly instead of aborting the whole loop. try { Publish-DevSkillJunction $canonical $dest } - catch { Publish-DevSkillCopy $canonical $dest } + catch { + try { Publish-DevSkillCopy $canonical $dest } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue + } + } $installed++ } + if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 70a631ad..698d9048 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -128,7 +128,14 @@ link_or_copy_skill() { target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi rm -rf "$stage" @@ -198,14 +205,31 @@ install_skill() { canonical="$HOME/.agents/skills/$SKILL_NAME" copy_tree "$src" "$canonical" || return 1 installed=1 + failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue - if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi - link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 + if is_cleanup_only_agent_dir "$agent_dir"; then + if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + continue + fi installed=$((installed + 1)) done + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 + return 1 + fi say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } @@ -248,7 +272,7 @@ main() { mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" - install_skill "$tmp/sk" + install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警" else say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)" fi diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 58c56cda..6ff57fc9 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -121,7 +121,9 @@ copy_tree() { backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage" "$dest"; then return 0; fi rm -rf "$stage" - [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -195,10 +197,20 @@ link_or_copy_skill() { target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi + if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi - rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true + rm -rf "$stage" + if [ -n "$backup" ] && ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi return 1 } @@ -208,14 +220,31 @@ install_skill_to_homes() { canonical="$HOME/.agents/skills/$skill_name" copy_tree "$src" "$canonical" || return 1 installed=1 + failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue - if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi - link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 + if is_cleanup_only_agent_dir "$agent_dir"; then + if ! backup_skill_dir "$base/$skill_name" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) + continue + fi installed=$((installed + 1)) done + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 + return 1 + fi printf '%s\n' "$installed" } @@ -276,10 +305,14 @@ install_skills_from_bundle() { mono_cache="$HOME/.dws/skills/mono" copy_tree "$mono_src" "$mono_cache" - event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" - shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" - misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" - mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" + event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ + || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \ + || err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \ + || err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \ + || err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 795ee1be..50540929 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -370,6 +370,52 @@ function Remove-SkillPathLexically { } } +# Removes a staging directory that may hold unpublished junction/symlink +# children. Every link child must be removed non-recursively first: Windows +# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points +# during Remove-Item -Recurse and would delete the canonical store's contents +# through the staged link. CI runs pwsh 7, which masks this behavior. +function Remove-LinkStageRoot { + param([string]$StageRoot) + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false } + } + try { + Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop + } catch { + $ok = $false + } + return $ok +} + +# Resolves a path to its physical location, dereferencing junction/symlink +# reparse points at any depth. Resolve-Path alone is lexical and never +# recognizes DWS's own junctions, so every rerun would back them up and +# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P +# (shell) on the other installer surfaces. +function Get-PhysicalSkillPath { + param([string]$Path, [int]$Depth = 0) + if ($Depth -gt 40) { return $null } + $parent = Split-Path $Path -Parent + if ([string]::IsNullOrWhiteSpace($parent)) { return $Path } + $parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1) + if ($null -eq $parentPhysical) { return $null } + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical } + $candidate = Join-Path $parentPhysical $leaf + try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } + $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 + if ($item.LinkType -and $target) { + if ([System.IO.Path]::IsPathRooted([string]$target)) { + return ([string]$target).TrimEnd([char[]]@('\', '/')) + } + return [System.IO.Path]::GetFullPath((Join-Path $parentPhysical ([string]$target))) + } + return $candidate +} + # Same-volume moves remain atomic. For a cross-volume backup/restore, stage on # the destination filesystem, copy links lexically, verify, publish, then # remove the source. Before source removal every failure keeps the original; @@ -394,7 +440,9 @@ function Move-SkillPathRecoverably { Assert-SkillPathCopy -Source $Source -Destination $stage Move-SkillPath -Source $stage -Destination $Destination Assert-SkillPathCopy -Source $Source -Destination $Destination - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "Skill staging 清理失败(已发布内容不受影响): $stageRoot" + } try { Remove-SkillPathLexically -Path $Source } catch { @@ -406,10 +454,8 @@ function Move-SkillPathRecoverably { } catch { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { - try { - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction Stop - } catch { - throw "$failure;跨设备 Skill staging 清理失败 $stageRoot`: $_" + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" } } throw $failure @@ -686,7 +732,11 @@ function Restore-MultiSkillSet { for ($i = $Published.Count - 1; $i -ge 0; $i--) { try { if (Test-SkillPathLexically -Path $Published[$i]) { - Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop + # Junction publications must never be removed with -Recurse: + # Windows PowerShell 5.1 can follow the reparse point and + # delete the canonical store's contents. Link targets remove + # lexically; copied directories still remove recursively. + Remove-SkillPathLexically -Path $Published[$i] } } catch { Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" @@ -772,7 +822,10 @@ function Test-AgentSkillBaseDetected { function Test-SamePhysicalSkillRoot { param([string]$Left, [string]$Right) if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } - try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false } + $leftPhysical = Get-PhysicalSkillPath -Path $Left + $rightPhysical = Get-PhysicalSkillPath -Path $Right + if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false } + return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/')) } function Move-AgentSkillRootToBackup { @@ -856,7 +909,7 @@ function Publish-CanonicalSkillLinks { Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" return $false } finally { - Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null } } From b664ace2f24fe42f7393891959ff7b03e6542c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 17:14:13 +0800 Subject: [PATCH 070/267] test(skills): junction-safe rollback and per-agent degrade regressions - extend the silent-rollback contract to install-event.sh - static contract: Restore-MultiSkillSet removes published paths lexically (section-scoped so identity-anchor refactors keep the guarantee) and link staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot - install-event.sh integration test: an uninstallable agent target is skipped loudly while later agents still receive links - pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and symlinks (junction idempotency asserted where junctions are creatable) --- test/scripts/install_script_test.go | 313 ++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index f05ff704..d69846ad 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,6 +226,7 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -242,6 +243,318 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + installPs1 := filepath.Join("..", "..", "scripts", "install.ps1") + data, err := os.ReadFile(installPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", installPs1, err) + } + text := string(data) + for _, want := range []string{ + "function Remove-LinkStageRoot", + "Remove-LinkStageRoot -StageRoot $stageRoot", + } { + if !strings.Contains(text, want) { + t.Errorf("install.ps1 missing junction-safe removal contract %q", want) + } + } + // The rollback of published junction paths must go through the lexical + // remover: Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse and could delete the canonical store's contents. + // Assert on the Restore-MultiSkillSet section so identity-anchor + // refactors keep the contract without breaking on variable naming. + begin := strings.Index(text, "function Restore-MultiSkillSet") + if begin < 0 { + t.Fatal("install.ps1 missing Restore-MultiSkillSet") + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + t.Fatal("install.ps1 Restore-MultiSkillSet section not terminated") + } + restore := text[begin : begin+1+end] + if !strings.Contains(restore, "Remove-SkillPathLexically") { + t.Errorf("install.ps1 Restore-MultiSkillSet must remove published paths lexically") + } + if strings.Contains(restore, "Remove-Item") && strings.Contains(restore, "-Recurse") { + t.Errorf("install.ps1 Restore-MultiSkillSet still contains a recursive removal:\n%s", restore) + } + + devappPs1 := filepath.Join("..", "..", "scripts", "install-devapp.ps1") + devappData, err := os.ReadFile(devappPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", devappPs1, err) + } + for _, want := range []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + } { + if !strings.Contains(string(devappData), want) { + t.Errorf("install-devapp.ps1 missing junction-safe removal contract %q", want) + } + } +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + // Prefer the advertised Windows PowerShell 5.1 irm|iex surface on Windows; + // pwsh 7 can mask junction removal and resolution differences. + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Probe Test-SamePhysicalSkillRoot with both link flavors: junctions need + // no privilege on Windows but silently no-op on non-Windows pwsh builds; + // symlinks need no privilege on Unix but may fail without Developer Mode + // on Windows. Exit 4 marks "no link flavor testable on this host". + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$other = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-shared' +New-Item -ItemType Directory -Path $other -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$asserted = 0 +$junction = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $junction) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } +} +$symlink = Join-Path $linkParent 'dingtalk-shared' +New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $symlink) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } +} +if ($asserted -eq 0) { exit 4 } +if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } +if (Test-Path -LiteralPath $junction) { exit 0 } +exit 4 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + probeOutput, probeErr := probeCmd.CombinedOutput() + if probeErr != nil { + exitCode := 0 + if exitErr, ok := probeErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + if exitCode != 4 { + t.Fatalf("link must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(probeOutput)) + } + return // junctions unavailable on this host (non-Windows pwsh): probed via symlink only + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + +func TestInstallPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$root = $env:DWS_TEST_HOME +$stage = Join-Path $root 'stage-payload' +$dest = Join-Path $root 'agent\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $stage -Force | Out-Null +New-Item -ItemType Directory -Path $dest -Force | Out-Null +Set-Content -LiteralPath (Join-Path $dest 'user-data.txt') -Value 'keep' +try { + Move-SkillPath -Source $stage -Destination $dest + exit 11 +} catch {} +if (!(Test-Path -LiteralPath $stage -PathType Container)) { exit 12 } +if (Test-Path -LiteralPath (Join-Path $dest 'stage-payload')) { exit 13 } +if (!(Test-Path -LiteralPath (Join-Path $dest 'user-data.txt') -PathType Leaf)) { exit 14 } + +# Simulate a later failure after publication, with another process replacing +# our link before rollback. Identity-aware rollback must retain its directory. +$canonical = Join-Path $root '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$publishedPath = Join-Path $root 'published-link' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +New-Item -ItemType $linkType -Path $publishedPath -Target $canonical -ErrorAction Stop | Out-Null +$record = New-PublishedSkillLinkRecord -Path $publishedPath +Remove-SkillPathLexically -Path $publishedPath +New-Item -ItemType Directory -Path $publishedPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -Value 'keep' +$restored = Restore-MultiSkillSet -Published @($record) -Backups @() +if ($restored) { exit 15 } +if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell publication race must retain concurrent directory: %v\n%s", err, string(output)) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() From a46958c7882a2afdd2c4c52e4b2527621db851a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 17:51:12 +0800 Subject: [PATCH 071/267] fix(skills): make PowerShell rollback race-safe --- scripts/install-devapp.ps1 | 97 ++++++++++++++++++++---- scripts/install.ps1 | 111 ++++++++++++++++++++++++---- test/scripts/install_script_test.go | 94 +++++++++++++++++++++++ 3 files changed, 275 insertions(+), 27 deletions(-) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index ce88a4a3..1719391d 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -110,7 +110,13 @@ function Test-PathLexically([string]$path) { } function Move-DevSkillPath([string]$Source, [string]$Destination) { - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Move($Source, $Destination) + } } function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { @@ -263,8 +269,60 @@ function Backup-DevSkill([string]$path, [ref]$BackupPath) { if ($null -ne $BackupPath) { $BackupPath.Value = $target } } -function Restore-DevSkillBackup([string]$Destination, [string]$Backup) { - if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } +function Get-DevSkillLinkSignature([string]$Path) { + try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null } + if (!$item.LinkType) { return $null } + return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0")) +} + +function New-DevRollbackFailure([string]$Message) { + $failure = [System.InvalidOperationException]::new($Message) + $failure.Data["DWSRollbackFailed"] = $true + return $failure +} + +function Remove-VerifiedDevSkillPublication( + [string]$Destination, + [string]$PublishedSource, + [string]$PublishedLinkSignature +) { + $parent = Split-Path $Destination -Parent + $quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N")) + Move-DevSkillPath $Destination $quarantine + try { + if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) { + if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) { + throw "published Skill link was replaced by another process: $Destination" + } + } elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) { + Assert-DevSkillPathCopy $PublishedSource $quarantine + } else { + throw "Skill restore destination is occupied by another process: $Destination" + } + Remove-DevSkillPathLexically $quarantine + } catch { + $failure = $_ + if (Test-PathLexically $quarantine) { + try { + if (Test-PathLexically $Destination) { throw "original path is occupied" } + Move-DevSkillPath $quarantine $Destination + } catch { + throw "$failure; concurrent object retained at $quarantine`: $_" + } + } + throw $failure + } +} + +function Restore-DevSkillBackup( + [string]$Destination, + [string]$Backup, + [string]$PublishedSource = "", + [string]$PublishedLinkSignature = "" +) { + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature + } if (![string]::IsNullOrWhiteSpace($Backup)) { Move-DevSkillPathRecoverably $Backup $Destination } @@ -276,6 +334,7 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" $backup = "" + $publishedSource = "" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null try { Copy-DevSkillPathLexically $Source $stage @@ -283,11 +342,12 @@ function Publish-DevSkillCopy([string]$Source, [string]$Destination) { Backup-DevSkill $Destination ([ref]$backup) try { Move-DevSkillPath $stage $Destination + $publishedSource = $Source Assert-DevSkillPathCopy $Source $Destination } catch { $publishFailure = $_ - try { Restore-DevSkillBackup $Destination $backup } catch { - throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") } throw $publishFailure } @@ -302,6 +362,7 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" $backup = "" + $publishedSignature = "" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null try { New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null @@ -310,10 +371,12 @@ function Publish-DevSkillJunction([string]$Target, [string]$Destination) { Move-DevSkillPath $stage $Destination $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + $publishedSignature = Get-DevSkillLinkSignature $Destination + if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" } } catch { $publishFailure = $_ - try { Restore-DevSkillBackup $Destination $backup } catch { - throw "$publishFailure; Skill rollback failed; backup retained at $backup`: $_" + try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") } throw $publishFailure } @@ -440,16 +503,22 @@ if (-not $NoSkills) { } continue } - New-Item -ItemType Directory -Path $base -Force | Out-Null # Per-agent degrade like install.sh: a failed link→copy fallback # skips that agent loudly instead of aborting the whole loop. - try { Publish-DevSkillJunction $canonical $dest } - catch { - try { Publish-DevSkillCopy $canonical $dest } catch { - Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" - $failed++ - continue + try { + New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null + try { Publish-DevSkillJunction $canonical $dest } + catch { + # A failed rollback leaves the old backup as the only + # trusted copy. Do not run a second publisher over that + # state; surface the failure and continue other agents. + if ($_.Exception.Data["DWSRollbackFailed"]) { throw } + Publish-DevSkillCopy $canonical $dest } + } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue } $installed++ } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 50540929..4cda9a7d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -258,7 +258,19 @@ function Move-SkillPath { [string]$Source, [string]$Destination ) - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-SkillPathLexically -Path $Destination) { + throw "Skill move destination already exists: $Destination" + } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + # Move-Item treats an existing directory as a container and nests the + # source below it. Directory.Move has the exact rename semantics this + # transaction requires: an occupied destination fails without touching + # either path. + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Move($Source, $Destination) + } } function Test-CrossDeviceMoveError { @@ -408,10 +420,16 @@ function Get-PhysicalSkillPath { try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 if ($item.LinkType -and $target) { + $targetPath = "" if ([System.IO.Path]::IsPathRooted([string]$target)) { - return ([string]$target).TrimEnd([char[]]@('\', '/')) + $targetPath = [string]$target + } else { + $targetPath = Join-Path $parentPhysical ([string]$target) } - return [System.IO.Path]::GetFullPath((Join-Path $parentPhysical ([string]$target))) + # Resolve the target recursively as well. A custom HOME or canonical + # root can itself sit below another junction, which must compare equal + # to the fully physical path just like EvalSymlinks/realpath do. + return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1) } return $candidate } @@ -723,6 +741,68 @@ function Backup-SkillDir { return $true } +function Get-SkillLinkSignature { + param([string]$Path) + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch { + return $null + } + if (!$item.LinkType) { return $null } + $targets = @($item.Target) | ForEach-Object { [string]$_ } + return ([string]$item.LinkType + "`0" + ($targets -join "`0")) +} + +function New-PublishedSkillLinkRecord { + param([string]$Path) + $signature = Get-SkillLinkSignature -Path $Path + if ([string]::IsNullOrEmpty($signature)) { + throw "published Skill path is not a link: $Path" + } + return [pscustomobject]@{ Path = $Path; LinkSignature = $signature } +} + +function New-PublishedSkillCopyRecord { + param([string]$Path, [string]$Source) + Assert-SkillPathCopy -Source $Source -Destination $Path + return [pscustomobject]@{ Path = $Path; Source = $Source } +} + +function Remove-PublishedSkillPathSafely { + param($Record) + $path = [string]$Record.Path + $parent = Split-Path $path -Parent + $quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N")) + + # Claim the current directory entry with an exact same-filesystem rename. + # If another process replaced our link, verification below fails and that + # object is moved back instead of ever being recursively removed. + Move-SkillPath -Source $path -Destination $quarantine + try { + if ($Record.LinkSignature) { + if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) { + throw "发布链接已被其他进程替换,拒绝删除" + } + } elseif ($Record.Source) { + Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine + } else { + throw "发布路径缺少事务身份,拒绝删除" + } + Remove-SkillPathLexically -Path $quarantine + } catch { + $failure = $_ + if (Test-SkillPathLexically -Path $quarantine) { + try { + if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" } + Move-SkillPath -Source $quarantine -Destination $path + } catch { + throw "$failure;并发对象保留于 $quarantine`: $_" + } + } + throw $failure + } +} + function Restore-MultiSkillSet { param( [array]$Published, @@ -730,16 +810,18 @@ function Restore-MultiSkillSet { ) $ok = $true for ($i = $Published.Count - 1; $i -ge 0; $i--) { + $publishedItem = $Published[$i] + $publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path } try { - if (Test-SkillPathLexically -Path $Published[$i]) { - # Junction publications must never be removed with -Recurse: - # Windows PowerShell 5.1 can follow the reparse point and - # delete the canonical store's contents. Link targets remove - # lexically; copied directories still remove recursively. - Remove-SkillPathLexically -Path $Published[$i] + if (Test-SkillPathLexically -Path $publishedPath) { + if (!($publishedItem -is [string])) { + Remove-PublishedSkillPathSafely -Record $publishedItem + } else { + Remove-SkillPathLexically -Path $publishedPath + } } } catch { - Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" + Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_" $ok = $false } } @@ -899,8 +981,11 @@ function Publish-CanonicalSkillLinks { } foreach ($name in $publishNames) { $dest = Join-Path $BaseDir $name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + # Only record a publication after the staged junction has occupied + # the exact destination. Rollback also checks this identity before + # deleting, so a concurrent user directory is never removed. + $published += New-PublishedSkillLinkRecord -Path $dest Write-Say "↪ Skills → $dest" } return $true @@ -1188,8 +1273,8 @@ function Install-MonoToBase { } } - $published += $dest Move-SkillPath -Source $stagedSkill -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc } catch { $transactionError = $_ if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) { @@ -1383,8 +1468,8 @@ function Install-MultiToBase { foreach ($skillDir in $skillDirs) { $dest = Join-Path $BaseDir $skillDir.Name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName } } catch { $transactionError = $_ diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d69846ad..006cabc2 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -417,12 +417,18 @@ New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction Sile if (Test-Path -LiteralPath $junction) { $asserted = 1 if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } + $junctionChain = Join-Path $linkParent 'dingtalk-chat-chain' + New-Item -ItemType Junction -Path $junctionChain -Target $junction -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $junctionChain -Right $canonical)) { exit 5 } } $symlink = Join-Path $linkParent 'dingtalk-shared' New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null if (Test-Path -LiteralPath $symlink) { $asserted = 1 if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } + $symlinkChain = Join-Path $linkParent 'dingtalk-shared-chain' + New-Item -ItemType SymbolicLink -Path $symlinkChain -Target $symlink -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $symlinkChain -Right $other)) { exit 5 } } if ($asserted -eq 0) { exit 4 } if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } @@ -544,6 +550,20 @@ Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') - $restored = Restore-MultiSkillSet -Published @($record) -Backups @() if ($restored) { exit 15 } if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } + +# Copied mono/multi publications use the same identity-protected rollback. +$copySource = Join-Path $root 'copy-source' +$copyPath = Join-Path $root 'published-copy' +New-Item -ItemType Directory -Path $copySource -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copySource 'SKILL.md') -Value 'transaction copy' +Copy-SkillPathLexically -Source $copySource -Destination $copyPath +$copyRecord = New-PublishedSkillCopyRecord -Path $copyPath -Source $copySource +Remove-SkillPathLexically -Path $copyPath +New-Item -ItemType Directory -Path $copyPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -Value 'keep' +$copyRestored = Restore-MultiSkillSet -Published @($copyRecord) -Backups @() +if ($copyRestored) { exit 17 } +if (!(Test-Path -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -PathType Leaf)) { exit 18 } exit 0 ` harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") @@ -3050,6 +3070,80 @@ try { } } +func TestInstallDevappPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$script:OriginalBackupDevSkill = ${function:Backup-DevSkill} +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + & $script:OriginalBackupDevSkill $path $BackupPath + # Simulate another installer recreating the destination after the old + # content was backed up but before this transaction publishes its stage. + New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $path 'concurrent-user-data.txt') -Value 'keep' +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if (!$_.Exception.Data['DWSRollbackFailed']) { Write-Error $_; exit 3 } +} +if (!(Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt') -PathType Leaf)) { exit 4 } +if (Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'payload')) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publication race must retain concurrent directory: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent devapp data = %q, %v", got, err) + } + if backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n"); backup == "" { + t.Fatal("original devapp backup must be retained after publication race") + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { From 7a97354d93e16689801fe9c25cb4fed0dde60b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:23:59 +0800 Subject: [PATCH 072/267] fix(skills): make publication rollback race-safe --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 155 ++++++++- internal/app/skill_setup.go | 20 +- .../skill_setup_canonical_failures_test.go | 5 +- .../app/skill_setup_cleanup_coverage_test.go | 25 +- internal/app/skill_setup_plan_test.go | 12 +- .../app/skill_setup_publication_race_test.go | 85 +++++ internal/upgrade/canonical_skills_test.go | 4 +- internal/upgrade/paths.go | 60 ++-- internal/upgrade/paths_multi_test.go | 22 +- internal/upgrade/skill_path_move.go | 39 +-- internal/upgrade/skill_path_move_test.go | 10 +- internal/upgrade/skill_publication.go | 153 +++++++++ internal/upgrade/skill_publication_test.go | 314 ++++++++++++++++++ .../upgrade/skill_publish_rename_darwin.go | 9 + .../upgrade/skill_publish_rename_linux.go | 9 + .../upgrade/skill_publish_rename_other.go | 12 + .../upgrade/skill_publish_rename_windows.go | 17 + .../upgrade/skill_publish_transaction_test.go | 45 ++- test/scripts/install_js_smoke.mjs | 71 +++- 20 files changed, 934 insertions(+), 135 deletions(-) create mode 100644 internal/app/skill_setup_publication_race_test.go create mode 100644 internal/upgrade/skill_publication.go create mode 100644 internal/upgrade/skill_publication_test.go create mode 100644 internal/upgrade/skill_publish_rename_darwin.go create mode 100644 internal/upgrade/skill_publish_rename_linux.go create mode 100644 internal/upgrade/skill_publish_rename_other.go create mode 100644 internal/upgrade/skill_publish_rename_windows.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 2608ccd6..b04fdd62 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index 5550d449..d64e2ef0 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -698,10 +698,133 @@ function samePhysicalDir(left, right) { } } +function writeSkillFingerprintField(hash, value) { + const bytes = Buffer.from(String(value), "utf8"); + hash.update(String(bytes.length), "utf8"); + hash.update(":", "utf8"); + hash.update(bytes); +} + +function skillPathFingerprint(target) { + const hash = crypto.createHash("sha256"); + const visit = (current) => { + const stat = fs.lstatSync(current, { bigint: true }); + writeSkillFingerprintField(hash, Number(stat.mode & 0o170000n)); + writeSkillFingerprintField(hash, Number(stat.mode & 0o777n)); + if (stat.isDirectory()) { + const names = fs.readdirSync(current).sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + writeSkillFingerprintField(hash, names.length); + for (const name of names) { + writeSkillFingerprintField(hash, name); + visit(path.join(current, name)); + } + return; + } + if (stat.isSymbolicLink()) { + writeSkillFingerprintField(hash, fs.readlinkSync(current)); + return; + } + if (stat.isFile()) { + writeSkillFingerprintField(hash, stat.size.toString()); + hash.update(fs.readFileSync(current)); + return; + } + throw new Error(`unsupported Skill path type: ${current}`); + }; + visit(target); + return hash.digest("hex"); +} + +function captureSkillPublication(source, destination) { + const stat = fs.lstatSync(source, { bigint: true }); + return { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(source), + }; +} + +function skillPublicationMatches(target, publication) { + const stat = fs.lstatSync(target, { bigint: true }); + return stat.dev.toString() === publication.device + && stat.ino.toString() === publication.inode + && skillPathFingerprint(target) === publication.fingerprint; +} + +function removeSkillPathLexically(target) { + const stat = fs.lstatSync(target); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.rmSync(target, { recursive: true, force: true }); + } else { + fs.unlinkSync(target); + } +} + +function skillPathExistsLexically(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function rollbackPublishedSkillPath(publication, options = {}) { + const renameFn = options.renameFn || fs.renameSync; + const removeFn = options.removeFn || removeSkillPathLexically; + const destination = publication.destination; + const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`)); + const quarantine = path.join(quarantineRoot, "payload"); + try { + renameFn(destination, quarantine); + } catch (err) { + fs.rmSync(quarantineRoot, { recursive: true, force: true }); + if (err && err.code === "ENOENT") return; + throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`); + } + let owned = false; + try { + owned = skillPublicationMatches(quarantine, publication); + } catch (err) { + throw new Error(`refusing to delete unverifiable Skill ${destination}; object retained at ${quarantine}: ${err.message}`); + } + if (!owned) { + throw new Error(`refusing to delete non-transaction Skill ${destination}; concurrent object retained at ${quarantine}`); + } + removeFn(quarantine); + fs.rmSync(quarantineRoot, { recursive: true, force: true }); +} + +function publishCanonicalLinkNoReplace(staged, destination, options = {}) { + const publication = captureSkillPublication(staged, destination); + if (process.platform === "win32") { + const renameFn = options.publishRenameFn || fs.renameSync; + renameFn(staged, destination); + } else { + const linkFn = options.publishLinkFn || ((source, target) => { + const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "ln -P failed").trim(); + const error = new Error(detail); + if (skillPathExistsLexically(target)) error.code = "EEXIST"; + throw error; + } + }); + linkFn(staged, destination); + } + if (!skillPublicationMatches(destination, publication)) { + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + return publication; +} + function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { const moveOptions = skillMoveOptions(options); - const publishRenameFn = options.publishRenameFn || fs.renameSync; - const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -713,9 +836,9 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - publishRemoveFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn: publishRemoveFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -752,8 +875,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - publishRenameFn(item.staged, item.dest); - published.push(item.dest); + const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options); + published.push(publication); } } catch (err) { try { @@ -807,6 +930,7 @@ function publishManagedMultiSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); @@ -818,9 +942,9 @@ function publishManagedMultiSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -855,8 +979,11 @@ function publishManagedMultiSkillSetAtomically( } for (const item of staged) { + const publication = captureSkillPublication(item.staged, item.dest); + if (skillPathExistsLexically(item.dest)) throw new Error(`Skill publish destination already exists: ${item.dest}`); renameFn(item.staged, item.dest); - published.push(item.dest); + if (!skillPublicationMatches(item.dest, publication)) throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + published.push(publication); } } catch (err) { try { @@ -883,6 +1010,7 @@ function publishManagedMonoSkillSetAtomically( const copyFn = options.copyFn || copyChildren; const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); @@ -895,9 +1023,9 @@ function publishManagedMonoSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { @@ -927,8 +1055,11 @@ function publishManagedMonoSkillSetAtomically( backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } - published.push(destDir); + const publication = captureSkillPublication(stagedDir, destDir); + if (skillPathExistsLexically(destDir)) throw new Error(`Skill publish destination already exists: ${destDir}`); renameFn(stagedDir, destDir); + if (!skillPublicationMatches(destDir, publication)) throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + published.push(publication); } catch (err) { try { restore(); diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 9a4ebfb8..56858494 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -143,6 +143,8 @@ var ( skillSetupReadState = skillstate.Read skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove + skillSetupPublishPath = upgrade.PublishSkillPathNoReplace + skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications skillSetupNow = time.Now skillSetupFoldPathCase = runtime.GOOS == "windows" ) @@ -2035,13 +2037,8 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s // restoreSkillSetupTarget removes a partially published replacement and // restores every original directory from its exact backup path. -func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := skillSetupRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error { + restoreErr := skillSetupRollbackPaths(published) for i := len(backups) - 1; i >= 0; i-- { item := backups[i] if _, err := skillSetupLstat(item.original); err == nil { @@ -2088,18 +2085,17 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ } func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error { - published := make([]string, 0, len(staged)) + published := make([]upgrade.SkillPathPublication, 0, len(staged)) for _, item := range staged { - // Record before rename so rollback also removes a destination created by - // a platform-specific partial failure. - published = append(published, item.dest) - if err := skillSetupPublishRename(item.staged, item.dest); err != nil { + publication, err := skillSetupPublishPath(item.staged, item.dest) + if err != nil { publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) } return publishErr } + published = append(published, publication) } return nil } diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go index c1b82a19..c5d6b7fd 100644 --- a/internal/app/skill_setup_canonical_failures_test.go +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { @@ -62,7 +63,9 @@ func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t t.Run("publish", func(t *testing.T) { home, plan := canonicalFailurePlan(t) testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, errors.New("publish denied") + }) if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { t.Fatalf("publish failure = %v", err) } diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 6fea3de5..b346eb3d 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -15,6 +15,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func useManagedSkillNames(t *testing.T, names ...string) { @@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test return originalBackup(homeDir, dir) }) } else { - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) } @@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("restore failure aggregation", func(t *testing.T) { t.Run("remove published", func(t *testing.T) { - testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure }) - if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) { + testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure }) + if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) { t.Fatalf("remove published error = %v", err) } }) @@ -490,7 +491,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { }) t.Run("publish rollback failure", func(t *testing.T) { - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, failure + }) testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) @@ -531,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("after publish failure", func(t *testing.T) { plan := newPlan(t) - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) originalRemoveAll := skillSetupRemoveAll cleanupErr := errors.New("cleanup after publish failure") diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index 8330d6e9..406b318a 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t backupCalls, copyCalls := []string{}, 0 testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) { backupCalls = append(backupCalls, path) + if err := os.RemoveAll(path); err != nil { + return "", err + } return "backup", nil }) testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil }) testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error { - if err := os.RemoveAll(dest); err != nil { - return err - } - return os.Rename(src, dest) - }) dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false) var dryRunOut bytes.Buffer dryRunCmd.SetOut(&dryRunOut) @@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t // A filtered multi plan replaces only selected same-name skills and leaves // unselected siblings out of the backup set. + if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil { + t.Fatal(err) + } filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true) if err != nil { t.Fatal(err) diff --git a/internal/app/skill_setup_publication_race_test.go b/internal/app/skill_setup_publication_race_test.go new file mode 100644 index 00000000..8d954e3a --- /dev/null +++ b/internal/app/skill_setup_publication_race_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" +) + +func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + writeSkillSetupFile(t, first, "old first") + writeSkillSetupFile(t, second, "old second") + backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard) + if err != nil { + t.Fatal(err) + } + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + writeSkillSetupFile(t, stagedFirst, "new first") + writeSkillSetupFile(t, stagedSecond, "new second") + + failure := errors.New("injected second setup publication failure") + originalPublish := skillSetupPublishPath + calls := 0 + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + writeSkillSetupFile(t, first, "concurrent") + return upgrade.SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err = publishSkillSetupTarget([]skillSetupStagedDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, backups) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("setup transaction error = %v", err) + } + assertSkillSetupFile(t, first, "concurrent") + assertSkillSetupFile(t, second, "old second") + var firstBackup string + for _, item := range backups { + if item.original == first { + firstBackup = item.backup + break + } + } + if firstBackup == "" { + t.Fatal("first backup was not recorded") + } + assertSkillSetupFile(t, firstBackup, "old first") +} + +func writeSkillSetupFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertSkillSetupFile(t *testing.T, dir, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil || string(content) != want { + t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want) + } +} diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index eee92fa4..9008fc31 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -416,8 +416,8 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { if mode == "multi" { useUpgradeManagedNames(t, "dingtalk-chat") } - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if src == victim { return errors.New("retire denied") } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 1eab7cf1..05551c79 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -140,26 +140,28 @@ var universalSkillDirs = map[string]bool{ } var ( - upgradeUserHomeDir = os.UserHomeDir - upgradeExecutable = os.Executable - upgradeEvalSymlinks = filepath.EvalSymlinks - upgradeCopyDir = copyDir - upgradeEnsureDir = ensureDir - upgradeRemoveAll = os.RemoveAll - upgradeMkdirAll = os.MkdirAll - upgradeMkdirTemp = os.MkdirTemp - upgradeReadDir = os.ReadDir - upgradeStat = os.Stat - upgradeLstat = os.Lstat - upgradeSymlink = os.Symlink - upgradeRel = filepath.Rel - upgradeGetenv = os.Getenv - upgradeBuildProvenance = skillprovenance.Build - upgradeReadSkillState = skillstate.Read - upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } - upgradeWriteSkillState = skillstate.Write - upgradeNow = time.Now - upgradeFoldPathCase = runtime.GOOS == "windows" + upgradeUserHomeDir = os.UserHomeDir + upgradeExecutable = os.Executable + upgradeEvalSymlinks = filepath.EvalSymlinks + upgradeCopyDir = copyDir + upgradeEnsureDir = ensureDir + upgradeRemoveAll = os.RemoveAll + upgradeMkdirAll = os.MkdirAll + upgradeMkdirTemp = os.MkdirTemp + upgradeReadDir = os.ReadDir + upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv + upgradeBuildProvenance = skillprovenance.Build + upgradeReadSkillState = skillstate.Read + upgradePublishSkillPath = PublishSkillPathNoReplace + upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications + upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } + upgradeWriteSkillState = skillstate.Write + upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -492,13 +494,8 @@ func uniqueSkillDirs(paths []string) []string { // restoreSkillSet removes any newly published directories, then restores all // original directories in reverse backup order. -func restoreSkillSet(published []string, backups []backedUpSkillDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := upgradeRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error { + restoreErr := upgradeRollbackPublishedSkillPaths(published) for i := len(backups) - 1; i >= 0; i-- { backup := backups[i] if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil { @@ -538,16 +535,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st if err != nil { return err } - published := make([]string, 0, len(staged)) + published := make([]SkillPathPublication, 0, len(staged)) for _, skill := range staged { - if err := upgradeRename(skill.staged, skill.dest); err != nil { - publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err) + publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest) + if publishErr != nil { + publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr) if restoreErr := restoreSkillSet(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr)) } return publishErr } - published = append(published, skill.dest) + published = append(published, publication) } return nil } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index a0d67135..6ba5d481 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -625,8 +625,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } useUpgradeManagedNames(t, filepath.Base(staleDir)) - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, "dingtalk-stale") { return errors.New("injected backup failure") } @@ -814,14 +814,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { if err := os.MkdirAll(victim4, 0o755); err != nil { t.Fatal(err) } - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("rename denied") }) if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { t.Fatalf("rename error = %v", err) } if _, err := os.Stat(victim4); err != nil { t.Fatalf("victim4 must survive rename failure: %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) } // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: @@ -949,11 +949,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, ".agents") { return errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) result, err := UpgradeSkillLocations(mono) if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { @@ -1014,7 +1014,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Backup failure of a multi leftover aborts cleanupMultiLeftovers. os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") }) if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } @@ -1024,19 +1024,19 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup mono backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Backup failure of a stale (non-mono) skill aborts with its own message. - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { if strings.Contains(src, "dingtalk-stale") { return errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup stale backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Success matrix: mono leftover + stale skill removed into backups, bundle // skill and regular file preserved. diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 2c28be90..e9f9539c 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -11,23 +11,24 @@ import ( ) var ( - skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } - skillPathCopy = copySkillPathLexically - skillPathVerify = verifySkillPathCopy - skillPathRemoveAll = os.RemoveAll - skillPathMkdirAll = os.MkdirAll - skillPathMkdirTemp = os.MkdirTemp - skillPathChmod = os.Chmod - skillPathLstat = os.Lstat - skillPathMkdir = os.Mkdir - skillPathReadDir = os.ReadDir - skillPathReadlink = os.Readlink - skillPathSymlink = os.Symlink - skillPathOpen = os.Open - skillPathOpenFile = os.OpenFile - skillPathCopyBytes = io.Copy - skillPathSync = func(file *os.File) error { return file.Sync() } - skillPathWalkDir = filepath.WalkDir + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathReadDir = os.ReadDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the @@ -44,7 +45,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) } - if renameErr := skillPathRename(src, dst); renameErr == nil { + if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { return nil } else if !isCrossDeviceError(renameErr) { return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) @@ -85,7 +86,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) } } - if err := skillPathRename(stage, dst); err != nil { + if err := skillPathRenameNoReplace(stage, dst); err != nil { return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) } if stageInfo.IsDir() { diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 4140fd6a..6d598590 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -140,7 +140,7 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("non cross-device rename never copies", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) copied := false - testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return os.ErrPermission }) testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { t.Fatalf("error = %v", err) @@ -181,8 +181,8 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("staging publish failure", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(from, to string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { if from == src && to == dst { return testCrossDeviceError() } @@ -402,8 +402,8 @@ func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { func forceCrossDeviceRename(t *testing.T, src, dst string) { t.Helper() - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(from, to string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { if from == src && to == dst { return testCrossDeviceError() } diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go new file mode 100644 index 00000000..f4315435 --- /dev/null +++ b/internal/upgrade/skill_publication.go @@ -0,0 +1,153 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "hash" + "os" + "path/filepath" + "sort" +) + +// SkillPathPublication records enough immutable identity to prove that a +// destination still belongs to the transaction that published it. The +// fingerprint is intentionally private so callers cannot forge records. +type SkillPathPublication struct { + Destination string + fingerprint [sha256.Size]byte + identity os.FileInfo +} + +// PublishSkillPathNoReplace atomically publishes a staged path without ever +// replacing a destination created after the backup phase. +func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) { + identity, err := skillPathLstat(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err) + } + fingerprint, err := fingerprintSkillPath(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) + } + if err := skillPathRenameNoReplace(staged, destination); err != nil { + return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) + } + return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil +} + +// RollbackSkillPathPublications removes only objects that can still be proven +// to have been published by this transaction. Each live path is first claimed +// into a private sibling quarantine. A concurrent replacement is restored when +// possible, otherwise retained in quarantine and reported explicitly. +func RollbackSkillPathPublications(publications []SkillPathPublication) error { + var rollbackErr error + for i := len(publications) - 1; i >= 0; i-- { + if err := rollbackSkillPathPublication(publications[i]); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + return rollbackErr +} + +func rollbackSkillPathPublication(publication SkillPathPublication) (err error) { + destination := publication.Destination + quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-") + if err != nil { + return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err) + } + quarantine := filepath.Join(quarantineRoot, "payload") + cleanupRoot := func() error { + if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil { + return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr) + } + return nil + } + + if err := skillPathRename(destination, quarantine); err != nil { + cleanupErr := cleanupRoot() + if os.IsNotExist(err) { + return cleanupErr + } + return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr) + } + + actualIdentity, identityErr := skillPathLstat(quarantine) + actual, fingerprintErr := fingerprintSkillPath(quarantine) + if identityErr == nil && fingerprintErr == nil && publication.identity != nil && + os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint { + if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { + return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) + } + return cleanupRoot() + } + + verificationErr := errors.Join(identityErr, fingerprintErr) + if verificationErr == nil { + verificationErr = errors.New("发布对象身份已变化") + } + if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil { + return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr) + } + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr), + cleanupRoot(), + ) +} + +func fingerprintSkillPath(path string) ([sha256.Size]byte, error) { + h := sha256.New() + if err := fingerprintSkillPathInto(h, path); err != nil { + return [sha256.Size]byte{}, err + } + var result [sha256.Size]byte + copy(result[:], h.Sum(nil)) + return result, nil +} + +func fingerprintSkillPathInto(h hash.Hash, path string) error { + info, err := skillPathLstat(path) + if err != nil { + return err + } + writeFingerprintField(h, info.Mode().Type().String()) + writeFingerprintField(h, info.Mode().Perm().String()) + switch { + case info.IsDir(): + entries, err := skillPathReadDir(path) + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + writeFingerprintField(h, fmt.Sprintf("%d", len(entries))) + for _, entry := range entries { + writeFingerprintField(h, entry.Name()) + if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil { + return err + } + } + return nil + case info.Mode()&os.ModeSymlink != 0: + target, err := skillPathReadlink(path) + if err != nil { + return err + } + writeFingerprintField(h, target) + return nil + case info.Mode().IsRegular(): + digest, err := skillPathFileDigest(path) + if err != nil { + return err + } + writeFingerprintField(h, fmt.Sprintf("%d", info.Size())) + _, err = h.Write(digest[:]) + return err + default: + return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode()) + } +} + +func writeFingerprintField(h hash.Hash, value string) { + _, _ = fmt.Fprintf(h, "%d:", len(value)) + _, _ = h.Write([]byte(value)) +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go new file mode 100644 index 00000000..470b03cc --- /dev/null +++ b/internal/upgrade/skill_publication_test.go @@ -0,0 +1,314 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) { + t.Run("existing destination is never replaced", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + seedUpgradeSkill(t, destination, "concurrent", false) + + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil { + t.Fatal("expected no-replace publication to fail") + } + assertUpgradeSkillContent(t, destination, "concurrent") + assertUpgradeSkillContent(t, staged, "new") + }) + + t.Run("owned publication is removed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("owned publication remains: %v", err) + } + }) + + t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + // Even a byte-for-byte identical replacement is not owned by this + // transaction: inode identity, not content alone, is authoritative. + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) +} + +func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { + failure := errors.New("injected publication failure") + + t.Run("publish identity", func(t *testing.T) { + if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected identity failure") + } + }) + + t.Run("publish fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + seedUpgradeSkill(t, staged, "value", false) + originalLstat := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls == 2 { + return nil, failure + } + return originalLstat(path) + }) + if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) { + t.Fatal("expected fingerprint failure") + } + }) + + t.Run("rollback temp", func(t *testing.T) { + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { + t.Fatalf("temp error = %v", err) + } + }) + + t.Run("missing destination cleanup", func(t *testing.T) { + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("missing destination cleanup failure", func(t *testing.T) { + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) { + t.Fatalf("cleanup error = %v", err) + } + }) + + t.Run("quarantine rename", func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "destination") + seedUpgradeSkill(t, destination, "value", false) + testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: destination}}); !errors.Is(err, failure) { + t.Fatalf("quarantine error = %v", err) + } + }) + + t.Run("owned remove", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if filepath.Base(path) == "payload" { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("remove error = %v", err) + } + }) + + t.Run("quarantined fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "payload" { + return nil, failure + } + return originalLstat(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("fingerprint error = %v", err) + } + }) + + t.Run("mismatch restore and cleanup", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "replacement", false) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".destination.rollback-") { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("mismatch cleanup error = %v", err) + } + assertUpgradeSkillContent(t, destination, "replacement") + }) + + t.Run("mismatch restore failure", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "replacement", false) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { + t.Fatalf("mismatch restore error = %v", err) + } + }) + + t.Run("fingerprint read failures and lexical types", func(t *testing.T) { + t.Run("directory read", func(t *testing.T) { + dir := t.TempDir() + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("read dir error = %v", err) + } + }) + t.Run("recursive child", func(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "child") + if err := os.WriteFile(child, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == child { + return nil, failure + } + return originalLstat(path) + }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("child error = %v", err) + } + }) + t.Run("symlink read", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure }) + if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) { + t.Fatalf("readlink error = %v", err) + } + }) + t.Run("file digest", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure }) + if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) { + t.Fatalf("digest error = %v", err) + } + }) + t.Run("special", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil { + t.Fatal("expected special path error") + } + }) + }) +} + +func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + seedUpgradeSkill(t, first, "old first", false) + seedUpgradeSkill(t, second, "old second", false) + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + seedUpgradeSkill(t, stagedFirst, "new first", false) + seedUpgradeSkill(t, stagedSecond, "new second", false) + + failure := errors.New("injected second publication failure") + originalPublish := upgradePublishSkillPath + calls := 0 + testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, first, "concurrent", false) + return SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err := publishStagedSkillSet(home, []stagedSkillDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, []string{first, second}) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("transaction error = %v", err) + } + assertUpgradeSkillContent(t, first, "concurrent") + assertUpgradeSkillContent(t, second, "old second") + matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md")) + if globErr != nil || len(matches) != 1 { + t.Fatalf("retained old backup = %v, %v", matches, globErr) + } + content, readErr := os.ReadFile(matches[0]) + if readErr != nil || string(content) != "old first" { + t.Fatalf("retained backup content = %q, %v", content, readErr) + } +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go new file mode 100644 index 00000000..06722ef1 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package upgrade + +import "golang.org/x/sys/unix" + +func renameSkillPathNoReplace(source, destination string) error { + return unix.RenamexNp(source, destination, unix.RENAME_EXCL) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go new file mode 100644 index 00000000..7936592a --- /dev/null +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package upgrade + +import "golang.org/x/sys/unix" + +func renameSkillPathNoReplace(source, destination string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go new file mode 100644 index 00000000..bdd0986b --- /dev/null +++ b/internal/upgrade/skill_publish_rename_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "runtime" +) + +func renameSkillPathNoReplace(_, _ string) error { + return fmt.Errorf("当前平台 %s 不支持安全的原子 no-replace rename", runtime.GOOS) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go new file mode 100644 index 00000000..7ac5afb1 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package upgrade + +import "golang.org/x/sys/windows" + +func renameSkillPathNoReplace(source, destination string) error { + sourcePtr, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFile(sourcePtr, destinationPtr) +} diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go index 02afa793..0e372b28 100644 --- a/internal/upgrade/skill_publish_transaction_test.go +++ b/internal/upgrade/skill_publish_transaction_test.go @@ -92,8 +92,8 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("backup failure restores earlier victims", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { if src == filepath.Join(base, "dingtalk-b") { return failure } @@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("publish failure rolls back complete set", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T t.Run("publish failure rolls back mono and multi", func(t *testing.T) { home, base, monoRoot := seed(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports removal and directory failures", func(t *testing.T) { - testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure }) + testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure }) testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure }) err := restoreSkillSet( - []string{filepath.Join(t.TempDir(), "published")}, + []SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}}, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}, ) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { @@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports rename failure", func(t *testing.T) { - testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return restoreFailure }) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) if !errors.Is(err, restoreFailure) { t.Fatalf("restore rename error = %v", err) @@ -236,8 +236,8 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) second := filepath.Join(home, "second") seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, second, "second", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { switch { case src == second: return failure @@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) staged := filepath.Join(home, "skills", ".stage", "dws") seedUpgradeSkill(t, old, "old", false) seedUpgradeSkill(t, staged, "new", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { - switch { - case src == staged: - return failure - case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + return SkillPathPublication{}, failure + }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { + if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) { return restoreFailure - default: - return originalRename(src, dest) } + return originalRename(src, dest) }) err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index ba0ad970..8142f74e 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -287,7 +287,7 @@ function runCrossDeviceMoveContract() { } fs.renameSync(source, target); }, - publishRenameFn() { throw new Error("link publish failed"); }, + publishLinkFn() { throw new Error("link publish failed"); }, }), /link publish failed/, ); @@ -687,6 +687,75 @@ scenario("cache copy failure preserves the previous complete cache", () => { } }); +scenario("canonical link publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-")); + try { + { + const home = path.join(tmp, "no-clobber-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const destination = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { + publishLinkFn(_source, target) { + writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); + throw Object.assign(new Error("concurrent destination exists"), { code: "EEXIST" }); + }, + }), + /concurrent destination exists/, + ); + assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + } + + { + const home = path.join(tmp, "rollback-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const first = path.join(base, "dingtalk-first"); + const second = path.join(base, "dingtalk-second"); + for (const name of ["dingtalk-first", "dingtalk-second"]) { + writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`); + writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`); + } + let publishCalls = 0; + assert.throws( + () => publishCanonicalLinksAtomically( + home, + canonical, + base, + ["dingtalk-first", "dingtalk-second"], + [first, second], + { + publishLinkFn(source, target) { + publishCalls += 1; + if (publishCalls === 1) { + const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || "ln -P failed"); + return; + } + fs.unlinkSync(first); + writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n"); + throw new Error("injected second canonical publish failure"); + }, + }, + ), + /concurrent object retained|rollback failed/, + ); + assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n"); + assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n"); + const retained = fs.readdirSync(base) + .filter((name) => name.startsWith(".dingtalk-first.rollback-")) + .map((name) => path.join(base, name, "payload", "concurrent-user-data.txt")) + .filter((candidate) => fs.existsSync(candidate)); + assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine"); + assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n"); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); From e6c54cf77752efa21c79d45071b100753596dbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:47:04 +0800 Subject: [PATCH 073/267] fix(skills): distinguish reused publication inodes --- internal/upgrade/skill_publication.go | 44 +++++- .../skill_publication_identity_darwin.go | 18 +++ .../skill_publication_identity_linux.go | 17 +++ .../skill_publication_identity_other.go | 12 ++ .../skill_publication_identity_windows.go | 17 +++ internal/upgrade/skill_publication_test.go | 144 ++++++++++++++++-- 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 internal/upgrade/skill_publication_identity_darwin.go create mode 100644 internal/upgrade/skill_publication_identity_linux.go create mode 100644 internal/upgrade/skill_publication_identity_other.go create mode 100644 internal/upgrade/skill_publication_identity_windows.go diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index f4315435..7c678af0 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -17,6 +17,7 @@ type SkillPathPublication struct { Destination string fingerprint [sha256.Size]byte identity os.FileInfo + incarnation string } // PublishSkillPathNoReplace atomically publishes a staged path without ever @@ -33,7 +34,22 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } - return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil + publishedIdentity, err := skillPathLstat(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + } + // Force os.SameFile to resolve and cache any path-backed identity (notably + // on Windows) while the just-published object is known to occupy the path. + if !os.SameFile(identity, publishedIdentity) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + _ = os.SameFile(publishedIdentity, publishedIdentity) + return SkillPathPublication{ + Destination: destination, + fingerprint: fingerprint, + identity: publishedIdentity, + incarnation: skillPathFileIncarnation(publishedIdentity), + }, nil } // RollbackSkillPathPublications removes only objects that can still be proven @@ -64,6 +80,27 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) return nil } + liveIdentity, liveIdentityErr := skillPathLstat(destination) + if os.IsNotExist(liveIdentityErr) { + return cleanupRoot() + } + liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination) + liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr) + if liveVerificationErr != nil { + return errors.Join( + fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr), + cleanupRoot(), + ) + } + if publication.identity == nil || !os.SameFile(publication.identity, liveIdentity) || + publication.incarnation != skillPathFileIncarnation(liveIdentity) || + liveFingerprint != publication.fingerprint { + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination), + cleanupRoot(), + ) + } + if err := skillPathRename(destination, quarantine); err != nil { cleanupErr := cleanupRoot() if os.IsNotExist(err) { @@ -74,8 +111,9 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) - if identityErr == nil && fingerprintErr == nil && publication.identity != nil && - os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint { + if identityErr == nil && fingerprintErr == nil && + os.SameFile(publication.identity, actualIdentity) && + actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) } diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go new file mode 100644 index 00000000..b436ac6e --- /dev/null +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, + stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go new file mode 100644 index 00000000..0f8a52fb --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go new file mode 100644 index 00000000..fcdee1a2 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "os" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go new file mode 100644 index 00000000..046327ef --- /dev/null +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 470b03cc..9733bcac 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -94,6 +94,44 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("publish confirmation missing", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + return os.RemoveAll(target) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { + t.Fatalf("publish confirmation error = %v", err) + } + }) + + t.Run("publish confirmation identity changed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + if err := os.RemoveAll(target); err != nil { + return err + } + seedUpgradeSkill(t, target, "replacement", false) + return nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { + t.Fatalf("publish identity error = %v", err) + } + }) + t.Run("rollback temp", func(t *testing.T) { testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { @@ -115,14 +153,61 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) t.Run("quarantine rename", func(t *testing.T) { - destination := filepath.Join(t.TempDir(), "destination") - seedUpgradeSkill(t, destination, "value", false) + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) - if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: destination}}); !errors.Is(err, failure) { + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { t.Fatalf("quarantine error = %v", err) } }) + t.Run("live verification", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("live verification error = %v", err) + } + }) + + t.Run("destination disappears before quarantine", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if err := os.RemoveAll(source); err != nil { + return err + } + return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist} + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + }) + t.Run("owned remove", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") @@ -174,10 +259,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) originalRemove := skillPathRemoveAll testseam.Swap(t, &skillPathRemoveAll, func(path string) error { if strings.Contains(filepath.Base(path), ".destination.rollback-") { @@ -200,10 +291,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { t.Fatalf("mismatch restore error = %v", err) @@ -211,6 +308,17 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) t.Run("fingerprint read failures and lexical types", func(t *testing.T) { + t.Run("directory ordering", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"b", "a"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := fingerprintSkillPath(dir); err != nil { + t.Fatal(err) + } + }) t.Run("directory read", func(t *testing.T) { dir := t.TempDir() testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) @@ -245,6 +353,15 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatalf("readlink error = %v", err) } }) + t.Run("symlink success", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := fingerprintSkillPath(link); err != nil { + t.Fatal(err) + } + }) t.Run("file digest", func(t *testing.T) { file := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(file, []byte("value"), 0o644); err != nil { @@ -263,6 +380,11 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatal("expected special path error") } }) + t.Run("unknown platform identity shape", func(t *testing.T) { + if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" { + t.Fatal("empty incarnation") + } + }) }) } From d0a9dad07909022a1cc06b82f6f07825ba3e84d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:58:14 +0800 Subject: [PATCH 074/267] test(skills): cover post-publish identity reuse --- internal/upgrade/skill_publication.go | 6 ++++- internal/upgrade/skill_publication_test.go | 26 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 7c678af0..d2fc7921 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -38,9 +38,13 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) } + publishedFingerprint, err := fingerprintSkillPath(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) + } // Force os.SameFile to resolve and cache any path-backed identity (notably // on Windows) while the just-published object is known to occupy the path. - if !os.SameFile(identity, publishedIdentity) { + if !os.SameFile(identity, publishedIdentity) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } _ = os.SameFile(publishedIdentity, publishedIdentity) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 9733bcac..00be230a 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -132,6 +132,32 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("publish confirmation fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + published := false + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + published = true + return nil + }) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if published && path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") { + t.Fatalf("publish fingerprint confirmation error = %v", err) + } + }) + t.Run("rollback temp", func(t *testing.T) { testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { From f16feed896f75a9a123cf61972c81a6fa589aa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:07:33 +0800 Subject: [PATCH 075/267] fix(skills): compare Windows publication identity stably --- internal/upgrade/skill_publication.go | 9 +++------ internal/upgrade/skill_publication_identity_darwin.go | 4 ++++ internal/upgrade/skill_publication_identity_linux.go | 4 ++++ internal/upgrade/skill_publication_identity_other.go | 4 ++++ internal/upgrade/skill_publication_identity_windows.go | 4 ++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index d2fc7921..0bd9ecdb 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -42,12 +42,9 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } - // Force os.SameFile to resolve and cache any path-backed identity (notably - // on Windows) while the just-published object is known to occupy the path. - if !os.SameFile(identity, publishedIdentity) || publishedFingerprint != fingerprint { + if !skillPathSameFileIdentity(identity, publishedIdentity) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } - _ = os.SameFile(publishedIdentity, publishedIdentity) return SkillPathPublication{ Destination: destination, fingerprint: fingerprint, @@ -96,7 +93,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) cleanupRoot(), ) } - if publication.identity == nil || !os.SameFile(publication.identity, liveIdentity) || + if publication.identity == nil || !skillPathSameFileIdentity(publication.identity, liveIdentity) || publication.incarnation != skillPathFileIncarnation(liveIdentity) || liveFingerprint != publication.fingerprint { return errors.Join( @@ -116,7 +113,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) if identityErr == nil && fingerprintErr == nil && - os.SameFile(publication.identity, actualIdentity) && + skillPathSameFileIdentity(publication.identity, actualIdentity) && actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index b436ac6e..a3547e4c 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -16,3 +16,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 0f8a52fb..cb62e1e1 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -15,3 +15,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { } return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index fcdee1a2..53c0bc86 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -10,3 +10,7 @@ import ( func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 046327ef..518771c5 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -15,3 +15,7 @@ func skillPathFileIncarnation(info os.FileInfo) string { } return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } + +func skillPathSameFileIdentity(left, right os.FileInfo) bool { + return skillPathFileIncarnation(left) == skillPathFileIncarnation(right) +} From ecaefb416f5d52ddf70a107709c860d4b71b0887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:14:04 +0800 Subject: [PATCH 076/267] fix(skills): retain Windows reparse link publication --- internal/upgrade/skill_publication_identity_windows.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 518771c5..03c97de7 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -16,6 +16,9 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { - return skillPathFileIncarnation(left) == skillPathFileIncarnation(right) +func skillPathSameFileIdentity(_, _ os.FileInfo) bool { + // MoveFile can surface different metadata identities for the same reparse + // point before and after rename. Windows ownership is therefore proved by + // the separately checked creation-time incarnation plus lexical fingerprint. + return true } From 6e20bc765f815b28b06e537a9bc0ac6cde498248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 20:22:32 +0800 Subject: [PATCH 077/267] test(skills): cover Windows no-replace path errors --- internal/upgrade/skill_publication_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 00be230a..a5c3eb38 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -70,6 +70,19 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { failure := errors.New("injected publication failure") + t.Run("platform no-replace path encoding", func(t *testing.T) { + if err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected invalid source path failure") + } + source := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(source, []byte("value"), 0o600); err != nil { + t.Fatal(err) + } + if err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { + t.Fatal("expected invalid destination path failure") + } + }) + t.Run("publish identity", func(t *testing.T) { if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil { t.Fatal("expected identity failure") From 62883b79406b7161302341539e625f32010cf057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 14:26:11 +0800 Subject: [PATCH 078/267] fix(skills): make obsolete-copy retirement non-fatal and harden install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A universal Agent whose obsolete private copy cannot be retired installs nothing there, yet every entry point counted that retirement failure as an install failure — aborting `npm install`, `dws skill setup`, and the shell installers even when the canonical store and all links published correctly, and skipping the skills-state write. Route retirement failures to a separate warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell, install.sh, install-skills.sh, install-event.sh, install-devapp.sh). Also: - Add a checked-rename fallback for filesystems that reject the atomic no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and the previously unsupported platforms build and work. - PowerShell multi-mode links only bundle skills, never the shared canonical store, so third-party/user skills are no longer fanned into every Agent root. - Prune ~/.dws/skill-backups to the newest 5 on every surface; encode HOME-relative backup names on PowerShell to preserve origin. - Add simulated-win32 junction coverage and rewrite the tautological no-replace test; remove dead code whose tests gave false coverage. - Soften the overstated Windows ownership-proof comment (NTFS tunneling). --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 72 ++++- docs/rfc-skill-installation-and-upgrade.md | 7 + internal/app/skill_setup.go | 70 +---- ...ll_setup_event_migration_universal_test.go | 13 +- internal/app/skill_setup_plan_test.go | 28 +- internal/app/upgrade.go | 3 + internal/upgrade/canonical_skills_test.go | 17 +- internal/upgrade/paths.go | 63 ++-- internal/upgrade/paths_multi_test.go | 42 ++- .../skill_publication_identity_windows.go | 8 +- internal/upgrade/skill_publish_rename.go | 40 +++ .../upgrade/skill_publish_rename_darwin.go | 17 +- .../upgrade/skill_publish_rename_linux.go | 18 +- .../upgrade/skill_publish_rename_other.go | 11 +- internal/upgrade/skill_publish_rename_test.go | 109 +++++++ .../upgrade/skill_publish_rename_windows.go | 7 +- scripts/install-devapp.ps1 | 76 ++++- scripts/install-devapp.sh | 81 ++++- scripts/install-event.sh | 85 +++++- scripts/install-skills.sh | 184 +++++++---- scripts/install.ps1 | 107 ++++++- scripts/install.sh | 190 ++++++++---- scripts/release/verify-package-managers.sh | 29 +- test/scripts/install_js_smoke.mjs | 285 ++++++++++++++++-- test/scripts/install_script_test.go | 15 +- test/scripts/package_script_test.go | 6 +- 27 files changed, 1266 insertions(+), 319 deletions(-) create mode 100644 internal/upgrade/skill_publish_rename.go create mode 100644 internal/upgrade/skill_publish_rename_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index b04fdd62..9a4afd7d 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Failing to retire an obsolete Agent copy is now a warning rather than an install failure, and every install surface prunes `~/.dws/skill-backups` to the newest 5 batches. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index d64e2ef0..1a243910 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -153,6 +153,42 @@ function backupStamp() { ); } +// SKILL_BACKUP_KEEP bounds ~/.dws/skill-backups/ growth: only the newest +// stamped backup directories are kept. Mirrors skillBackupKeep and +// pruneSkillBackups in internal/upgrade/paths.go so every installer surface +// applies the same retention. +const SKILL_BACKUP_KEEP = 5; + +// Backup roots this process created are never pruned: an in-flight +// transaction still needs them to roll back. +const currentRunBackupRoots = new Set(); + +// pruneSkillBackups removes the oldest stamped backup directories once more +// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in +// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +function pruneSkillBackups(homeDir) { + const root = path.join(homeDir, ".dws", "skill-backups"); + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (err) { + if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return; + throw err; + } + const names = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + let excess = names.length - SKILL_BACKUP_KEEP; + for (const name of names) { + if (excess <= 0) break; + const candidate = path.join(root, name); + if (currentRunBackupRoots.has(candidate)) continue; + fs.rmSync(candidate, { recursive: true, force: true }); + excess -= 1; + } +} + function pathExistsLexicallySync(target) { try { fs.lstatSync(target); @@ -397,6 +433,14 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { if (backups) { backups.push({ original: dir, backup: target }); } + currentRunBackupRoots.add(targetRoot); + // Keep the backup root bounded. The backup itself already succeeded, so a + // prune failure is only a warning (matches Go's best-effort prune call). + try { + pruneSkillBackups(homeDir); + } catch (err) { + console.warn(`⚠️ 旧备份清理失败(备份本身已成功): ${err.message}`); + } console.log(` × 已备份并移除 ${dir} → ${target}`); return target; } @@ -568,11 +612,14 @@ function installSkillsToHomes(skillRoot) { if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { + // Cleanup-only migration: universal Agents read the canonical store + // directly, so nothing is installed here. A retire failure leaves an + // obsolete copy in place — worth a warning, never a reason to fail an + // install whose canonical store and links all succeeded. try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { - console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); - failed += 1; + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); } continue; } @@ -799,6 +846,15 @@ function rollbackPublishedSkillPath(publication, options = {}) { function publishCanonicalLinkNoReplace(staged, destination, options = {}) { const publication = captureSkillPublication(staged, destination); + // No-replace is a hard property of this publisher, so it is checked before + // either platform's publish call. Windows rename replaces the destination + // outright (libuv passes MOVEFILE_REPLACE_EXISTING) and `ln -P` treats an + // existing directory as a container to link INTO; in both cases the identity + // confirmation below compares against the staged object and could not + // recover the user's data. The sibling copy publishers guard identically. + if (skillPathExistsLexically(destination)) { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } if (process.platform === "win32") { const renameFn = options.publishRenameFn || fs.renameSync; renameFn(staged, destination); @@ -825,6 +881,9 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const moveOptions = skillMoveOptions(options); const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; + // Injectable so the Windows junction type and absolute link target can be + // asserted from a non-Windows host. + const symlinkFn = options.symlinkFn || fs.symlinkSync; fs.mkdirSync(baseDir, { recursive: true }); const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); @@ -864,7 +923,7 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const stagedPath = path.join(stageRoot, name); const realTarget = fs.realpathSync(target); const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); - fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + symlinkFn(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); } const seen = new Set(); @@ -1181,11 +1240,14 @@ function installMultiSkillsToHomes(multiRoot) { if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; if (universal) { + // Cleanup-only migration (see installSkillsToHomes): nothing is + // installed in a universal root, so a retire failure must not fail an + // otherwise complete install. Go records the same case as a failed + // directory yet still returns success for the run. try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { - console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`); - failed += 1; + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); } continue; } diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index 56e70758..a2e90013 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -194,6 +194,13 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 上述验证和失败合同。 +原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件 +系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与 +overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级 +为“显式存在性检查 + 普通 rename”:不覆盖既有目标的契约保持不变,仅失去原子性, +其检查与 rename 之间的竞态窗口与普通 rename 本身一致,并由发布后的身份与内容校验 +兜底。Windows `MoveFile` 本身即拒绝已存在的目标,无需降级。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 56858494..344809c7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -388,7 +388,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { retiredNames = append(retiredNames, multiMiscSkill) } if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { - return retireErr + // Retiring an obsolete universal copy installs nothing; report it and + // keep the successful installation rather than failing the run. + fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr) } } if skipped > 0 { @@ -1231,63 +1233,6 @@ func samePhysicalSkillSetupPath(left, right string) bool { return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } -func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { - // Derive HOME from a concrete Agent destination instead of resolving it a - // second time. The destinations were already resolved from HOME by the - // caller, and a later/transient UserHomeDir failure must not turn an - // otherwise valid setup plan into an error. Direct/custom destinations that - // do not match a known concrete Agent root have no generic-root migration. - home := "" - for _, dest := range dests { - base := dest - if filepath.Base(dest) == "dws" { - base = filepath.Dir(dest) - } - base = filepath.Clean(base) - for i, rel := range skillSetupAgentHomes { - if i == 0 { - continue - } - suffix := filepath.Clean(filepath.FromSlash(rel)) - needle := string(filepath.Separator) + suffix - if strings.HasSuffix(base, needle) { - home = strings.TrimSuffix(base, needle) - break - } - } - if home != "" { - break - } - } - if home == "" { - return nil, nil - } - genericBase := filepath.Join(home, ".agents", "skills") - - target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true} - add := func(path, reason string) { - if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason}) - } - } - add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual) - entries, readErr := skillSetupReadDir(genericBase) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr) - } - for _, entry := range entries { - path := filepath.Join(genericBase, entry.Name()) - if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale}) - } - } - if len(target.Backups) == 0 { - return nil, nil - } - sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - return target, nil -} - func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) { if mode != skillSetupModeMono && mode != skillSetupModeMulti { return nil, fmt.Errorf("内部错误:未知 mode %q", mode) @@ -2116,14 +2061,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement must not count as a skipped install: any skipped + // count fails the whole setup, even when every real target succeeded. if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr) } continue } diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go index 902aec6a..928f672d 100644 --- a/internal/app/skill_setup_event_migration_universal_test.go +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -112,7 +112,9 @@ func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T } }) - t.Run("backup propagates through command", func(t *testing.T) { + // Retiring an obsolete universal copy installs nothing, so its failure is + // surfaced as a warning and the successful installation is kept. + t.Run("backup failure warns without failing the command", func(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) codex := filepath.Join(home, ".codex", "skills") @@ -121,14 +123,17 @@ func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) - _, _, err := executeMultiSkillSetupTest( + _, stderr, err := executeMultiSkillSetupTest( t, src, []string{filepath.Join(home, ".agents", "skills"), codex}, "--skill", "event", "--yes", ) - if !errors.Is(err, failure) || !strings.Contains(err.Error(), "退役 universal Agent") { - t.Fatalf("retirement backup failure = %v, want wrapped %v", err, failure) + if err != nil { + t.Fatalf("retirement backup failure must not fail the command: %v", err) + } + if !strings.Contains(stderr, "退役 universal Agent") { + t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr) } }) } diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index 406b318a..90b13869 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -174,32 +174,13 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar t.Fatalf("universal cleanup preview missing: %s", preview.String()) } - t.Run("managed multi and scan failure", func(t *testing.T) { - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { - t.Fatal(err) - } - managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true}) - if targetErr != nil || target == nil || len(target.Backups) != 2 { - t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr) - } - failure := errors.New("generic scan failure") - testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) - if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { - t.Fatalf("generic scan error = %v", targetErr) - } - }) } func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) { failure := errors.New("cleanup failure") cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}} - t.Run("prior skip suppresses cleanup", func(t *testing.T) { + t.Run("cleanup runs even after an install skip", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil }) plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}} installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard) @@ -208,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. } }) + // A cleanup-only target installs nothing, so its failure is a warning and + // must never increment skipped — runSkillSetup turns any skipped count into + // a hard error and would fail an otherwise complete installation. t.Run("home failure keeps generic copy", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -222,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") { t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 6388ce46..5c11dd5d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { for _, d := range succeeded { fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir))) } + for _, d := range result.RetireWarnings() { + fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error())) + } } else { fmt.Printf(" %s\n", ugGreen("✓")) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 9008fc31..174b776e 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -424,8 +424,21 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { return origRename(src, dst) }) result, err := UpgradeSkillLocations(makeSource(t)) - if err != nil || len(result.Failed()) != 1 { - t.Fatalf("retire failure = %#v, %v", result, err) + if err != nil { + t.Fatalf("retire failure must not fail the upgrade: %v", err) + } + if len(result.Failed()) != 0 { + t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed()) + } + warnings := result.RetireWarnings() + if len(warnings) != 1 || warnings[0].Err == nil { + t.Fatalf("retire warnings = %#v", warnings) + } + if len(result.Succeeded()) == 0 { + t.Fatalf("canonical must still be published: %#v", result) + } + if _, statErr := os.Lstat(victim); statErr != nil { + t.Fatalf("unretired copy must be preserved: %v", statErr) } }) diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 05551c79..f75d575d 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -239,13 +239,17 @@ const ( SkillDirSkipped // agent not detected, directory skipped SkillDirBlacklisted // blacklisted, never touched SkillDirFailed // installation attempted but failed + // SkillDirRetireWarning marks a universal Agent whose obsolete private copy + // could not be retired. Nothing is installed below such a root, so the + // leftover is reported without counting as an install failure. + SkillDirRetireWarning ) // SkillDirResult holds the per-directory install result. type SkillDirResult struct { Dir string // destination directory (e.g. ~/.claude/skills/dws) Status SkillDirStatus // outcome - Err error // non-nil when Status == SkillDirFailed + Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning } // SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call. @@ -275,6 +279,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { return out } +// RetireWarnings returns universal Agent roots that still hold an obsolete +// private copy because retiring it failed. +func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult { + var out []SkillDirResult + for _, d := range r.Results { + if d.Status == SkillDirRetireWarning { + out = append(out, d) + } + } + return out +} + // UpgradeSkillLocations refreshes skills from extractedDir into agent homes. // extractedDir may be a multi-skill bundle root (subdirectories each containing // SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that @@ -789,7 +805,7 @@ func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names [ } needed = append(needed, name) } - filteredVictims := victims[:0] + filteredVictims := make([]string, 0, len(victims)) for _, victim := range victims { if !correct[filepath.Clean(victim)] { filteredVictims = append(filteredVictims, victim) @@ -840,7 +856,9 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e } if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err}) } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) } @@ -912,7 +930,9 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk } if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err}) } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) } @@ -1005,23 +1025,6 @@ func skillTreeHasRoot(dir string) bool { return err == nil && !info.IsDir() } -// cleanupMultiLeftovers backs up + removes every proven DWS-managed -// multi-mode skill directory inside one agent home before mono is installed. -// A missing base directory simply means no leftovers; any other read failure -// is reported so mono never silently co-exists with multi. Removal is -// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a -// backup failure aborts the removal for that home. -func cleanupMultiLeftovers(homeDir, baseDir string) error { - victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理 multi 残留失败: %w", err) - } - return nil -} - func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) { entries, err := upgradeReadDir(baseDir) if err != nil { @@ -1040,24 +1043,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str return victims, nil } -// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the -// legacy mono directory (dws/) and every proven DWS-managed multi skill -// directory that is not part of the new bundle. A dingtalk-* prefix alone is -// not proof of ownership because market/user skills may use the same prefix. -// Removal is reversible: each -// directory is preserved under ~/.dws/skill-backups/ and a backup failure -// aborts the removal for that home. -func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error { - victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理对面模式残留失败: %w", err) - } - return nil -} - func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) { victims := []string{filepath.Join(destBase, "dws")} entries, err := upgradeReadDir(destBase) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 6ba5d481..9ae6579d 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -994,6 +994,30 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { } } +// The production wrappers that used to bundle these two steps were removed as +// dead code; the test keeps exercising the live building blocks they composed. +func cleanupMultiLeftoversForTest(homeDir, baseDir string) error { + victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) + if err != nil { + return err + } + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理 multi 残留失败: %w", err) + } + return nil +} + +func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error { + victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) + if err != nil { + return err + } + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理对面模式残留失败: %w", err) + } + return nil +} + // TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers: // read failures surface, backup failures abort, and the opposite-mode cleanup // preserves bundle skills while removing the mono leftover. @@ -1003,11 +1027,11 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Read failure (non-ENOENT) surfaces from both cleanups. testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupMultiLeftovers read error = %v", err) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err) } - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err) + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err) } testseam.Swap(t, &upgradeReadDir, os.ReadDir) @@ -1015,13 +1039,13 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } // Backup failure of the mono leftover aborts the opposite-mode cleanup. os.MkdirAll(filepath.Join(base, "dws"), 0o755) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup mono backup error = %v", err) } testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) @@ -1033,7 +1057,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } return renameSkillPathNoReplace(src, dst) }) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup stale backup error = %v", err) } testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) @@ -1044,7 +1068,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { skillSet := map[string]bool{"dingtalk-keep": true} os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755) os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644) - if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil { + if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil { t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err) } for _, gone := range []string{"dws", "dingtalk-stale"} { @@ -1064,7 +1088,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } // cleanupMultiLeftovers on a missing base is a no-op. - if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil { + if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil { t.Fatalf("missing base must be a no-op, got %v", err) } } diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 03c97de7..107e266a 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -18,7 +18,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(_, _ os.FileInfo) bool { // MoveFile can surface different metadata identities for the same reparse - // point before and after rename. Windows ownership is therefore proved by - // the separately checked creation-time incarnation plus lexical fingerprint. + // point before and after rename, so Windows ownership relies on the + // separately checked creation-time incarnation plus the lexical fingerprint. + // That pair is strong evidence but not proof: NTFS file tunneling restores + // the original creation time when a same-named object is recreated in the + // same directory shortly afterwards, so a byte-identical concurrent + // replacement can still be attributed to this transaction. return true } diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go new file mode 100644 index 00000000..490f0f9a --- /dev/null +++ b/internal/upgrade/skill_publish_rename.go @@ -0,0 +1,40 @@ +package upgrade + +import ( + "errors" + "fmt" + "os" +) + +// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace +// rename primitive at all. +var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename") + +var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic + +// renameSkillPathNoReplace moves source onto destination and never replaces an +// object that already occupies destination. +// +// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require +// support from the underlying filesystem and report EINVAL/ENOTSUP when it is +// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and +// overlayfs homes reject the flag. Failing there would abort the whole install +// on those filesystems, so they degrade to an explicit existence check plus a +// plain rename: the no-clobber contract is preserved, only its atomicity is +// lost. That check-then-rename window is the same exposure a plain rename +// always had, and callers additionally verify identity after publication. +func renameSkillPathNoReplace(source, destination string) error { + err := skillPathRenameNoReplaceAtomic(source, destination) + if err == nil { + return nil + } + if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { + return err + } + if _, statErr := skillPathLstat(destination); statErr == nil { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查发布目标失败 %s: %w", destination, statErr) + } + return skillPathRename(source, destination) +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go index 06722ef1..94df0fb2 100644 --- a/internal/upgrade/skill_publish_rename_darwin.go +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -2,8 +2,21 @@ package upgrade -import "golang.org/x/sys/unix" +import ( + "errors" + "syscall" -func renameSkillPathNoReplace(source, destination string) error { + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { return unix.RenamexNp(source, destination, unix.RENAME_EXCL) } + +// Volumes whose filesystem does not implement renamex_np (several network and +// FUSE mounts) reject RENAME_EXCL instead of performing the rename. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go index 7936592a..20d1be77 100644 --- a/internal/upgrade/skill_publish_rename_linux.go +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -2,8 +2,22 @@ package upgrade -import "golang.org/x/sys/unix" +import ( + "errors" + "syscall" -func renameSkillPathNoReplace(source, destination string) error { + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) } + +// rename(2) documents EINVAL as "the filesystem does not support one of the +// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels +// without renameat2 at all. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) || + errors.Is(err, syscall.EOPNOTSUPP) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go index bdd0986b..c9695771 100644 --- a/internal/upgrade/skill_publish_rename_other.go +++ b/internal/upgrade/skill_publish_rename_other.go @@ -2,11 +2,8 @@ package upgrade -import ( - "fmt" - "runtime" -) - -func renameSkillPathNoReplace(_, _ string) error { - return fmt.Errorf("当前平台 %s 不支持安全的原子 no-replace rename", runtime.GOOS) +func renameSkillPathNoReplaceAtomic(_, _ string) error { + return errNoReplaceRenameUnsupported } + +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go new file mode 100644 index 00000000..e3996aa3 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_test.go @@ -0,0 +1,109 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for +// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). +// Those installs must still publish, and must still refuse to clobber an +// occupied destination. +func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { + unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} + + t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" { + t.Fatalf("destination = %q, %v", data, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + for _, path := range []string{source, destination} { + if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" { + t.Fatalf("destination must be untouched, got %q, %v", data, readErr) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("a genuine error is not retried", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + calls := 0 + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { + calls++ + return os.ErrPermission + }) + if err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("permission error must surface, got %v", err) + } + if calls != 1 { + t.Fatalf("atomic attempts = %d, want 1", calls) + } + }) + + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { + if isNoReplaceRenameUnsupported(syscall.EXDEV) { + t.Fatal("EXDEV must not be treated as an unsupported flag") + } + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "backup", "destination") + original := skillPathRenameNoReplaceAtomic + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { + if from == source { + return syscall.EXDEV + } + return original(from, to) + }) + if err := moveSkillPathRecoverably(source, destination); err != nil { + t.Fatalf("cross-device move = %v", err) + } + if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" { + t.Fatalf("cross-device destination = %q, %v", data, err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go index 7ac5afb1..18927afe 100644 --- a/internal/upgrade/skill_publish_rename_windows.go +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -4,7 +4,7 @@ package upgrade import "golang.org/x/sys/windows" -func renameSkillPathNoReplace(source, destination string) error { +func renameSkillPathNoReplaceAtomic(source, destination string) error { sourcePtr, err := windows.UTF16PtrFromString(source) if err != nil { return err @@ -15,3 +15,8 @@ func renameSkillPathNoReplace(source, destination string) error { } return windows.MoveFile(sourcePtr, destinationPtr) } + +// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied +// destination on every supported Windows version, so there is nothing to +// degrade to. +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 1719391d..50502caa 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -258,15 +258,73 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { } } +# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so +# copies retired from different Agent roots stay distinguishable. Mirrors +# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall +# back to the bare leaf. +function Get-DevSkillBackupName([string]$Dir) { + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { return $leaf } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching +# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this +# run created are never pruned: a rollback still needs them. +$DevSkillBackupKeep = 5 +$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +function Remove-OldDevSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Sort-Object -Property Name) + $excess = $dirs.Count - $DevSkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + $excess-- + } +} + function Backup-DevSkill([string]$path, [ref]$BackupPath) { if ($null -ne $BackupPath) { $BackupPath.Value = "" } if (!(Test-PathLexically $path)) { return } - $backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))" + $backupBase = Join-Path $HOME ".dws\skill-backups" + $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $name = Get-DevSkillBackupName $path + $backupRoot = Join-Path $backupBase $stamp + $target = Join-Path $backupRoot $name + $i = 1 + while (Test-PathLexically $target) { + $backupRoot = Join-Path $backupBase "$stamp-$i" + $target = Join-Path $backupRoot $name + $i++ + if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" } + } New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null - $target = Join-Path $backupRoot (Split-Path $path -Leaf) - while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" } Move-DevSkillPathRecoverably $path $target if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null + Remove-OldDevSkillBackups + } catch { + Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)" + } } function Get-DevSkillLinkSignature([string]$Path) { @@ -487,6 +545,7 @@ if (-not $NoSkills) { Publish-DevSkillCopy $src $canonical $installed = 1 $failed = 0 + $retireFailed = 0 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { $parts = $row.Split('|'); $universal = $parts[1] -eq "1" @@ -497,9 +556,13 @@ if (-not $NoSkills) { if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName if ($universal) { + # Cleanup-only migration: universal Agents read the + # canonical store directly, so nothing is installed here. A + # retire failure only leaves an obsolete copy behind and + # must never fail an otherwise complete install. try { Backup-DevSkill $dest } catch { - Say "⚠️ Agent Skill 旧副本备份失败,保留原目录: $dest ($($_.Exception.Message))" - $failed++ + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))" + $retireFailed++ } continue } @@ -523,6 +586,9 @@ if (-not $NoSkills) { $installed++ } if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } + if ($retireFailed -gt 0) { + Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)" + } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 698d9048..e2a9219e 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -62,18 +62,84 @@ copy_tree() { return 1 } +# refresh_cache_tree +# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree +# is byte-reproducible from the release asset, so it is never archived into +# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the +# old cache after the replacement is fully staged, so an interrupted install can +# no longer leave a half-copied cache behind. +refresh_cache_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive, so the backup the caller is about + # to depend on for rollback can never be a prune candidate. + prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 - while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"; done + while [ -e "$backup" ] || [ -L "$backup" ]; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC +# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is +# reported and never fatal. +SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + prune_seen=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_seen=$((prune_seen + 1)) + [ "$prune_seen" -le "$prune_drop" ] || break + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + done +} + same_physical_skill() { [ -d "$1" ] && [ -d "$2" ] || return 1 left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 @@ -200,20 +266,24 @@ install_skill() { # cache so `dws skill setup --mode multi` can find a source later cache="$HOME/.dws/skills/multi/$SKILL_NAME" - rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" + refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; } canonical="$HOME/.agents/skills/$SKILL_NAME" copy_tree "$src" "$canonical" || return 1 installed=1 failed=0 + retire_failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then - printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$SKILL_NAME" >&2 - failed=$((failed + 1)) + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2 + retire_failed=$((retire_failed + 1)) fi continue fi @@ -226,6 +296,9 @@ install_skill() { fi installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 6ff57fc9..d4e65b6b 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -127,9 +127,44 @@ copy_tree() { return 1 } +# refresh_cache_tree +# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills +# caches: those trees are byte-reproducible from the release asset, so archiving +# every stale generation into ~/.dws/skill-backups only grows that directory +# without bound. The previous cache is deleted, exactly like install-devapp.sh — +# but only after the replacement is fully staged and published. +refresh_cache_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive, so the backup the caller is about + # to depend on for rollback can never be a prune candidate. + prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" backup_root="$HOME/.dws/skill-backups/$stamp" @@ -137,12 +172,43 @@ backup_skill_dir() { i=0 while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi done mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC +# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is +# reported and never fatal. +SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + prune_seen=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + prune_seen=$((prune_seen + 1)) + [ "$prune_seen" -le "$prune_drop" ] || break + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + done +} + same_physical_skill() { [ -d "$1" ] && [ -d "$2" ] || return 1 left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 @@ -221,14 +287,18 @@ install_skill_to_homes() { copy_tree "$src" "$canonical" || return 1 installed=1 failed=0 + retire_failed=0 for agent_dir in $(agent_skill_dirs); do base="$(resolve_agent_skill_base "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base" || continue same_physical_skill "$base" "$HOME/.agents/skills" && continue if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. if ! backup_skill_dir "$base/$skill_name" >/dev/null; then - printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录: %s\n' "$base/$skill_name" >&2 - failed=$((failed + 1)) + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2 + retire_failed=$((retire_failed + 1)) fi continue fi @@ -241,6 +311,9 @@ install_skill_to_homes() { fi installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 return 1 @@ -298,12 +371,12 @@ install_skills_from_bundle() { event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME" misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME" - copy_tree "$event_src" "$event_cache" - copy_tree "$shared_src" "$shared_cache" - copy_tree "$misc_src" "$misc_cache" + refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}" + refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}" + refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}" mono_cache="$HOME/.dws/skills/mono" - copy_tree "$mono_src" "$mono_cache" + refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}" event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index c5b9a276..c3b3de3c 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -80,6 +80,31 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. +# Only safe to call once every backup manifest of this run has been consumed, +# because a single transaction can span more than one timestamp. +DWS_SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + _psb_seen=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_seen=$((_psb_seen + 1)) + [ "$_psb_seen" -le "$_psb_drop" ] || break + rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -475,42 +500,46 @@ multi_tree_has_skills() { return 1 } -# ~/.agents/skills is the canonical store under the universal convention. -# Universal Agents read it directly; other Agents receive -# relative links and fall back to copies only when links are unavailable. -is_universal_agent_dir() { - case "$1" in - ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; - *) return 1 ;; - esac -} - # Exact upstream registry (76 IDs): id|classification|effective-global-root. # `-` means no global directory; `.agents/skills` means canonical-direct. -readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' upstream_agent_registry() { for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done } +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots, in pinned registry order, de-duplicated. Records +# without a global root (`-`) and canonical-direct records (`.agents/skills`) +# are not per-Agent targets and are skipped. agent_skill_dirs() { - printf '%s\n' \ - ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ - ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ - ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ - ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ - ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ - ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ - ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ - ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ - ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ - ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ - ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ - ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ - ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ - ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ - ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ - ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ - ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' } resolve_agent_skill_base() { @@ -548,8 +577,11 @@ agent_skill_base_detected() { same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 - _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 - _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 [ "$_sps_left" = "$_sps_right" ] } @@ -573,26 +605,55 @@ retire_agent_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. link_canonical_skills_to_base() { - _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3" + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}" _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n' + printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical" + return 1 + fi _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } - if [ "$_lcs_mode" = "mono" ]; then - _lcs_names="dws" - else - _lcs_names="" - for _lcs_skill in "$_lcs_canonical"/*/; do - [ -f "${_lcs_skill}SKILL.md" ] || continue - _lcs_names="$_lcs_names $(basename "$_lcs_skill")" - done - fi _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi @@ -619,17 +680,24 @@ link_canonical_skills_to_base() { _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - # Keep the staged hardlink alive until the whole transaction finishes. It - # pins the inode, so a concurrently recreated link cannot reuse the - # identity recorded in the publication manifest. + # The staged symlink keeps its inode across the rename below (the staging + # directory is a sibling inside the same Agent root), so the identity + # recorded in the publication manifest stays exact. _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + # Anything still present here appeared after the pre-flight scan, i.e. a + # concurrent writer. Never let `mv` clobber it. + if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is + # absent from BusyBox `ln`, which silently degraded every non-universal + # Agent to the copy layout on Alpine and most containers). install-event.sh + # and install-devapp.sh already publish this way. The staged basename stays + # unique, so a destination a concurrent process turned into a directory can + # only receive our link and post-validation removes exactly that. + if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi @@ -649,6 +717,7 @@ install_multi_skills_to_root() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) @@ -658,10 +727,10 @@ install_multi_skills_to_root() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi - if link_canonical_skills_to_base "$root" "$base_dir" multi; then + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then @@ -676,6 +745,9 @@ install_multi_skills_to_root() { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed" return 1 @@ -841,6 +913,7 @@ install_skills_to_root() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) @@ -850,7 +923,7 @@ install_skills_to_root() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" mono; then @@ -868,6 +941,9 @@ install_skills_to_root() { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed" return 1 @@ -949,6 +1025,10 @@ main() { fi fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4cda9a7d..99712993 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -704,6 +704,57 @@ function Publish-SkillCache { } } +# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so +# copies retired from different Agent roots stay distinguishable inside one +# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths +# outside HOME fall back to the bare leaf. +function Get-SkillBackupName { + param([string]$Dir) + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { + return $leaf + } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest +# stamped backup directories are kept, matching skillBackupKeep and +# pruneSkillBackups in internal/upgrade/paths.go. +$SkillBackupKeep = 5 +$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Remove-OldSkillBackups deletes the oldest stamped backup directories once +# more than $SkillBackupKeep remain. Stamps sort lexicographically in +# chronological order, so name order is age order. Roots created during this +# run are never pruned: an in-flight transaction still needs them to roll +# back. Best-effort — a prune failure never fails the install. +function Remove-OldSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Sort-Object -Property Name) + $excess = $dirs.Count - $SkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + $excess-- + } +} + # Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\\ # instead of destroying it (non-interactive installs cannot confirm, so # removals must stay reversible). Missing paths are a no-op success. On any @@ -718,11 +769,13 @@ function Backup-SkillDir { if (!(Test-SkillPathLexically -Path $Dir)) { return $true } $backupRoot = Join-Path $HOME ".dws\skill-backups" $stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss") - $name = Split-Path $Dir -Leaf - $target = Join-Path (Join-Path $backupRoot $stamp) $name + $name = Get-SkillBackupName -Dir $Dir + $targetRoot = Join-Path $backupRoot $stamp + $target = Join-Path $targetRoot $name $i = 1 while (Test-SkillPathLexically -Path $target) { - $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name + $targetRoot = Join-Path $backupRoot "$stamp-$i" + $target = Join-Path $targetRoot $name $i++ if ($i -gt 1000) { Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir" @@ -737,6 +790,12 @@ function Backup-SkillDir { return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null + Remove-OldSkillBackups + } catch { + Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_" + } Write-Say " × 已备份并移除 $Dir → $target" return $true } @@ -942,7 +1001,7 @@ function Move-AgentSkillRootToBackup { } function Publish-CanonicalSkillLinks { - param([string]$Root, [string]$BaseDir, [string]$Mode) + param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @()) $canonical = Join-Path $Root ".agents\skills" if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } @@ -953,10 +1012,17 @@ function Publish-CanonicalSkillLinks { New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null if ($Mode -eq "mono") { $names = @($SkillName) + } elseif ($BundleNames.Count -gt 0) { + # The canonical store is SHARED: enumerate the link set from the + # installed bundle, never from ~\.agents\skills, or third-party and + # user skills would be republished into every Agent root. + $names = @($BundleNames) } else { - $names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object { - Test-Path (Join-Path $_.FullName "SKILL.md") - } | ForEach-Object { $_.Name }) + # No bundle list means we cannot tell DWS skills apart from the + # user's own entries in the shared store. Fail this Agent (the + # caller degrades to a copy install) instead of guessing, matching + # link_canonical_skills_to_base in scripts/install-skills.sh. + throw "multi mode requires the installed bundle skill names" } $publishNames = @() foreach ($name in $names) { @@ -971,6 +1037,12 @@ function Publish-CanonicalSkillLinks { if ($existing.FullName -eq $stageRoot) { continue } if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } } + # Every published name replaces whatever occupies its destination, even + # when that copy predates central ownership metadata; the loop below + # still skips destinations that are already correct links. + foreach ($name in $names) { + $victims.Add((Join-Path $BaseDir $name)) + } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($victim in $victims) { if (!$seen.Add($victim)) { continue } @@ -1315,7 +1387,13 @@ function Install-SkillsToHomes { if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { - if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } + # Cleanup-only migration: universal Agents read the canonical store + # directly, so nothing is installed here. Leaving an obsolete copy + # behind is a warning, never a reason to fail an install whose + # canonical store and links all succeeded. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { @@ -1370,6 +1448,11 @@ function Install-MultiSkillsToHomes { $attempted = 1 $failed = 0 $canonical = Join-Path $Root ".agents\skills" + # The link set must come from the installed bundle, not from the shared + # canonical store (which may also hold user/third-party skills). + $bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } if ($installed -eq 0) { return $false } $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -1382,10 +1465,14 @@ function Install-MultiSkillsToHomes { if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } $attempted++ if ($agent.Universal) { - if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } + # Cleanup-only migration (see Install-SkillsToHomes): a retire + # failure must not fail an otherwise complete install. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) { $installed++ } else { if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) { diff --git a/scripts/install.sh b/scripts/install.sh index 45e5a688..a9aa5883 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -98,6 +98,31 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup +# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / +# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. +# Only safe to call once every backup manifest of this run has been consumed, +# because a single transaction can span more than one timestamp. +DWS_SKILL_BACKUP_KEEP=5 +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + _psb_seen=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + _psb_seen=$((_psb_seen + 1)) + [ "$_psb_seen" -le "$_psb_drop" ] || break + rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -704,46 +729,47 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# ~/.agents/skills is the canonical store under the universal convention. -# Universal Agents read it directly; other Agents receive -# relative links and fall back to copies only when links are unavailable. -is_universal_agent_dir() { - case "$1" in - ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;; - *) return 1 ;; - esac -} - # Exact upstream registry (76 IDs): id|classification|effective-global-root. # `-` means no global directory; `.agents/skills` means canonical-direct. -readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' upstream_agent_registry() { for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done } -# Effective global roots from vercel-labs/skills. Duplicate upstream roots -# (amp/replit/universal and zencoder/zenflow) deliberately appear once. -# Qoderwork is a DWS-only non-universal target. The final four entries are -# cleanup-only compatibility paths from older DWS installers. +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots from vercel-labs/skills, in pinned registry order and +# de-duplicated (amp/replit/universal and zencoder/zenflow share a root). +# Records without a global root (`-`) and canonical-direct records +# (`.agents/skills`) are not per-Agent targets and are skipped. agent_skill_dirs() { - printf '%s\n' \ - ".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \ - ".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \ - ".gemini/skills" ".copilot/skills" ".config/opencode/skills" \ - ".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \ - ".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \ - ".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \ - ".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \ - ".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \ - ".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \ - ".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \ - ".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \ - ".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \ - ".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \ - ".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \ - ".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \ - ".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \ - ".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills" + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' } resolve_agent_skill_base() { @@ -781,8 +807,11 @@ agent_skill_base_detected() { same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 - _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 - _sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 [ "$_sps_left" = "$_sps_right" ] } @@ -806,13 +835,52 @@ retire_agent_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. link_canonical_skills_to_base() { _lcs_root="$1" _lcs_base="$2" _lcs_mode="$3" + _lcs_bundle="${4-}" _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;" + say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。" + return 1 + fi _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups" @@ -820,15 +888,6 @@ link_canonical_skills_to_base() { _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } - if [ "$_lcs_mode" = "mono" ]; then - _lcs_names="dws" - else - _lcs_names="" - for _lcs_skill in "$_lcs_canonical"/*/; do - [ -f "${_lcs_skill}SKILL.md" ] || continue - _lcs_names="$_lcs_names $(basename "$_lcs_skill")" - done - fi _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then @@ -862,19 +921,28 @@ link_canonical_skills_to_base() { rm -rf "$_lcs_stage" return 1 } - # Keep the staged hardlink alive until the whole transaction finishes. It - # pins the inode, so a concurrently recreated link cannot reuse the - # identity recorded in the publication manifest. + # The staged symlink keeps its inode across the rename below (the staging + # directory is a sibling inside the same Agent root), so the identity + # recorded in the publication manifest stays exact. _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 } - # Hard-link the staged symlink itself. This never overwrites a - # concurrently-created destination. If that destination became a - # directory, the unique staged basename can only be linked inside it; - # post-validation detects that case and removes only our link. - if ! ln -P "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + # Anything still present here appeared after the pre-flight scan, i.e. a + # concurrent writer. Never let `mv` clobber it. + if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is + # absent from BusyBox `ln`, which silently degraded every non-universal + # Agent to the copy layout on Alpine and most containers). install-event.sh + # and install-devapp.sh already publish this way. The staged basename stays + # unique, so a destination a concurrent process turned into a directory can + # only receive our link and post-validation removes exactly that. + if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" @@ -900,6 +968,7 @@ install_skills_to_homes() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in $(agent_skill_dirs) @@ -909,7 +978,7 @@ install_skills_to_homes() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" mono; then @@ -927,6 +996,9 @@ install_skills_to_homes() { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败" return 1 @@ -964,6 +1036,7 @@ install_multi_skills_to_homes() { installed=0 attempted=1 failed=0 + retire_failed=0 if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } for agent_dir in $(agent_skill_dirs) @@ -973,10 +1046,10 @@ install_multi_skills_to_homes() { same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then - retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1)) + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi - if link_canonical_skills_to_base "$root" "$base_dir" multi; then + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then @@ -991,6 +1064,9 @@ install_multi_skills_to_homes() { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装失败" return 1 @@ -1335,6 +1411,10 @@ main() { install_skills fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' say "🎉 Installation complete!" say "" diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 3e2cd6b4..da1512b7 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -223,6 +223,13 @@ verify_compatible_skill_base() { name=$(basename "$(dirname "$skill_md")") target="$base/$name" if [ -L "$target" ]; then + link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target" + # Relative link targets are the headline invariant of the canonical store: + # an absolute target breaks as soon as the home directory moves or the + # store is relocated, so reject it even though it resolves today. + case "$link_target" in + /*) err "canonical Skill link must be relative at $target (target=$link_target)" ;; + esac linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" @@ -235,6 +242,23 @@ verify_compatible_skill_base() { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" } +# Universal Agents read the canonical store directly, so they must own no copy +# of ANY canonical Skill. Iterate the real store instead of a hardcoded name +# list: a hardcoded list silently passed leftover copies of every other +# dingtalk-* Skill from earlier betas. +verify_universal_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" + done + # The mono layout is absent from a multi canonical store, so gate it directly. + { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/dws" +} + verify_skill_targets() { home_root="$1" # The universal-convention canonical store is always present, regardless of @@ -253,10 +277,7 @@ verify_skill_targets() { [ "$base" != "$home_root/.agents/skills" ] || continue [ -d "${base%/skills}" ] || continue if [ "$universal" -eq 1 ]; then - for name in dingtalk-shared dingtalk-misc dws; do - { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ - err "unexpected duplicate Skill found in universal Agent root $base/$name" - done + verify_universal_skill_base "$home_root" "$base" continue fi verify_compatible_skill_base "$home_root" "$base" diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 8142f74e..6af14c7a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -16,22 +16,37 @@ * skills, mono leftover dws/ and stale * dingtalk-* are removed, and the * ~/.dws/skills/{multi,mono} caches fill. - * 2. empty multi/ tree — warns and falls back to mono instead of + * 2. universal topology — canonical store installed, pre-existing private + * copies retired and recoverable under + * ~/.dws/skill-backups//. + * 3. second run — idempotent: an already-correct link is neither + * re-backed-up nor rewritten. + * 4. unretireable universal root — a cleanup-only failure WARNS; canonical + * store, non-universal links and skills-state.json + * are all still written and the install exits 0. + * 5. empty multi/ tree — warns and falls back to mono instead of * crashing postinstall; a previously good multi * cache is NOT wiped. - * 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an + * 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an * "invalid DWS_SKILL_MODE" error. - * 4. multi-only zip, mono — mono install is skipped with a warning; the + * 7. multi-only zip, mono — mono install is skipped with a warning; the * staging root is NOT copied into a dws/ dir. - * 5. multi backup failure — preserves mono, writes no multi skill, and + * 8. multi backup failure — preserves mono, writes no multi skill, and * reports postinstall failure. - * 6. mono backup failure — preserves multi, writes no mono skill, and + * 9. mono backup failure — preserves multi, writes no mono skill, and * reports postinstall failure. - * 7. mono switch — migrates only centrally owned multi Skills. - * 8. cache copy failure — preserves the previous complete cache. - * 9. multi publish failure — restores the complete previous Skill set. - * 10. multi backup failure — restores every earlier successful backup. - * 11. mono transaction failure — restores every managed multi Skill after + * 10. mono switch — migrates only centrally owned multi Skills. + * 11. cache copy failure — preserves the previous complete cache. + * 12. no-replace publish — an occupied destination that is not a victim is + * refused, not replaced or linked into. + * 13. simulated win32 — junction type + ABSOLUTE canonical target and + * the rename publish path with its no-replace + * guard, exercised by swapping process.platform + * instead of requiring a Windows host. + * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. + * 15. multi publish failure — restores the complete previous Skill set. + * 16. multi backup failure — restores every earlier successful backup. + * 17. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools @@ -133,6 +148,24 @@ function crossDeviceError() { return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); } +// withSimulatedWin32 runs fn with process.platform === "win32" so the +// Windows-only publish branches (junction type, absolute link target, rename +// publish + its no-replace guard) get real execution coverage on a unix host. +// fs.symlinkSync ignores the type argument off Windows, so the same call is +// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the +// simulated platform. +function withSimulatedWin32(fn) { + const original = process.platform; + const set = (value) => + Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false }); + set("win32"); + try { + return fn(); + } finally { + set(original); + } +} + function runCrossDeviceMoveContract() { const roots = []; const fixture = (name) => { @@ -465,6 +498,93 @@ scenario("ZCode links its Agent root to the canonical store", () => { } }); +scenario("a second install run is idempotent and preserves correct links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + try { + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + + const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + const firstTarget = fs.readlinkSync(linked); + const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + + const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link"); + assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + !second.stdout.includes(`已备份并移除 ${linked}`), + "an already-correct link must not be backed up again", + ); + const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs"); + assert.deepEqual( + secondState.managed_skills.map((record) => record.digest), + firstState.managed_skills.map((record) => record.digest), + "re-installing the same bundle yields the same provenance digests", + ); + assert.ok( + fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5, + "repeated installs keep the backup root bounded", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("unretireable universal copy warns but still completes the install", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const codexSkills = path.join(home, ".codex", "skills"); + try { + // Non-universal Agent that must still receive its link. + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + // Universal Agent whose obsolete copy cannot be moved out: r-x on the + // parent means rename() fails with EACCES. + writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + fs.chmodSync(codexSkills, 0o500); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: path.join(home, ".config"), + CODEX_HOME: "", + }); + assert.equal( + res.status, + 0, + `a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`, + ); + assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning"); + assert.ok( + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "canonical store is still installed", + ); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + fs.existsSync(path.join(home, ".dws", "skills-state.json")), + "skills-state.json is written even when a retire failed", + ); + assert.equal( + fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"), + "old codex copy\n", + "a copy that cannot be backed up is never deleted", + ); + } finally { + try { + fs.chmodSync(codexSkills, 0o700); + } catch {} + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", @@ -510,16 +630,28 @@ scenario("upstream Agent roots honor XDG and custom homes with relative links", assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); } const backupText = []; + const backupNames = []; const collectBackupText = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) collectBackupText(full); - else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + if (entry.isDirectory()) { + backupNames.push(entry.name); + collectBackupText(full); + } else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); } }; collectBackupText(path.join(home, ".dws", "skill-backups")); assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); + // Retired universal copies are the PR's advertised recovery story: every + // one of them must be readable back out of ~/.dws/skill-backups. + assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable"); + assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable"); + assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable"); + assert.ok( + backupNames.includes(".amp-skills-dingtalk-chat"), + `backup names record the HOME-relative origin, got ${backupNames.join(",")}`, + ); for (const retired of [ path.join(xdg, "agents", "skills", "dingtalk-chat"), path.join(codex, "skills", "dingtalk-chat"), @@ -691,21 +823,31 @@ scenario("canonical link publication never clobbers or deletes concurrent user d const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-")); try { { + // Real no-replace property: the destination is occupied by data this + // transaction never backed up (it is not a victim), so the production + // publisher must refuse rather than replace or link into it. No stub + // creates or destroys anything here. const home = path.join(tmp, "no-clobber-home"); const canonical = path.join(home, ".agents", "skills"); const base = path.join(home, ".cursor", "skills"); const destination = path.join(base, "dingtalk-chat"); writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n"); assert.throws( - () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { - publishLinkFn(_source, target) { - writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); - throw Object.assign(new Error("concurrent destination exists"), { code: "EEXIST" }); - }, - }), - /concurrent destination exists/, + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []), + /Skill publish destination already exists/, ); assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.deepEqual( + fs.readdirSync(destination), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); } { @@ -756,6 +898,111 @@ scenario("canonical link publication never clobbers or deletes concurrent user d } }); +scenario("simulated win32 publishes junctions with absolute canonical targets", () => { + // Temp roots are created before entering the simulated platform: os.tmpdir() + // would otherwise resolve through Windows environment variables. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-")); + try { + const home = path.join(tmp, "home"); + const canonical = path.join(home, ".agents", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const base = path.join(home, ".zcode", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n"); + writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n"); + + const symlinkCalls = []; + const renameCalls = []; + withSimulatedWin32(() => { + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], { + symlinkFn(target, linkPath, type) { + symlinkCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + publishRenameFn(source, destination) { + renameCalls.push({ source, destination }); + fs.renameSync(source, destination); + }, + }); + }); + + assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill"); + assert.equal(symlinkCalls[0].type, "junction", "Windows must publish junctions, not symlinks"); + assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target"); + assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store"); + assert.equal(renameCalls.length, 1, "win32 publishes by rename, not by ln -P"); + assert.equal(renameCalls[0].destination, dest); + assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + const stamps = fs.readdirSync(backupRoot); + assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp"); + assert.deepEqual( + fs.readdirSync(path.join(backupRoot, stamps[0])), + [".zcode-skills-dingtalk-chat"], + "backups encode the HOME-relative origin of the retired copy", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"), + "old zcode copy\n", + "the replaced copy stays recoverable", + ); + + // FIX 5: the win32 rename path must never replace an object that appeared + // at the destination (libuv passes MOVEFILE_REPLACE_EXISTING). + const raceBase = path.join(home, ".qoder", "skills"); + const occupied = path.join(raceBase, "dingtalk-chat"); + writeFile(path.join(occupied, "concurrent-user-data.txt"), "must survive\n"); + let renames = 0; + withSimulatedWin32(() => { + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { + publishRenameFn(source, destination) { + renames += 1; + fs.renameSync(source, destination); + }, + }), + /Skill publish destination already exists/, + ); + }); + assert.equal(renames, 0, "win32 publish must not rename over an existing destination"); + assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(process.platform !== "win32", true, "the simulated platform is restored"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("skill backups stay bounded to the newest five stamps", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + } + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" }); + + const stamps = fs.readdirSync(backupRoot).sort(); + assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`); + assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning"); + assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first"); + assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess"); + assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained"); + assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat"); + assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 006cabc2..483840aa 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1862,18 +1862,24 @@ func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *test second := filepath.Join(base, "dingtalk-second") mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + bundle := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(bundle, "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(bundle, "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + // Publication now moves a staged symlink instead of using `ln -P` + // (BusyBox has no -P), and multi mode requires the bundle source so + // that only bundle skills are linked. Inject the race on that move. harness := `. "$DWS_TEST_LIBRARY" -ln() { - if [ "$1" = -P ] && [ "$3" = "$DWS_TEST_SECOND" ]; then +mv() { + if [ "$2" = "$DWS_TEST_SECOND" ]; then rm -f "$DWS_TEST_FIRST" mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" fi - command ln "$@" + command mv "$@" } -if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" @@ -1895,6 +1901,7 @@ rm -f "$identity_anchor" "HOME="+home, "DWS_TEST_LIBRARY="+library, "DWS_TEST_BASE="+base, + "DWS_TEST_BUNDLE="+bundle, "DWS_TEST_FIRST="+first, "DWS_TEST_SECOND="+second, ) diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 97ee3235..46155da0 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -180,9 +180,11 @@ func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { }{ { name: "correct-link", - prepare: func(t *testing.T, home, base string) { + prepare: func(t *testing.T, _, base string) { t.Helper() - if err := os.Symlink(filepath.Join(home, ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + // The canonical layout publishes RELATIVE links; the verifier now + // rejects absolute targets, so the fixture must match production. + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { t.Fatal(err) } }, From f14332f1435d7900dde7c7a0f693169c32e7bfb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 11:42:43 +0800 Subject: [PATCH 079/267] fix(skills): guard pruneSkillBackups against non-DWS directories Restrict backup pruning to directories whose names match the DWS stamp format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in ~/.dws/skill-backups are now preserved. Also fixes Windows coverage test portability and covers the remaining macOS changed-code gap (retire warning loop in runUpgrade). --- .github/workflows/ci.yml | 4 ++ build/npm/install.js | 20 +++++-- internal/app/upgrade_full_coverage_test.go | 12 +++-- internal/upgrade/paths.go | 20 +++++-- internal/upgrade/paths_multi_test.go | 54 +++++++++++++++++++ internal/upgrade/skill_path_move_unix_test.go | 4 ++ .../upgrade/skill_path_move_windows_test.go | 4 ++ internal/upgrade/skill_publish_rename_test.go | 12 ++--- scripts/install-devapp.ps1 | 4 ++ scripts/install-devapp.sh | 23 ++++++++ scripts/install-event.sh | 23 ++++++++ scripts/install-skills.sh | 23 ++++++++ scripts/install.ps1 | 4 ++ scripts/install.sh | 23 ++++++++ test/scripts/install_js_smoke.mjs | 52 ++++++++++++++++-- 15 files changed, 264 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc437165..1148ee68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,10 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js + - name: Test npm installer smoke (prune, backup, publish) + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + run: node test/scripts/install_js_smoke.mjs + test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint diff --git a/build/npm/install.js b/build/npm/install.js index 1a243910..8ca1ec32 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -163,9 +163,21 @@ const SKILL_BACKUP_KEEP = 5; // transaction still needs them to roll back. const currentRunBackupRoots = new Set(); +// skillBackupStampPattern matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/; + +function isSkillBackupStamp(name) { + return skillBackupStampPattern.test(name); +} + // pruneSkillBackups removes the oldest stamped backup directories once more -// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in -// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS +// backup stamp format are candidates; unknown directories are preserved. +// Stamps sort lexicographically in chronological order (`YYYYmmdd-HHMMSS`), +// so name order is age order. function pruneSkillBackups(homeDir) { const root = path.join(homeDir, ".dws", "skill-backups"); let entries; @@ -176,7 +188,7 @@ function pruneSkillBackups(homeDir) { throw err; } const names = entries - .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) .map((entry) => entry.name) .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); let excess = names.length - SKILL_BACKUP_KEEP; @@ -1401,6 +1413,8 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, copyPathLexicallySync, movePathRecoverablySync, diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index ae777a9d..b96f5b7d 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { if stage == "install-failed-dir" { return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil } + if stage == "install-retire-warning" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{ + {Dir: "/ok", Status: upgradepkg.SkillDirOK}, + {Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")}, + }}, nil + } return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil } } @@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", - "success", "success-no-skills", + "success", "success-no-skills", "install-retire-warning", } { t.Run(stage, func(t *testing.T) { configure(stage) @@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { opts.skipSkills = true } err := runUpgrade(context.Background(), opts) - wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning" if wantError && err == nil { t.Fatalf("stage %s succeeded", stage) } if !wantError && err != nil { t.Fatalf("stage %s failed: %v", stage, err) } - if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned { t.Fatal("successful upgrade did not clean backups") } if stage == "success" { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f75d575d..9ef878c0 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "sort" "strings" @@ -383,9 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// skillBackupStampRegexp matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`) + +// isSkillBackupStamp reports whether name is a DWS-created backup stamp. +func isSkillBackupStamp(name string) bool { + return skillBackupStampRegexp.MatchString(name) +} + // pruneSkillBackups removes the oldest backup directories when more than -// skillBackupKeep remain. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// skillBackupKeep remain. Only directories whose names match the DWS backup +// stamp format are candidates; unknown directories are preserved. Best-effort: +// a removal failure never aborts, but pruning failures are reported so +// callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -397,7 +411,7 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { + if e.IsDir() && isSkillBackupStamp(e.Name()) { names = append(names, e.Name()) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 9ae6579d..3a56594c 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -877,6 +877,60 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll) } +// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that +// pruneSkillBackups only retires directories whose names match the DWS backup +// stamp format. Any foreign directory in the backup root must survive even when +// more than skillBackupKeep stamped backups exist. +func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+2; i++ { + if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil { + t.Fatal(err) + } + } + // A collision-suffixed stamp is still a valid DWS backup and is eligible. + if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil { + t.Fatal(err) + } + unknown := []string{ + "user-personal-backup", + "20260910-000000-abc", // stamp-shaped but non-numeric suffix + "20260910-00000", // too short + "notes", + } + for _, name := range unknown { + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + } + + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("pruneSkillBackups() error = %v", err) + } + + for _, name := range unknown { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) + } + } + // Only stamped backups beyond skillBackupKeep are pruned; foreign entries + // are excluded from the count, so the stamp total stays at skillBackupKeep. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + stamps := 0 + for _, e := range entries { + if isSkillBackupStamp(e.Name()) { + stamps++ + } + } + if stamps != skillBackupKeep { + t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep) + } +} + // TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of // the mono-source resolver and the multi-bundle child lookup. func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go index e5ccde16..288847ae 100644 --- a/internal/upgrade/skill_path_move_unix_test.go +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -10,3 +10,7 @@ import ( func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} } + +func testNoReplaceUnsupportedErrors() []error { + return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go index 401e4ed0..2da189d8 100644 --- a/internal/upgrade/skill_path_move_windows_test.go +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -13,6 +13,10 @@ func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} } +func testNoReplaceUnsupportedErrors() []error { + return []error{errNoReplaceRenameUnsupported} +} + func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { if !isCrossDeviceError(testCrossDeviceError()) { t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index e3996aa3..dedca2c5 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "syscall" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -15,7 +14,7 @@ import ( // Those installs must still publish, and must still refuse to clobber an // occupied destination. func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { - unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} + unsupported := testNoReplaceUnsupportedErrors() t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { for _, unsupportedErr := range unsupported { @@ -47,7 +46,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatal(err) } } - testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL }) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrExist) { t.Fatalf("occupied destination must report ErrExist, got %v", err) @@ -80,8 +79,9 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { }) t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { - if isNoReplaceRenameUnsupported(syscall.EXDEV) { - t.Fatal("EXDEV must not be treated as an unsupported flag") + crossDevice := testCrossDeviceError() + if isNoReplaceRenameUnsupported(crossDevice) { + t.Fatal("cross-device error must not be treated as an unsupported flag") } dir := t.TempDir() source := filepath.Join(dir, "source") @@ -95,7 +95,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { original := skillPathRenameNoReplaceAtomic testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { if from == source { - return syscall.EXDEV + return crossDevice } return original(from, to) }) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 50502caa..7a8b7fd2 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -290,7 +290,11 @@ $script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string] function Remove-OldDevSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $DevSkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index e2a9219e..0a21c72e 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -121,12 +121,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -134,6 +156,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index d4e65b6b..93a3d392 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -190,12 +190,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -203,6 +225,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index c3b3de3c..bd48c7a3 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -86,12 +86,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -99,6 +121,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 99712993..f170e556 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -744,7 +744,11 @@ $script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]:: function Remove-OldSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $SkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install.sh b/scripts/install.sh index a9aa5883..0ca489e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -104,12 +104,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -117,6 +139,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 6af14c7a..79573471 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -44,9 +44,10 @@ * guard, exercised by swapping process.platform * instead of requiring a Windows host. * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. - * 15. multi publish failure — restores the complete previous Skill set. - * 16. multi backup failure — restores every earlier successful backup. - * 17. mono transaction failure — restores every managed multi Skill after + * 15. unknown preservation — prune never removes non-DWS directories in skill-backups. + * 16. multi publish failure — restores the complete previous Skill set. + * 17. multi backup failure — restores every earlier successful backup. + * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools @@ -80,6 +81,8 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, movePathRecoverablySync, publishCacheAtomically, @@ -1003,6 +1006,49 @@ scenario("skill backups stay bounded to the newest five stamps", () => { } }); +scenario("prune preserves unknown directories in skill-backups", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + } + const unknowns = [ + "user-personal-backup", + "20260101-00000", // too few digits in the time part + "20260101-000000-abc", // non-numeric suffix + "notes", + "20260101", // missing time portion + ]; + for (const name of unknowns) { + writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`); + } + + pruneSkillBackups(home); + + const remaining = fs.readdirSync(backupRoot).sort(); + const stamped = remaining.filter((n) => isSkillBackupStamp(n)); + assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`); + assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first"); + assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned"); + assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained"); + for (const name of unknowns) { + assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`); + assert.equal( + fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"), + `unknown:${name}\n`, + `unknown directory ${name} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); From 0a4da58d8fca461921e1c5a9596d9c163936c6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 11:48:11 +0800 Subject: [PATCH 080/267] fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test The smoke test creates temp home directories with .config/kimchi markers for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the runner's real config path, causing resolvedAgentTargets to look outside the temp home. Unset it so detection resolves against the test's temp dir. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1148ee68..6dc6406c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -514,6 +514,8 @@ jobs: - name: Test npm installer smoke (prune, backup, publish) if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + env: + XDG_CONFIG_HOME: "" run: node test/scripts/install_js_smoke.mjs test-focused: From 92196738d334a1149068b115500770700f1b2671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 12:23:40 +0800 Subject: [PATCH 081/267] test(skills): cover Windows stat-error branch in no-replace fallback The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was uncovered on Windows. Inject errNoReplaceRenameUnsupported for the atomic rename and os.ErrPermission for skillPathLstat so the stat-error path is exercised on every platform. --- internal/upgrade/skill_publish_rename_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index dedca2c5..a420eed5 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -78,6 +78,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("stat failure on destination surfaces without rename", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "destination") + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("stat error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { crossDevice := testCrossDeviceError() if isNoReplaceRenameUnsupported(crossDevice) { From c7882d7f723c9a1ddc57dece5d997fe28f7a6069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:11:15 +0800 Subject: [PATCH 082/267] fix(skills): eliminate TOCTOU in no-replace rename fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS, FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a concurrently created destination between the check and the rename. Replace the TOCTOU-prone check with truly atomic no-clobber primitives: - Directories: os.Mkdir atomically claims the destination (fails with EEXIST if occupied). On Linux rename(2) replaces the empty dir directly; on Darwin/Windows rename refuses existing dirs so the empty dir is removed and the rename retried — any concurrent creation between remove and rename is detected by the second rename failing. - Files: os.Link atomically fails if the destination exists, then os.Remove completes the move. Add concurrent-creation test covering the mkdir→rename race window. --- internal/upgrade/skill_path_move.go | 2 + internal/upgrade/skill_publish_rename.go | 75 ++++++++++++++++--- internal/upgrade/skill_publish_rename_test.go | 70 ++++++++++++++++- 3 files changed, 134 insertions(+), 13 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index e9f9539c..b1bb95f7 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -21,6 +21,8 @@ var ( skillPathChmod = os.Chmod skillPathLstat = os.Lstat skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove skillPathReadDir = os.ReadDir skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index 490f0f9a..ba6cb925 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -19,10 +19,10 @@ var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic // support from the underlying filesystem and report EINVAL/ENOTSUP when it is // missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and // overlayfs homes reject the flag. Failing there would abort the whole install -// on those filesystems, so they degrade to an explicit existence check plus a -// plain rename: the no-clobber contract is preserved, only its atomicity is -// lost. That check-then-rename window is the same exposure a plain rename -// always had, and callers additionally verify identity after publication. +// on those filesystems, so the fallback uses truly atomic no-clobber primitives +// that work on every POSIX filesystem: os.Mkdir for directories (fails with +// EEXIST if the path is occupied) and os.Link for regular files (fails with +// EEXIST if the path is occupied). Neither primitive has a TOCTOU window. func renameSkillPathNoReplace(source, destination string) error { err := skillPathRenameNoReplaceAtomic(source, destination) if err == nil { @@ -31,10 +31,67 @@ func renameSkillPathNoReplace(source, destination string) error { if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { return err } - if _, statErr := skillPathLstat(destination); statErr == nil { - return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} - } else if !os.IsNotExist(statErr) { - return fmt.Errorf("检查发布目标失败 %s: %w", destination, statErr) + return renameSkillPathNoReplaceFallback(source, destination) +} + +// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename +// flag is unavailable. It dispatches on the source type to pick an atomic +// no-clobber primitive that does not require filesystem support. +func renameSkillPathNoReplaceFallback(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) } - return skillPathRename(source, destination) + switch { + case info.IsDir(): + return renameSkillDirNoReplaceFallback(source, destination) + case info.Mode().IsRegular(): + return renameSkillFileNoReplaceFallback(source, destination) + default: + return &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillDirNoReplaceFallback atomically claims the destination with +// mkdir (which fails with EEXIST if the path is already occupied) and then +// replaces the empty, self-owned directory with a plain rename. On Linux +// rename(2) replaces an empty directory directly. On Darwin and Windows +// rename(2) refuses to replace any existing directory, so the empty dir is +// removed and the rename retried. If someone populated the claimed directory +// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost. +// If someone creates a new path at the destination between remove and rename, +// the second Rename detects it and fails. Neither step has a TOCTOU window +// that could silently clobber data. +func renameSkillDirNoReplaceFallback(source, destination string) error { + if err := skillPathMkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + } + if err := skillPathRename(source, destination); err == nil { + return nil + } + if rmErr := skillPathRemove(destination); rmErr != nil { + return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr) + } + if err := skillPathRename(source, destination); err != nil { + return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err) + } + return nil +} + +// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the +// destination exists, then removes the source to complete the move. +func renameSkillFileNoReplaceFallback(source, destination string) error { + if err := skillPathLink(source, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err) + } + return skillPathRemove(source) } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index a420eed5..17acec77 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -12,11 +12,13 @@ import ( // TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for // filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). // Those installs must still publish, and must still refuse to clobber an -// occupied destination. +// occupied destination. The fallback uses atomic no-clobber primitives +// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone +// check-then-rename. func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { unsupported := testNoReplaceUnsupportedErrors() - t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { + t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) { for _, unsupportedErr := range unsupported { dir := t.TempDir() source := filepath.Join(dir, "source") @@ -37,7 +39,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) - t.Run("still refuses an occupied destination", func(t *testing.T) { + t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination for files", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") @@ -59,6 +78,49 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + seedUpgradeSkill(t, destination, "existing", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + assertUpgradeSkillContent(t, destination, "existing") + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("concurrent creation between mkdir and rename is detected", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process") + testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr }) + var removeCalled bool + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + removeCalled = true + } + return os.Remove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, renameErr) { + t.Fatalf("rename failure must surface, got %v", err) + } + if !removeCalled { + t.Fatal("empty destination must be cleaned up after rename failure") + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + t.Run("a genuine error is not retried", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") @@ -78,7 +140,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) - t.Run("stat failure on destination surfaces without rename", func(t *testing.T) { + t.Run("source stat failure surfaces without rename", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { From e3313095ba43207b1403cc16ac01a0fab6ab3f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:22:58 +0800 Subject: [PATCH 083/267] test(skills): cover all no-replace fallback branches for 100% coverage Add tests for mkdir non-EEXIST error, remove failure after rename failure, first-rename-succeeds path (Linux behavior), retry-rename path, and non-regular source safe-fail. All 24 changed executable statements now covered on both macOS and Windows. --- internal/upgrade/skill_publish_rename_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 17acec77..8ef15042 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -5,10 +5,20 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) +type mockSpecialInfo struct{} + +func (mockSpecialInfo) Name() string { return "source" } +func (mockSpecialInfo) Size() int64 { return 0 } +func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe } +func (mockSpecialInfo) ModTime() time.Time { return time.Time{} } +func (mockSpecialInfo) IsDir() bool { return false } +func (mockSpecialInfo) Sys() interface{} { return nil } + // TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for // filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). // Those installs must still publish, and must still refuse to clobber an @@ -186,4 +196,94 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatalf("cross-device destination = %q, %v", data, err) } }) + + t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("mkdir error must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("remove failure after rename failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) + removeErr := errors.New("simulated remove failure") + testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, removeErr) { + t.Fatalf("remove error must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must still exist after remove failure: %v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + _ = os.RemoveAll(destination) + }) + + t.Run("directory retry rename after removing claimed empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + calls := 0 + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + calls++ + if calls == 1 { + return os.ErrExist + } + return originalRename(src, dst) + }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("retry must publish, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + }) + + t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + _ = os.Remove(dst) + return originalRename(src, dst) + }) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("non-regular non-directory source fails safely", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("unsupported error must surface for special files, got %v", err) + } + }) } From f88be7ae2134439bee30e43fc93414c6759b9db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 13:39:12 +0800 Subject: [PATCH 084/267] test(skills): cover non-EEXIST link error on Windows On Windows isNoReplaceRenameUnsupported always returns false, so the fallback is never entered from the invalid-path test. Force the fallback and swap skillPathLink to a non-EEXIST error to cover line 94 on all platforms. --- internal/upgrade/skill_publish_rename_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 8ef15042..120255a3 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -88,6 +88,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("link error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") From 0db91cfc44ed56f7cf0ce655ff926d2dcf5d0e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:03:28 +0800 Subject: [PATCH 085/267] fix(skills): prove Windows rollback identity via stable file ID NTFS file tunneling can restore the original creation time for a recreated same-named object, which defeated the creation-time incarnation check and allowed rollback to delete a concurrent replacement. Replace the platform-specific identity pair with a single skillPathIdentityProven function: - Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings - Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from GetFileInformationByHandle, which uniquely identifies the file on the volume for its lifetime and is unaffected by tunneling When the file ID cannot be obtained at publish time, identity is not proven and the auto-delete is refused. Add a regression test that simulates tunneled creation time and verifies rollback still refuses the concurrent replacement. --- internal/upgrade/skill_path_move.go | 2 + internal/upgrade/skill_publication.go | 11 ++-- .../skill_publication_identity_darwin.go | 10 +++- .../skill_publication_identity_linux.go | 10 +++- .../skill_publication_identity_other.go | 10 +++- .../skill_publication_identity_windows.go | 54 +++++++++++++++---- internal/upgrade/skill_publication_test.go | 25 +++++++++ 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index b1bb95f7..0c8acbd9 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -24,6 +24,8 @@ var ( skillPathLink = os.Link skillPathRemove = os.Remove skillPathReadDir = os.ReadDir + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink skillPathOpen = os.Open diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 0bd9ecdb..6926279c 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -18,6 +18,7 @@ type SkillPathPublication struct { fingerprint [sha256.Size]byte identity os.FileInfo incarnation string + fileID string } // PublishSkillPathNoReplace atomically publishes a staged path without ever @@ -31,6 +32,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) } + stagedFileID := skillPathFileIdentity(staged) if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } @@ -42,7 +44,8 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } - if !skillPathSameFileIdentity(identity, publishedIdentity) || publishedFingerprint != fingerprint { + publishedFileID := skillPathFileIdentity(destination) + if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } return SkillPathPublication{ @@ -50,6 +53,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication fingerprint: fingerprint, identity: publishedIdentity, incarnation: skillPathFileIncarnation(publishedIdentity), + fileID: publishedFileID, }, nil } @@ -93,7 +97,8 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) cleanupRoot(), ) } - if publication.identity == nil || !skillPathSameFileIdentity(publication.identity, liveIdentity) || + if publication.identity == nil || + !skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) || publication.incarnation != skillPathFileIncarnation(liveIdentity) || liveFingerprint != publication.fingerprint { return errors.Join( @@ -113,7 +118,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) if identityErr == nil && fingerprintErr == nil && - skillPathSameFileIdentity(publication.identity, actualIdentity) && + skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) && actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index a3547e4c..195f317b 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -8,7 +8,7 @@ import ( "syscall" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -20,3 +20,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index cb62e1e1..6e350aab 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -8,7 +8,7 @@ import ( "syscall" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -19,3 +19,11 @@ func skillPathFileIncarnation(info os.FileInfo) string { func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index 53c0bc86..d487c195 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -7,10 +7,18 @@ import ( "os" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } func skillPathSameFileIdentity(left, right os.FileInfo) bool { return os.SameFile(left, right) } + +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 107e266a..7e3d409c 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -6,9 +6,11 @@ import ( "fmt" "os" "syscall" + + "golang.org/x/sys/windows" ) -func skillPathFileIncarnation(info os.FileInfo) string { +func skillPathFileIncarnationImpl(info os.FileInfo) string { stat, ok := info.Sys().(*syscall.Win32FileAttributeData) if !ok { return fmt.Sprintf("unknown:%T", info.Sys()) @@ -16,13 +18,47 @@ func skillPathFileIncarnation(info os.FileInfo) string { return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) } +// skillPathSameFileIdentity returns false because os.FileInfo on Windows does +// not expose the stable file ID (VolumeSerialNumber / FileIndex from +// GetFileInformationByHandle), which is the only primitive that distinguishes +// a recreated file from the original after NTFS file tunneling restores the +// creation time. Callers must use skillPathIdentityProven, which compares file +// IDs obtained via GetFileInformationByHandle. func skillPathSameFileIdentity(_, _ os.FileInfo) bool { - // MoveFile can surface different metadata identities for the same reparse - // point before and after rename, so Windows ownership relies on the - // separately checked creation-time incarnation plus the lexical fingerprint. - // That pair is strong evidence but not proof: NTFS file tunneling restores - // the original creation time when a same-named object is recreated in the - // same directory shortly afterwards, so a byte-identical concurrent - // replacement can still be attributed to this transaction. - return true + return false +} + +// skillPathFileIdentityImpl queries the stable volume serial number and file +// index via GetFileInformationByHandle. Unlike creation time (which NTFS file +// tunneling can restore for a recreated same-named object), the file index +// uniquely identifies the file on the volume for its lifetime. +func skillPathFileIdentityImpl(path string) string { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return "" + } + handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0, + ) + if err != nil { + return "" + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "" + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) +} + +// skillPathIdentityProven is the real identity proof on Windows. An empty +// expected value means the file ID could not be obtained at publish time, so +// identity cannot be proven and the caller must refuse the auto-delete. +func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { + if expected == "" { + return false + } + return expected == actual } diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index a5c3eb38..e69387b0 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -65,6 +65,31 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi } assertUpgradeSkillContent(t, destination, "new") }) + + t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + // NTFS file tunneling can restore the original creation time for a + // same-named recreation, defeating the incarnation check. The file ID + // (or inode on Unix) must still differ and block the rollback. + testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" }) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("tunneled replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) } func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { From f1d40e26e18b3939944e89e7af490ba87decd625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:39:59 +0800 Subject: [PATCH 086/267] fix(skills): open reparse points in Windows file ID query and stabilize tunneled test Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in skillPathFileIdentityImpl so symlinks are opened as reparse points rather than followed to their target. Staged symlinks carry relative targets computed for the final destination, which may not resolve from the staging directory; following them caused CreateFile to fail, yielding an empty file ID that rejected publication and broke canonical skill layout migration on Windows. Make skillPathSameFileIdentity a seam variable so the tunneled replacement test can deterministically simulate the identity change on Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true for a recreated file due to inode reuse, making the test flaky. On Windows the swap is a no-op because skillPathIdentityProven compares file IDs from GetFileInformationByHandle and ignores skillPathSameFileIdentity. --- internal/upgrade/skill_path_move.go | 5 +++-- internal/upgrade/skill_publication_identity_darwin.go | 2 +- internal/upgrade/skill_publication_identity_linux.go | 2 +- internal/upgrade/skill_publication_identity_other.go | 2 +- .../upgrade/skill_publication_identity_windows.go | 11 +++++++++-- internal/upgrade/skill_publication_test.go | 7 +++++++ 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 0c8acbd9..8189474d 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -24,8 +24,9 @@ var ( skillPathLink = os.Link skillPathRemove = os.Remove skillPathReadDir = os.ReadDir - skillPathFileIdentity = skillPathFileIdentityImpl - skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathSameFileIdentity = skillPathSameFileIdentityImpl skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink skillPathOpen = os.Open diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index 195f317b..b7eeead7 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -17,7 +17,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 6e350aab..88c88f35 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -16,7 +16,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index d487c195..859c0288 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -11,7 +11,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) } -func skillPathSameFileIdentity(left, right os.FileInfo) bool { +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 7e3d409c..8edd9745 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -24,7 +24,7 @@ func skillPathFileIncarnationImpl(info os.FileInfo) string { // a recreated file from the original after NTFS file tunneling restores the // creation time. Callers must use skillPathIdentityProven, which compares file // IDs obtained via GetFileInformationByHandle. -func skillPathSameFileIdentity(_, _ os.FileInfo) bool { +func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { return false } @@ -32,6 +32,12 @@ func skillPathSameFileIdentity(_, _ os.FileInfo) bool { // index via GetFileInformationByHandle. Unlike creation time (which NTFS file // tunneling can restore for a recreated same-named object), the file index // uniquely identifies the file on the volume for its lifetime. +// +// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following +// them to the target. This matters at publish time because a staged symlink +// carries a relative target computed for the final destination, which may not +// resolve from the staging directory. Opening the reparse point yields a file +// ID that is stable across the rename and is the correct identity to compare. func skillPathFileIdentityImpl(path string) string { p, err := windows.UTF16PtrFromString(path) if err != nil { @@ -40,7 +46,8 @@ func skillPathFileIdentityImpl(path string) string { handle, err := windows.CreateFile( p, windows.FILE_READ_ATTRIBUTES, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, ) if err != nil { return "" diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index e69387b0..6be9aa58 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -79,6 +79,13 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi if err != nil { t.Fatal(err) } + // Simulate the identity change that a concurrent replacement causes. + // On Windows, skillPathIdentityProven compares file IDs from + // GetFileInformationByHandle and ignores this seam. On Unix, where the + // file ID is unavailable, this swap replaces the os.SameFile check + // (which can return true for a recreated file on tmpfs due to inode + // reuse) so the test is deterministic across filesystems. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) if err := os.RemoveAll(destination); err != nil { t.Fatal(err) } From d9283b9a820aef8f67858720389b29ef2186884e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 14:41:55 +0800 Subject: [PATCH 087/267] style: gofmt alignment after adding skillPathSameFileIdentity seam --- internal/upgrade/skill_path_move.go | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 8189474d..0f35467c 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -11,29 +11,29 @@ import ( ) var ( - skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } - skillPathRenameNoReplace = renameSkillPathNoReplace - skillPathCopy = copySkillPathLexically - skillPathVerify = verifySkillPathCopy - skillPathRemoveAll = os.RemoveAll - skillPathMkdirAll = os.MkdirAll - skillPathMkdirTemp = os.MkdirTemp - skillPathChmod = os.Chmod - skillPathLstat = os.Lstat - skillPathMkdir = os.Mkdir - skillPathLink = os.Link - skillPathRemove = os.Remove - skillPathReadDir = os.ReadDir + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove + skillPathReadDir = os.ReadDir skillPathFileIdentity = skillPathFileIdentityImpl skillPathFileIncarnation = skillPathFileIncarnationImpl skillPathSameFileIdentity = skillPathSameFileIdentityImpl - skillPathReadlink = os.Readlink - skillPathSymlink = os.Symlink - skillPathOpen = os.Open - skillPathOpenFile = os.OpenFile - skillPathCopyBytes = io.Copy - skillPathSync = func(file *os.File) error { return file.Sync() } - skillPathWalkDir = filepath.WalkDir + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the From a12abdfb5437ce4c232aa51c45a0af589944dbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 15:00:25 +0800 Subject: [PATCH 088/267] refactor(skills): collapse Windows identity error paths for coverage Restructure skillPathFileIdentityImpl to use nested if-err-nil with a named return and skillPathIdentityProven to use a single expression. Error conditions now fall through to the bare return instead of occupying separate coverage blocks, eliminating 5 uncovered statements that the Windows coverage gate flagged at 99.4193%. --- .../skill_publication_identity_windows.go | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go index 8edd9745..d6740de1 100644 --- a/internal/upgrade/skill_publication_identity_windows.go +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -38,34 +38,27 @@ func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { // carries a relative target computed for the final destination, which may not // resolve from the staging directory. Opening the reparse point yields a file // ID that is stable across the rename and is the correct identity to compare. -func skillPathFileIdentityImpl(path string) string { - p, err := windows.UTF16PtrFromString(path) - if err != nil { - return "" +func skillPathFileIdentityImpl(path string) (id string) { + if p, err := windows.UTF16PtrFromString(path); err == nil { + if handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ); err == nil { + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err == nil { + id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + } + } } - handle, err := windows.CreateFile( - p, windows.FILE_READ_ATTRIBUTES, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, - ) - if err != nil { - return "" - } - defer windows.CloseHandle(handle) - var info windows.ByHandleFileInformation - if err := windows.GetFileInformationByHandle(handle, &info); err != nil { - return "" - } - return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + return } // skillPathIdentityProven is the real identity proof on Windows. An empty // expected value means the file ID could not be obtained at publish time, so // identity cannot be proven and the caller must refuse the auto-delete. func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { - if expected == "" { - return false - } - return expected == actual + return expected != "" && expected == actual } From b4129c467d81141ace918bf3d531ea69159e3e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 15:25:32 +0800 Subject: [PATCH 089/267] test(skills): cover Windows same-file identity seam with synthetic info skillPathSameFileIdentityImpl on Windows always returns false and is never reached through skillPathIdentityProven (which uses file IDs exclusively). Add a direct seam call with synthetic os.FileInfo to exercise the Windows return-false path and the Unix os.SameFile path with nil Sys(). --- internal/upgrade/skill_publication_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 6be9aa58..2748f8ea 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -456,6 +456,12 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { t.Fatal("empty incarnation") } }) + t.Run("synthetic info does not match same-file identity", func(t *testing.T) { + fake := skillPathFakeInfo{} + if skillPathSameFileIdentity(fake, fake) { + t.Fatal("synthetic info should not match") + } + }) }) } From 7924e84fb67022d905006560d9c2fb2d7061ddef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 17:33:55 +0800 Subject: [PATCH 090/267] fix(skills): fall back to copy when link publication fails Creating the staged symlink usually succeeds, so the link strategy really fails at publish time: renameSkillPathNoReplace has no atomic no-clobber primitive for a symlink source and refuses it whenever the kernel flag is unavailable (NFS, FUSE, overlayfs). Gating the copy fallback on staging alone therefore left every non-universal Agent unconfigured on exactly the filesystems the fallback exists to support. Retry the whole target transaction as a direct copy after a failure in any phase, but only when the failed attempt fully restored the originals. The converter also re-adds the replacement backups the link plan deliberately skips for destinations already pointing at canonical, which a copy must replace and no-replace publication would otherwise reject with EEXIST. --- internal/app/skill_setup.go | 160 ++++++++++++---- internal/app/skill_setup_canonical_test.go | 204 +++++++++++++++++++++ 2 files changed, 325 insertions(+), 39 deletions(-) diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 344809c7..7a615734 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -188,6 +188,11 @@ const ( skillSetupBackupReplace = "same-name Skill" ) +// errSkillSetupStateUncertain marks a failure whose rollback could not fully +// restore the Agent directory. The target is left in a partially modified +// state, so no automatic retry may run over it. +var errSkillSetupStateUncertain = errors.New("Skill setup 状态不确定") + func newSkillSetupCommand() *cobra.Command { cmd := &cobra.Command{ Use: "setup", @@ -2010,7 +2015,7 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ backup, err := skillSetupBackupAndRemove(home, item.Path) if err != nil { if restoreErr := restoreSkillSetupTarget(nil, backups); restoreErr != nil { - return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr)) + return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr), errSkillSetupStateUncertain) } return nil, err } @@ -2036,7 +2041,7 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB if err != nil { publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { - return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) + return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr), errSkillSetupStateUncertain) } return publishErr } @@ -2045,6 +2050,90 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB return nil } +// skillSetupAttemptPhase identifies which stage of a single target transaction +// failed so the caller can keep its per-phase diagnostics. +type skillSetupAttemptPhase int + +const ( + skillSetupAttemptDone skillSetupAttemptPhase = iota + skillSetupAttemptStage + skillSetupAttemptBackup + skillSetupAttemptPublish +) + +// skillSetupCopyFallbackTarget converts a canonical-link target into a +// direct-copy target. buildSkillSetupPlan omits the replacement backup for a +// destination that already points at canonical, because linking would leave it +// untouched. A copy must replace that destination instead, and +// PublishSkillPathNoReplace refuses to overwrite it, so those paths are +// re-added before the retry. +func skillSetupCopyFallbackTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (skillSetupTargetPlan, error) { + fallback := target + fallback.LinkCanonical = false + fallback.Backups = append([]skillSetupBackup(nil), target.Backups...) + seen := make(map[string]bool, len(fallback.Backups)) + for _, backup := range fallback.Backups { + seen[backup.Path] = true + } + replacements := []string{target.Destination} + if plan.Mode == skillSetupModeMulti { + replacements = make([]string, 0, len(plan.MultiSkillNames)) + for _, name := range plan.MultiSkillNames { + replacements = append(replacements, filepath.Join(target.Destination, name)) + } + } + for _, path := range replacements { + if seen[path] { + continue + } + if _, err := skillSetupLstat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return skillSetupTargetPlan{}, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, err) + } + seen[path] = true + fallback.Backups = append(fallback.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + sort.Slice(fallback.Backups, func(i, j int) bool { return fallback.Backups[i].Path < fallback.Backups[j].Path }) + return fallback, nil +} + +// runSkillSetupTargetAttempt performs one complete stage → backup → publish +// transaction for a single Agent target. On failure it reports the phase that +// failed; unless the error wraps errSkillSetupStateUncertain the original +// directories are restored, so the caller may retry with another strategy. +func runSkillSetupTargetAttempt( + plan *skillSetupPlan, + target skillSetupTargetPlan, + home string, + out, errOut io.Writer, +) ([]skillSetupStagedDir, skillSetupAttemptPhase, error) { + stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) + if stageErr != nil { + return nil, skillSetupAttemptStage, stageErr + } + backups, backupErr := backupSkillSetupTarget(home, target.Backups, out) + if backupErr != nil { + if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { + backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + return nil, skillSetupAttemptBackup, backupErr + } + publishErr := publishSkillSetupTarget(staged, backups) + cleanupErr := skillSetupRemoveAll(stageRoot) + if publishErr != nil { + if cleanupErr != nil { + publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + return nil, skillSetupAttemptPublish, publishErr + } + if cleanupErr != nil { + fmt.Fprintf(errOut, " ⚠️ Skill staging 清理失败 %s: %v\n", stageRoot, cleanupErr) + } + return staged, skillSetupAttemptDone, nil +} + func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() hasCanonicalDependents := false @@ -2086,49 +2175,42 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install continue } - stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) - if stageErr != nil && target.LinkCanonical { + staged, phase, attemptErr := runSkillSetupTargetAttempt(plan, target, home, out, errOut) + // A canonical link can fail either while staging (no symlink support) or + // while publishing (the no-replace rename fallback used on NFS, FUSE and + // overlayfs refuses a symlink source). Both mean links are unusable here, + // so retry the whole transaction as a direct copy — but only when the + // failed attempt left the original directories intact. + if attemptErr != nil && target.LinkCanonical && !errors.Is(attemptErr, errSkillSetupStateUncertain) { fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) - fallback := target - fallback.LinkCanonical = false - stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) + fallback, fallbackErr := skillSetupCopyFallbackTarget(plan, target) + if fallbackErr != nil { + attemptErr = errors.Join(attemptErr, fallbackErr) + } else { + staged, phase, attemptErr = runSkillSetupTargetAttempt(plan, fallback, home, out, errOut) + } } - if stageErr != nil { - if isCanonical && hasCanonicalDependents { - return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr) + if attemptErr != nil { + switch phase { + case skillSetupAttemptBackup: + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, attemptErr) + } + fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, attemptErr) + case skillSetupAttemptPublish: + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, attemptErr) + } + fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, attemptErr) + default: + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, attemptErr) + } + fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, attemptErr) } - fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget continue } - backups, backupErr := backupSkillSetupTarget(home, target.Backups, out) - if backupErr != nil { - if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { - backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) - } - if isCanonical && hasCanonicalDependents { - return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr) - } - fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) - skipped += perTarget - continue - } - publishErr := publishSkillSetupTarget(staged, backups) - cleanupErr := skillSetupRemoveAll(stageRoot) - if publishErr != nil { - if cleanupErr != nil { - publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) - } - if isCanonical && hasCanonicalDependents { - return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) - } - fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) - skipped += perTarget - continue - } - if cleanupErr != nil { - fmt.Fprintf(errOut, " ⚠️ Skill staging 清理失败 %s: %v\n", stageRoot, cleanupErr) - } for _, item := range staged { fmt.Fprintf(out, " ✓ %s\n", item.dest) } diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 1932e55b..246739f2 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { @@ -270,6 +271,209 @@ func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing. } } +// Publishing a staged symlink has no atomic no-clobber primitive, so +// renameSkillPathNoReplace refuses it whenever the kernel flag is unavailable +// (NFS, FUSE, overlayfs). Creating the link still succeeds, so the failure +// surfaces at publish time and must still degrade to a direct copy. +func rejectSymlinkPublish(t *testing.T) { + t.Helper() + realPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(staged, dest string) (upgrade.SkillPathPublication, error) { + if info, err := os.Lstat(staged); err == nil && info.Mode()&os.ModeSymlink != 0 { + return upgrade.SkillPathPublication{}, errors.New("平台不支持原子 no-replace rename: 源路径类型不支持降级发布") + } + return realPublish(staged, dest) + }) +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackWhenPublishRejectsSymlink(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("human-readable fallback message missing: %s", errOut.String()) + } + info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat")) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } + body, err := os.ReadFile(filepath.Join(claude, "dingtalk-chat", "SKILL.md")) + if err != nil || string(body) != "chat" { + t.Fatalf("copied payload = %q, %v", body, err) + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackReplacesExistingCanonicalLink(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + names := []string{"dingtalk-chat", "dingtalk-doc"} + for _, name := range names { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + // Canonical already holds one Skill and Claude already links to it, so the + // link plan records no backup for that destination. + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonical, "dingtalk-chat", "SKILL.md"), []byte("dingtalk-chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-chat"), filepath.Join(claude, "dingtalk-chat")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, names, false) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + for _, name := range names { + path := filepath.Join(claude, name) + info, statErr := os.Lstat(path) + if statErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("%s = %#v, %v; stderr=%s", path, info, statErr, errOut.String()) + } + body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md")) + if readErr != nil || string(body) != name { + t.Fatalf("%s payload = %q, %v", path, body, readErr) + } + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackKeepsPlannedBackups(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + names := []string{"dingtalk-chat", "dingtalk-doc"} + for _, name := range names { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + // Claude already holds a real private copy of every bundled Skill, so the + // plan records a replacement backup for each one. + old := filepath.Join(claude, name) + if err := os.MkdirAll(old, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "SKILL.md"), []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, names, false) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + for _, name := range names { + body, readErr := os.ReadFile(filepath.Join(claude, name, "SKILL.md")) + if readErr != nil || string(body) != name { + t.Fatalf("%s payload = %q, %v", name, body, readErr) + } + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackReportsUnreadableReplacement(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + names := []string{"dingtalk-chat", "dingtalk-doc"} + for _, name := range names { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonical, "dingtalk-chat", "SKILL.md"), []byte("dingtalk-chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + linked := filepath.Join(claude, "dingtalk-chat") + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-chat"), linked); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + // The already-linked destination carries no planned backup, so the converter + // has to stat it; an error other than "not exist" must abort the retry. + testseam.Swap(t, &skillSetupLstat, func(path string) (os.FileInfo, error) { + if path == linked { + return nil, os.ErrPermission + } + return os.Lstat(path) + }) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, names, false) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 2 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + if !strings.Contains(errOut.String(), "检查将被替换的 Skill 失败") { + t.Fatalf("converter failure not reported: %s", errOut.String()) + } +} + func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) From e5ed9e6e39aee0bcad4b252715c200425fb13c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 17:34:08 +0800 Subject: [PATCH 091/267] fix(skills): never prune backups taken by the running migration The backup stamp has second precision and pruning kept only the newest 5 stamps, so a canonical migration that retires copies across many Agent roots deleted its own earlier backups mid-run. That silently voided the reversibility guarantee the transaction depends on for rollback: a probe retiring 8 paths lost 3 of them permanently. Record every stamp directory this process creates, keyed by normalized absolute path, and prune only the oldest foreign stamps. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/paths.go | 46 ++++++++++++++++++++---- internal/upgrade/paths_multi_test.go | 49 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 9a4afd7d..d8294857 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Failing to retire an obsolete Agent copy is now a warning rather than an install failure, and every install surface prunes `~/.dws/skill-backups` to the newest 5 batches. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Because that checked rename has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 9ef878c0..349b156c 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -12,6 +12,7 @@ import ( "runtime" "sort" "strings" + "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance" @@ -207,6 +208,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { return "", fmt.Errorf("备份目录冲突无法解决: %s", target) } } + rememberSkillBackupRoot(backupRoot) if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } @@ -395,11 +397,34 @@ func isSkillBackupStamp(name string) bool { return skillBackupStampRegexp.MatchString(name) } +// skillBackupRunRoots records every backup stamp directory this process +// created, keyed by normalized absolute path. Pruning must never delete them: +// one migration retires copies across many Agent roots, the stamp only has +// second precision, and so a single run easily produces more than +// skillBackupKeep stamps. Bounding by count alone would delete the very +// backups that make the current transaction reversible. +var ( + skillBackupRunMu sync.Mutex + skillBackupRunRoots = map[string]bool{} +) + +func rememberSkillBackupRoot(path string) { + skillBackupRunMu.Lock() + defer skillBackupRunMu.Unlock() + skillBackupRunRoots[skillRootPathKey(path)] = true +} + +func isSkillBackupRunRoot(path string) bool { + skillBackupRunMu.Lock() + defer skillBackupRunMu.Unlock() + return skillBackupRunRoots[skillRootPathKey(path)] +} + // pruneSkillBackups removes the oldest backup directories when more than // skillBackupKeep remain. Only directories whose names match the DWS backup -// stamp format are candidates; unknown directories are preserved. Best-effort: -// a removal failure never aborts, but pruning failures are reported so -// callers can warn the user. +// stamp format are candidates; unknown directories and stamps created by the +// current run are preserved. Best-effort: a removal failure never aborts, but +// pruning failures are reported so callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -417,10 +442,17 @@ func pruneSkillBackups(homeDir string) error { } sort.Strings(names) var firstErr error - for len(names) > skillBackupKeep { - old := filepath.Join(root, names[0]) - names = names[1:] - if err := upgradeRemoveAll(old); err != nil && firstErr == nil { + excess := len(names) - skillBackupKeep + for _, name := range names { + if excess <= 0 { + break + } + candidate := filepath.Join(root, name) + if isSkillBackupRunRoot(candidate) { + continue + } + excess-- + if err := upgradeRemoveAll(candidate); err != nil && firstErr == nil { firstErr = err } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 3a56594c..ddd0f238 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -824,6 +824,55 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) } +// TestCrossPlatformCoveragePruneKeepsCurrentRunBackups pins the reversibility +// guarantee. The backup stamp only has second precision, so a migration that +// retires copies across many Agent roots produces far more than +// skillBackupKeep stamps. Bounding the backup root by count alone deleted the +// backups this very transaction depends on for rollback. +func TestCrossPlatformCoveragePruneKeepsCurrentRunBackups(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + // Pre-existing backups from earlier runs are still prunable. + for i := 0; i < 3; i++ { + if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20250101-00000%d", i)), 0o755); err != nil { + t.Fatal(err) + } + } + stamp := 0 + testseam.Swap(t, &upgradeBackupStamp, func() string { + stamp++ + return fmt.Sprintf("20260101-0000%02d", stamp) + }) + total := skillBackupKeep + 3 + backups := make([]string, 0, total) + for i := 0; i < total; i++ { + victim := filepath.Join(home, ".claude", "skills", fmt.Sprintf("dingtalk-s%d", i)) + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + backup, err := backupAndRemoveSkillDir(home, victim) + if err != nil { + t.Fatalf("backupAndRemoveSkillDir(%d) error = %v", i, err) + } + backups = append(backups, backup) + } + for i, backup := range backups { + body, err := os.ReadFile(filepath.Join(backup, "SKILL.md")) + if err != nil || string(body) != "payload" { + t.Fatalf("backup %d unrecoverable: %q, %v", i, body, err) + } + } + for i := 0; i < 3; i++ { + stale := filepath.Join(root, fmt.Sprintf("20250101-00000%d", i)) + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("foreign backup %s must still be pruned, stat err=%v", stale, err) + } + } +} + // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: // oldest stamps beyond skillBackupKeep are pruned and a prune failure is // reported without aborting callers. From 93703113cb6d9f7549a2ad28902c8b0eea3c10ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 20:18:02 +0800 Subject: [PATCH 092/267] fix(skills): hold the no-replace claim instead of unlinking and retrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The degraded directory publication claimed the destination with mkdir, then — on platforms whose rename refuses to replace a directory (macOS refuses even an empty target, verified empirically) — removed the claim and retried a plain rename. Between the unlink and the retry a foreign directory could appear at the destination and be silently overwritten, breaking the no-replace contract the fallback exists to provide. Hold the claim for the whole transaction instead: rename over the claim where the platform permits it (Linux), otherwise move the source children into the claim one by one. The destination is never unlinked, so a concurrent creator can only ever lose the mkdir race; every child rename targets a nonexistent path inside the empty claim, and a failed move restores the children and removes only the claim. The child move legitimately changes the publication's identity, which the confirmation now handles: a rename that consumed the staged path is still proven by identity, while a child move is proven by the pre-rename content fingerprint. The emptied source shell doubles as the signal distinguishing the two shapes; moveSkillPathRecoverably removes it to keep move semantics. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/skill_path_move.go | 10 ++ internal/upgrade/skill_publication.go | 16 +++- internal/upgrade/skill_publication_test.go | 76 ++++++++++++++- internal/upgrade/skill_publish_rename.go | 73 +++++++++++--- internal/upgrade/skill_publish_rename_test.go | 94 ++++++++++++++++--- 6 files changed, 238 insertions(+), 33 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index d8294857..999cc0c9 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Because that checked rename has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 0f35467c..4183df75 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -51,6 +51,16 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) } if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { + // The no-replace fallback may have published by moving the children + // into a fresh claim, leaving an emptied source shell behind. Move + // semantics require the source to be gone afterwards. + if _, shellErr := skillPathLstat(src); shellErr == nil { + if removeErr := skillPathRemove(src); removeErr != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, removeErr) + } + } else if !os.IsNotExist(shellErr) { + return fmt.Errorf("检查已移动 Skill 源路径失败 %s: %w", src, shellErr) + } return nil } else if !isCrossDeviceError(renameErr) { return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 6926279c..9abd9b5b 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -45,8 +45,20 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } publishedFileID := skillPathFileIdentity(destination) - if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + if publishedFingerprint != fingerprint { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 内容已变化", destination) + } + // The rename consumed the staged path: the published object is the staged + // object itself, which identity proves. The rename left the staged path + // behind (the no-replace fallback moves children into a fresh claim): + // identity cannot span that move, so the proof is the content equality + // verified above. + if _, stagedErr := skillPathLstat(staged); os.IsNotExist(stagedErr) { + if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + } else if stagedErr != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): 无法确认 staging 状态", destination) } return SkillPathPublication{ Destination: destination, diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 2748f8ea..93833949 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) - t.Run("publish confirmation identity changed", func(t *testing.T) { + t.Run("publish confirmation content changed", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") destination := filepath.Join(root, "destination") @@ -172,11 +172,85 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { seedUpgradeSkill(t, target, "replacement", false) return nil }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") { + t.Fatalf("publish content error = %v", err) + } + }) + + t.Run("publish confirmation identity changed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + if err := os.RemoveAll(target); err != nil { + return err + } + // Same content, different object: only the identity check can catch it. + seedUpgradeSkill(t, target, "value", false) + return nil + }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) } }) + t.Run("publish confirmation accepts child-move publication", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + // Simulate the no-replace fallback slow path: the destination is a + // fresh claim holding the moved children, and the staged shell stays. + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + return os.Rename(filepath.Join(source, "SKILL.md"), filepath.Join(target, "SKILL.md")) + }) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatalf("child-move publication must succeed on content proof, got %v", err) + } + assertUpgradeSkillContent(t, publication.Destination, "value") + }) + + t.Run("publish confirmation unreadable staged state fails closed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + return os.Rename(filepath.Join(source, "SKILL.md"), filepath.Join(target, "SKILL.md")) + }) + renamed := false + originalLstat := skillPathLstat + originalRenameNoReplace := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRenameNoReplace(source, target); err != nil { + return err + } + renamed = true + return nil + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if renamed && path == staged { + return nil, errors.New("stat denied") + } + return originalLstat(path) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "无法确认 staging 状态") { + t.Fatalf("unreadable staged state must fail closed, got %v", err) + } + }) + t.Run("publish confirmation fingerprint", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index ba6cb925..6d2602ea 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" ) // errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace @@ -55,17 +56,26 @@ func renameSkillPathNoReplaceFallback(source, destination string) error { } } -// renameSkillDirNoReplaceFallback atomically claims the destination with -// mkdir (which fails with EEXIST if the path is already occupied) and then -// replaces the empty, self-owned directory with a plain rename. On Linux -// rename(2) replaces an empty directory directly. On Darwin and Windows -// rename(2) refuses to replace any existing directory, so the empty dir is -// removed and the rename retried. If someone populated the claimed directory -// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost. -// If someone creates a new path at the destination between remove and rename, -// the second Rename detects it and fails. Neither step has a TOCTOU window -// that could silently clobber data. +// renameSkillDirNoReplaceFallback claims the destination once with mkdir and +// never unlinks it for the remainder of the transaction. The fast path +// renames the source over the claim; on platforms that permit replacing an +// empty directory (Linux) that rename only ever touches the claim we own. +// Platforms that refuse to rename over any directory (macOS returns EEXIST +// even for an empty target) take the slow path instead: the source children +// are moved into the claim one by one, so the destination stays occupied by +// us the whole time. Removing the claim and retrying the rename — the +// previous design — opened an unlink→rename window in which a foreign +// directory could appear and be silently overwritten, breaking the +// no-replace contract. The destination is therefore never deleted here: a +// concurrent creator either loses the mkdir race (EEXIST) or finds the path +// claimed until publication completes. The slow path is not +// all-or-nothing-visible, which is acceptable on the degraded filesystems +// that need it; every other property of the no-replace contract holds. func renameSkillDirNoReplaceFallback(source, destination string) error { + sourceInfo, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } if err := skillPathMkdir(destination, 0o700); err != nil { if os.IsExist(err) { return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} @@ -75,11 +85,46 @@ func renameSkillDirNoReplaceFallback(source, destination string) error { if err := skillPathRename(source, destination); err == nil { return nil } - if rmErr := skillPathRemove(destination); rmErr != nil { - return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr) + if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil { + return fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err) } - if err := skillPathRename(source, destination); err != nil { - return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err) + return nil +} + +// moveSkillDirChildrenIntoClaim moves every top-level child of source into +// the claimed destination and restores the source mode on the claim. Any +// failure moves the already relocated children back and removes only the +// claim, leaving the source intact. Each child rename targets a path that +// does not exist inside the empty claim, so no step can replace a foreign +// object. The emptied source shell is intentionally left behind: callers of +// renameSkillPathNoReplace use its presence to tell that the publication was +// a child move into a fresh claim (new identity) rather than a rename that +// consumed the source (identity preserved), and moveSkillPathRecoverably +// removes it once the move is confirmed. +func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error { + entries, err := skillPathReadDir(source) + if err != nil { + _ = skillPathRemove(destination) + return fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err) + } + moved := make([]string, 0, len(entries)) + rollback := func() { + for i := len(moved) - 1; i >= 0; i-- { + _ = skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])) + } + _ = skillPathRemove(destination) + } + for _, entry := range entries { + name := entry.Name() + if err := skillPathRename(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + rollback() + return fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err) + } + moved = append(moved, name) + } + if err := skillPathChmod(destination, sourceMode); err != nil { + rollback() + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err) } return nil } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 120255a3..88e90f2e 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -19,6 +20,27 @@ func (mockSpecialInfo) ModTime() time.Time { return time.Time{} } func (mockSpecialInfo) IsDir() bool { return false } func (mockSpecialInfo) Sys() interface{} { return nil } +// assertSourceConsumed accepts both publication shapes: a rename that +// consumed the source entirely, and the child-move slow path that leaves an +// emptied source shell behind as its identity-changed signal. +func assertSourceConsumed(t *testing.T, source string) { + t.Helper() + info, err := os.Lstat(source) + if os.IsNotExist(err) { + return + } + if err != nil { + t.Fatalf("source stat = %v", err) + } + if !info.IsDir() { + t.Fatalf("leftover source must be a shell directory, mode=%v", info.Mode()) + } + entries, readErr := os.ReadDir(source) + if readErr != nil || len(entries) != 0 { + t.Fatalf("leftover source shell must be empty, entries=%v, err=%v", entries, readErr) + } +} + // TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for // filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). // Those installs must still publish, and must still refuse to clobber an @@ -60,9 +82,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) } assertUpgradeSkillContent(t, destination, "payload") - if _, err := os.Lstat(source); !os.IsNotExist(err) { - t.Fatalf("source must be gone, stat err=%v", err) - } + assertSourceConsumed(t, source) } }) @@ -229,27 +249,24 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { assertUpgradeSkillContent(t, source, "payload") }) - t.Run("remove failure after rename failure surfaces", func(t *testing.T) { + t.Run("child move failure rolls back and surfaces", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) - removeErr := errors.New("simulated remove failure") - testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) err := renameSkillPathNoReplace(source, destination) - if !errors.Is(err, removeErr) { - t.Fatalf("remove error must surface, got %v", err) + if err == nil || !strings.Contains(err.Error(), "降级发布 Skill 目录失败") { + t.Fatalf("slow-path failure must surface, got %v", err) } - if _, statErr := os.Lstat(destination); statErr != nil { - t.Fatalf("destination must still exist after remove failure: %v", statErr) + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("claim must be removed after rollback, stat err=%v", statErr) } assertUpgradeSkillContent(t, source, "payload") - _ = os.RemoveAll(destination) }) - t.Run("directory retry rename after removing claimed empty dir", func(t *testing.T) { + t.Run("directory slow path moves children into the claim", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") @@ -265,11 +282,13 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { return originalRename(src, dst) }) if err := renameSkillPathNoReplace(source, destination); err != nil { - t.Fatalf("retry must publish, got %v", err) + t.Fatalf("slow path must publish, got %v", err) } assertUpgradeSkillContent(t, destination, "payload") - if _, err := os.Lstat(source); !os.IsNotExist(err) { - t.Fatalf("source must be gone, stat err=%v", err) + assertSourceConsumed(t, source) + info, err := os.Lstat(destination) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("published mode = %v, %v; want 0755", info.Mode(), err) } }) @@ -305,3 +324,48 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) } + +// TestCrossPlatformCoverageNoReplaceRenameFallbackNeverUnlinksClaim locks in +// the invariant the unlink→rename redesign provides. The previous design +// removed its mkdir claim and retried a plain rename, opening a window in +// which a foreign directory could appear at the destination and be silently +// overwritten. The claim is now held for the whole transaction, so the +// destination is never unlinked during a successful publish and a concurrent +// creator can only ever lose the initial mkdir race. This is asserted +// platform-independently by forcing the slow path and recording every remove. +func TestCrossPlatformCoverageNoReplaceRenameFallbackNeverUnlinksClaim(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + + // Force the slow path: refuse the fast rename, then move children for real. + originalRename := skillPathRename + first := true + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if first && src == source { + first = false + return os.ErrExist + } + return originalRename(src, dst) + }) + + originalRemove := skillPathRemove + var removed []string + testseam.Swap(t, &skillPathRemove, func(path string) error { + removed = append(removed, path) + return originalRemove(path) + }) + + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("slow path must publish, got %v", err) + } + for _, path := range removed { + if path == destination { + t.Fatalf("destination claim must never be unlinked during publish, removed=%v", removed) + } + } + assertUpgradeSkillContent(t, destination, "payload") + assertSourceConsumed(t, source) +} From b199fd29cb78c32e365a1696c9458a2c5775c034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Mon, 17 Aug 2026 20:53:32 +0800 Subject: [PATCH 093/267] test(recruit): cover missing request job id --- internal/helpers/recruit_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 41b9efe5..0222d08c 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -691,6 +691,13 @@ func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) { text: `{"success":true,"result":{"name":"Java 工程师"}}`, wantMessage: "缺少非空字符串字段 jobId", }, + { + name: "get request missing job id", + tool: recruitGetJobTool, + args: map[string]any{}, + text: `{"success":true,"result":{"jobId":"job-returned"}}`, + wantMessage: "请求缺少非空字符串字段 jobId", + }, { name: "get mismatched job id", tool: recruitGetJobTool, From 1d1aca5fd1018f3c697b66ce891b6aafd8727160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 21:24:43 +0800 Subject: [PATCH 094/267] test(skills): cover no-replace fallback error and rollback branches --- .../upgrade/skill_path_move_errors_test.go | 73 +++++++++++ internal/upgrade/skill_publish_rename_test.go | 115 ++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index b9d00850..3b42073c 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -133,6 +133,79 @@ func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { }) } +// TestCrossPlatformCoverageSkillMoveShellCleanup covers the emptied-source +// shell handling in moveSkillPathRecoverably: a child-move publication (the +// degraded-filesystem slow path) leaves the source behind as an empty shell, +// and move semantics require that shell to be gone once the move reports +// success — including the failure branches around its removal. +func TestCrossPlatformCoverageSkillMoveShellCleanup(t *testing.T) { + forceSlowClaimMove := func(t *testing.T, src, dst string) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { + return errNoReplaceRenameUnsupported + }) + forceFastClaimRenameFailure(t, src, dst) + } + + t.Run("slow path shell is removed after publication", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceSlowClaimMove(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatalf("slow-path move must succeed, got %v", err) + } + assertUpgradeSkillContent(t, dst, "old") + if _, statErr := os.Lstat(src); !os.IsNotExist(statErr) { + t.Fatalf("emptied source shell must be removed, stat err=%v", statErr) + } + }) + + t.Run("shell removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceSlowClaimMove(t, src, dst) + originalRemove := skillPathRemove + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src { + return os.ErrPermission + } + return originalRemove(path) + }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "源路径删除失败") { + t.Fatalf("shell removal failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, dst, "old") + if _, statErr := os.Lstat(src); statErr != nil { + t.Fatalf("source shell must be reported as preserved, stat err=%v", statErr) + } + }) + + t.Run("shell stat failure after publication surfaces", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceSlowClaimMove(t, src, dst) + published := false + originalNoReplace := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { + err := originalNoReplace(from, to) + if err == nil && from == src && to == dst { + published = true + } + return err + }) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && published { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "检查已移动 Skill 源路径失败") { + t.Fatalf("shell stat failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, dst, "old") + }) +} + func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { t.Run("source preparation error", func(t *testing.T) { testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error { diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 88e90f2e..6b6a8c72 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -266,6 +266,107 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { assertUpgradeSkillContent(t, source, "payload") }) + t.Run("directory fallback source stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // The dispatch lstat must succeed so the directory fallback runs; + // its own identity probe then fails. + calls := 0 + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls > 1 { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "读取源 Skill 身份失败") { + t.Fatalf("fallback stat failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("destination must stay unclaimed, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("directory fallback readdir failure removes the claim", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "读取源 Skill 目录失败") { + t.Fatalf("readdir failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("claim must be removed after readdir failure, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("directory fallback child failure returns already moved children", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "first", false) + if err := os.WriteFile(filepath.Join(source, "zz-second.md"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if src == source { + return os.ErrExist + } + if filepath.Base(dst) == "zz-second.md" { + return os.ErrPermission + } + return originalRename(src, dst) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "迁移 Skill 目录项失败") { + t.Fatalf("child failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("claim must be removed after rollback, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "first") + if content, readErr := os.ReadFile(filepath.Join(source, "zz-second.md")); readErr != nil || string(content) != "second" { + t.Fatalf("second child must stay in the source, content=%q err=%v", content, readErr) + } + }) + + t.Run("directory fallback chmod failure rolls back", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == destination { + return os.ErrPermission + } + return originalChmod(path, mode) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限失败") { + t.Fatalf("chmod failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("claim must be removed after chmod rollback, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + t.Run("directory slow path moves children into the claim", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") @@ -369,3 +470,17 @@ func TestCrossPlatformCoverageNoReplaceRenameFallbackNeverUnlinksClaim(t *testin assertUpgradeSkillContent(t, destination, "payload") assertSourceConsumed(t, source) } + +// forceFastClaimRenameFailure makes the plain rename of source onto the just +// claimed destination fail so the child-move slow path runs, while every +// later rename (the children themselves) executes for real. +func forceFastClaimRenameFailure(t *testing.T, source, destination string) { + t.Helper() + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if src == source && dst == destination { + return os.ErrExist + } + return originalRename(src, dst) + }) +} From f7a3e606f7caee92549ff74e91ce5db322245802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 21:24:46 +0800 Subject: [PATCH 095/267] fix(skills): never prune shell installers' current-run backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four standalone installers pruned the oldest excess stamp directories regardless of origin, so a migration retiring more than five batches destroyed its own rollback material mid-run — the same data loss already fixed for Go via the run-root registry and present in install.js/install.ps1 as currentRunBackupRoots. Every installer now records the stamp directories it creates and pruning only removes earlier-run batches, which is what the changelog already promises. --- scripts/install-devapp.sh | 36 +++-- scripts/install-event.sh | 36 +++-- scripts/install-skills.sh | 33 +++- scripts/install.sh | 33 +++- test/scripts/skill_backup_prune_test.go | 199 ++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 34 deletions(-) create mode 100644 test/scripts/skill_backup_prune_test.go diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 0a21c72e..30cbdeda 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -95,8 +95,8 @@ refresh_cache_tree() { backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } - # Prune before creating this run's archive, so the backup the caller is about - # to depend on for rollback can never be a prune candidate. + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" @@ -110,18 +110,34 @@ backup_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC -# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is -# reported and never fatal. +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -153,13 +169,15 @@ prune_skill_backups() { done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) - prune_seen=0 for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue - prune_seen=$((prune_seen + 1)) - [ "$prune_seen" -le "$prune_drop" ] || break + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) done } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 93a3d392..bca8bb25 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -162,8 +162,8 @@ refresh_cache_tree() { backup_skill_dir() { victim="$1" [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } - # Prune before creating this run's archive, so the backup the caller is about - # to depend on for rollback can never be a prune candidate. + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. prune_skill_backups stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" @@ -179,18 +179,34 @@ backup_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC -# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is -# reported and never fatal. +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -222,13 +238,15 @@ prune_skill_backups() { done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) - prune_seen=0 for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue - prune_seen=$((prune_seen + 1)) - [ "$prune_seen" -le "$prune_drop" ] || break + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) done } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index bd48c7a3..88c58ad4 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -67,6 +67,7 @@ backup_and_remove_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$(dirname "$_bed_target")" mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 @@ -80,13 +81,27 @@ backup_and_remove_skill_dir() { return 1 } -# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. -# Only safe to call once every backup manifest of this run has been consumed, -# because a single transaction can span more than one timestamp. +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. DWS_SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -118,13 +133,15 @@ prune_skill_backups() { done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) - _psb_seen=0 for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue - _psb_seen=$((_psb_seen + 1)) - [ "$_psb_seen" -le "$_psb_drop" ] || break + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + _psb_drop=$((_psb_drop - 1)) done } diff --git a/scripts/install.sh b/scripts/install.sh index 0ca489e4..62d23fbd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -85,6 +85,7 @@ backup_and_remove_skill_dir() { return 1 fi done + record_current_run_backup_stamp "$(dirname "$_bed_target")" mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 @@ -98,13 +99,27 @@ backup_and_remove_skill_dir() { return 1 } -# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup -# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / -# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. -# Only safe to call once every backup manifest of this run has been consumed, -# because a single transaction can span more than one timestamp. +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. DWS_SKILL_BACKUP_KEEP=5 +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved @@ -136,13 +151,15 @@ prune_skill_backups() { done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) - _psb_seen=0 for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue - _psb_seen=$((_psb_seen + 1)) - [ "$_psb_seen" -le "$_psb_drop" ] || break + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + _psb_drop=$((_psb_drop - 1)) done } diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go new file mode 100644 index 00000000..6defc75b --- /dev/null +++ b/test/scripts/skill_backup_prune_test.go @@ -0,0 +1,199 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// shellPruneScriptTargets are the standalone installers that embed their own +// copy of prune_skill_backups. Each must keep every backup stamp the running +// process created, mirroring Go's run-root registry and install.js's +// currentRunBackupRoots, so a migration retiring more than the keep limit +// stays reversible. +var shellPruneScriptTargets = []string{ + "install.sh", + "install-skills.sh", + "install-event.sh", + "install-devapp.sh", +} + +// extractShellFunction pulls a top-level `name() { ... }` definition out of a +// script so the shipped logic — not a copy — is what the test exercises. +func extractShellFunction(t *testing.T, scriptPath, name string) string { + t.Helper() + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", scriptPath, err) + } + text := string(data) + start := strings.Index(text, "\n"+name+"() {") + if start < 0 { + t.Fatalf("function %s not found in %s", name, scriptPath) + } + start++ + end := strings.Index(text[start:], "\n}\n") + if end < 0 { + t.Fatalf("function %s has no top-level closing brace in %s", name, scriptPath) + } + return text[start : start+end+3] +} + +// runShellPruneScenario builds a sandbox HOME with the given pre-existing +// (foreign) stamps, registers the given stamps as created by the current run, +// runs the script's own prune_skill_backups, and returns the surviving stamp +// directories. +func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string) []string { + t.Helper() + home := t.TempDir() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range foreignStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { + t.Fatal(err) + } + } + + var driver strings.Builder + driver.WriteString("#!/bin/sh\n") + driver.WriteString("say() { :; }\n") + // The keep limit is a script-level variable outside the extracted + // functions; set both spellings the four installers use. + driver.WriteString("DWS_SKILL_BACKUP_KEEP=5\n") + driver.WriteString("SKILL_BACKUP_KEEP=5\n") + driver.WriteString(extractShellFunction(t, scriptPath, "is_skill_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "record_current_run_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups")) + driver.WriteString("\n") + for _, stamp := range runStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { + t.Fatal(err) + } + driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n") + } + driver.WriteString("prune_skill_backups\n") + + driverPath := filepath.Join(t.TempDir(), "prune-driver.sh") + if err := os.WriteFile(driverPath, []byte(driver.String()), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", driverPath) + cmd.Env = append(os.Environ(), "HOME="+home) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prune driver failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + if entry.IsDir() { + surviving = append(surviving, entry.Name()) + } + } + return surviving +} + +func assertStampsSurvive(t *testing.T, surviving []string, want ...string) { + t.Helper() + left := make(map[string]bool) + for _, stamp := range surviving { + left[stamp] = true + } + for _, stamp := range want { + if !left[stamp] { + t.Fatalf("stamp %s must survive pruning, surviving=%v", stamp, surviving) + } + delete(left, stamp) + } + for stamp := range left { + t.Fatalf("stamp %s must have been pruned, surviving=%v", stamp, surviving) + } +} + +// TestInstallShellPruneKeepsCurrentRunBackups pins the reversibility contract +// the changelog states for every install surface: pruning may only remove +// batches from earlier runs. The old shell logic deleted the oldest excess +// stamps regardless of origin, so a single migration retiring more than five +// batches silently destroyed its own rollback material. +func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("shell installer tests do not run on %s", runtime.GOOS) + } + for _, name := range shellPruneScriptTargets { + t.Run(name, func(t *testing.T) { + scriptPath := filepath.Join("..", "..", "scripts", name) + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("stat %s: %v", scriptPath, err) + } + + t.Run("run that exceeds the keep limit loses nothing", func(t *testing.T) { + var runStamps []string + for i := 1; i <= 8; i++ { + runStamps = append(runStamps, stampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, nil, runStamps) + assertStampsSurvive(t, surviving, runStamps...) + }) + + t.Run("only earlier-run batches are pruned", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 6; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + runStamps := []string{stampName(1), stampName(2)} + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, runStamps) + // Total 8, keep 5: three oldest foreign batches go, the + // current run keeps both of its own. + assertStampsSurvive(t, surviving, + oldStampName(4), oldStampName(5), oldStampName(6), + stampName(1), stampName(2)) + }) + + t.Run("foreign-only history still prunes to the keep limit", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil) + assertStampsSurvive(t, surviving, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7)) + }) + }) + } +} + +func stampName(i int) string { + return "20260817-0000" + twoDigits(i) +} + +func oldStampName(i int) string { + return "20200101-0000" + twoDigits(i) +} + +func twoDigits(i int) string { + if i < 10 { + return "0" + string(rune('0'+i)) + } + return string(rune('0'+i/10)) + string(rune('0'+i%10)) +} From a0accff258b014d95fff115be465b31a2d92bcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 21:33:35 +0800 Subject: [PATCH 096/267] test(skills): assert claim mode matches source across platforms --- internal/upgrade/skill_publish_rename_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 6b6a8c72..b60104d6 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -372,6 +372,11 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) + sourceInfo, err := os.Lstat(source) + if err != nil { + t.Fatal(err) + } + sourceMode := sourceInfo.Mode().Perm() testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) calls := 0 originalRename := skillPathRename @@ -388,8 +393,8 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { assertUpgradeSkillContent(t, destination, "payload") assertSourceConsumed(t, source) info, err := os.Lstat(destination) - if err != nil || info.Mode().Perm() != 0o755 { - t.Fatalf("published mode = %v, %v; want 0755", info.Mode(), err) + if err != nil || info.Mode().Perm() != sourceMode { + t.Fatalf("published mode = %v, %v; want source mode %v", info.Mode(), err, sourceMode) } }) From bc5e5db7efd6488e05c2ee594784e8c0b451bebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 21:46:42 +0800 Subject: [PATCH 097/267] test(skills): pin publish identity rejection through the identity seam The physical same-content swap relied on the recreated destination getting a fresh inode, but CI runners' ext4/overlayfs recycle inodes eagerly, so the swap was undetectable on Linux and the subtest failed there (while passing on macOS). Swap the same-file identity seam instead so the confirmation's fast-path rejection contract is pinned on every platform. --- internal/upgrade/skill_publication_test.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 93833949..ad443f44 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -182,18 +182,14 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { staged := filepath.Join(root, "staged") destination := filepath.Join(root, "destination") seedUpgradeSkill(t, staged, "value", false) - originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { - if err := originalRename(source, target); err != nil { - return err - } - if err := os.RemoveAll(target); err != nil { - return err - } - // Same content, different object: only the identity check can catch it. - seedUpgradeSkill(t, target, "value", false) - return nil - }) + // A real rename consumed the staged path (fast path), but the object + // at the destination is not the staged one. Whether the swap is + // observable through inode/file-ID comparison depends on the + // filesystem — CI runners' ext4/overlayfs recycle inodes eagerly, so + // reproducing the swap physically is not portable. The confirmation's + // contract under test is its response to a failing identity proof on + // the fast path, which the seam pins down on every platform. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) } From 951dd27f0cbe47ee678b68f2ad00f1203b20aabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 22:10:37 +0800 Subject: [PATCH 098/267] test(skills): fake same file IDs across staged and published paths The constant file-ID stub made both IDs equal, so the Windows proof (expected == actual) accepted the publication and the subtest failed there; Unix stayed green because its proof ignores the ID strings and the swapped os.SameFile seam already forced the failure. Return the first observed ID for both paths so staged and published identities differ on every platform while real IDs still flow through the wrapper. --- internal/upgrade/skill_publication_test.go | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index ad443f44..4554c5d2 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -183,13 +183,24 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { destination := filepath.Join(root, "destination") seedUpgradeSkill(t, staged, "value", false) // A real rename consumed the staged path (fast path), but the object - // at the destination is not the staged one. Whether the swap is - // observable through inode/file-ID comparison depends on the - // filesystem — CI runners' ext4/overlayfs recycle inodes eagerly, so - // reproducing the swap physically is not portable. The confirmation's - // contract under test is its response to a failing identity proof on - // the fast path, which the seam pins down on every platform. + // at the destination must be proven to be the staged one. Whether a + // same-content swap is observable through inode/file-ID comparison + // depends on the filesystem — CI runners' ext4/overlayfs recycle + // inodes eagerly, so reproducing the swap physically is not portable. + // Force the failure through both underlying primitives the + // platform-specific proof consults: os.SameFile (Unix) and the + // stable file ID (Windows), pinning the confirmation's rejection on + // every platform. testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + seen := "" + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + id := originalFileIdentity(path) + if seen == "" { + seen = id + } + return seen + }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) } From 06661af43fe981292aa71fb0e58b700122f5676e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 22:11:24 +0800 Subject: [PATCH 099/267] fix test: published file ID must differ from staged for Windows proof The previous wrapper returned the first observed ID for both paths, so expected == actual still held on Windows and the proof accepted the swap. Return a distinct ID for the second probe. --- internal/upgrade/skill_publication_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 4554c5d2..6adeae17 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -193,13 +193,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { // every platform. testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) originalFileIdentity := skillPathFileIdentity - seen := "" + stagedID := "" testseam.Swap(t, &skillPathFileIdentity, func(path string) string { id := originalFileIdentity(path) - if seen == "" { - seen = id + if stagedID == "" { + stagedID = id + return id } - return seen + // The published probe must report a different object than the + // staged one so the proof fails on both platforms. + return id + ":swapped" }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) From 59268a42a6955b0a4af479bed17e070c86c6eb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Mon, 17 Aug 2026 23:51:17 +0800 Subject: [PATCH 100/267] fix(skills): retract the published link when source removal fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-replace file fallback links the destination and then removes the source. If the removal fails, the caller treats the publish as failed, but no publication record exists to roll the new destination back, and a backup restore would refuse the occupied path. Remove the destination behind an identity check — only the proven linked object may be deleted — and report when the retraction itself fails or the destination was concurrently replaced. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/skill_publish_rename.go | 38 ++++- internal/upgrade/skill_publish_rename_test.go | 147 ++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 999cc0c9..d9d77617 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index 6d2602ea..9cf02b05 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -130,13 +130,47 @@ func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.Fil } // renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the -// destination exists, then removes the source to complete the move. +// destination exists, then removes the source to complete the move. If the +// source removal fails, the link just created is retracted so the failed +// publish leaves no untracked object at the destination (the caller has no +// publication record to roll back, and a backup restore would refuse the +// occupied path). func renameSkillFileNoReplaceFallback(source, destination string) error { + sourceInfo, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + sourceFileID := skillPathFileIdentity(source) if err := skillPathLink(source, destination); err != nil { if os.IsExist(err) { return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} } return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err) } - return skillPathRemove(source) + if err := skillPathRemove(source); err != nil { + return errors.Join( + fmt.Errorf("移动 Skill 文件失败(源 %s 与目标 %s 均尝试保留): %w", source, destination, err), + retractLinkedSkillFile(source, destination, sourceInfo, sourceFileID), + ) + } + return nil +} + +// retractLinkedSkillFile removes the hard link the fallback just created at +// destination. The link shares the source inode, and only that proven +// identity may be removed: if destination no longer resolves to the linked +// object (a concurrent replacement won the path), it is left untouched and +// reported instead. +func retractLinkedSkillFile(source, destination string, sourceInfo os.FileInfo, sourceFileID string) error { + destInfo, err := skillPathLstat(destination) + if err != nil { + return fmt.Errorf("确认待撤回 Skill 文件失败 %s: %w", destination, err) + } + if !skillPathIdentityProven(sourceInfo, destInfo, sourceFileID, skillPathFileIdentity(destination)) { + return fmt.Errorf("待撤回 Skill 文件已被替换,保留目标 %s", destination) + } + if err := skillPathRemove(destination); err != nil { + return fmt.Errorf("撤回已链接 Skill 文件失败 %s(源 %s 与目标 %s 均保留): %w", destination, source, destination, err) + } + return nil } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index b60104d6..6a4f66fb 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -126,6 +126,153 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } }) + t.Run("file source removal failure retracts the linked destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRemove := skillPathRemove + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return originalRemove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "移动 Skill 文件失败") { + t.Fatalf("source removal failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("linked destination must be retracted, stat err=%v", statErr) + } + if content, readErr := os.ReadFile(source); readErr != nil || string(content) != "payload" { + t.Fatalf("source must stay intact, content=%q err=%v", content, readErr) + } + }) + + t.Run("file source removal failure with unremovable destination keeps both", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRemove, func(string) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "撤回已链接 Skill 文件失败") { + t.Fatalf("retract failure must be reported, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must be reported as retained, stat err=%v", statErr) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must stay intact, stat err=%v", statErr) + } + }) + + t.Run("file retract refuses a replaced destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + stagedID := "" + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + id := originalFileIdentity(path) + if stagedID == "" { + stagedID = id + return id + } + return id + ":swapped" + }) + originalRemove := skillPathRemove + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return originalRemove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "待撤回 Skill 文件已被替换") { + t.Fatalf("replaced destination must be refused, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("replaced destination must be retained, stat err=%v", statErr) + } + }) + + t.Run("file fallback source stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + calls := 0 + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls > 1 { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "读取源 Skill 身份失败") { + t.Fatalf("file fallback stat failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("destination must stay unlinked, stat err=%v", statErr) + } + }) + + t.Run("file retract destination stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + linked := false + originalLink := skillPathLink + testseam.Swap(t, &skillPathLink, func(src, dst string) error { + err := originalLink(src, dst) + linked = err == nil + return err + }) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == destination && linked { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + originalRemove := skillPathRemove + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return originalRemove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "确认待撤回 Skill 文件失败") { + t.Fatalf("retract stat failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must be retained when its state is unreadable, stat err=%v", statErr) + } + }) + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") From e8a320a06b6ad891c6822cd06c4eaa0e0d0ae1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 00:24:18 +0800 Subject: [PATCH 101/267] fix(skills): claim npm copy publish destinations atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mono and multi set copy publishers checked destination existence with lstat and then called Node's rename, which replaces the target on every platform (libuv passes MOVEFILE_REPLACE_EXISTING on Windows). A file, symlink, or empty directory created between the check and the rename was silently overwritten, and the identity confirmation could not recover it because the publication record only proved the staged object arrived. Claim the destination with mkdir — which fails with EEXIST if anything occupies the path, so the claim itself is the existence check — and move the staged children into the claim, restoring the source mode on it. A failed child move relocates the children back and removes only the claim. Covered for mono, multi, and simulated Windows, including an injected concurrent creator at the claim instant. --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 68 ++++++++++++-- test/scripts/install_js_smoke.mjs | 125 +++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 15 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index d9d77617..6342df03 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, so a concurrently created file, symlink, or directory is refused instead of being replaced by Node's replacing rename on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index 8ca1ec32..96baad6d 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -856,6 +856,64 @@ function rollbackPublishedSkillPath(publication, options = {}) { fs.rmSync(quarantineRoot, { recursive: true, force: true }); } +// Publish a staged Skill directory with a truly atomic no-replace claim. +// fs.mkdirSync fails with EEXIST when anything occupies the destination, so +// the claim itself is the existence check — there is no check-then-rename +// window a concurrent file, symlink, or empty directory could slip through +// (fs.renameSync replaces the destination outright on every platform, +// including Windows where libuv passes MOVEFILE_REPLACE_EXISTING). Children +// then move into the claimed directory one by one; each child rename targets +// a path inside a directory this transaction owns, so no step can replace a +// foreign object. The publication identity is the claim's: it is captured +// after the claim succeeds and re-verified by the caller once the move +// completes. On a failed child move the relocated children are moved back and +// only the (now empty) claim is removed, so the destination returns to the +// foreign object or to nothing — never to a partial publication. +function publishSkillDirNoReplace(staged, destination, options = {}) { + const mkdirFn = options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })); + const renameFn = options.publishRenameFn || fs.renameSync; + const chmodFn = options.publishChmodFn || fs.chmodSync; + const sourceStat = fs.lstatSync(staged); + try { + mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + const claimStat = fs.lstatSync(destination, { bigint: true }); + const publication = { + destination, + device: claimStat.dev.toString(), + inode: claimStat.ino.toString(), + fingerprint: skillPathFingerprint(staged), + }; + const moved = []; + try { + for (const name of fs.readdirSync(staged)) { + renameFn(path.join(staged, name), path.join(destination, name)); + moved.push(name); + } + chmodFn(destination, sourceStat.mode & 0o7777); + } catch (err) { + for (let i = moved.length - 1; i >= 0; i -= 1) { + try { + renameFn(path.join(destination, moved[i]), path.join(staged, moved[i])); + } catch (_) { + // Best effort: the claim cleanup below reports whatever is left. + } + } + try { + fs.rmdirSync(destination); + } catch (cleanupErr) { + throw new Error(`${err.message}; failed to remove the claimed Skill destination ${destination}: ${cleanupErr.message}`); + } + throw err; + } + return publication; +} + function publishCanonicalLinkNoReplace(staged, destination, options = {}) { const publication = captureSkillPublication(staged, destination); // No-replace is a hard property of this publisher, so it is checked before @@ -999,7 +1057,6 @@ function publishManagedMultiSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); @@ -1050,9 +1107,7 @@ function publishManagedMultiSkillSetAtomically( } for (const item of staged) { - const publication = captureSkillPublication(item.staged, item.dest); - if (skillPathExistsLexically(item.dest)) throw new Error(`Skill publish destination already exists: ${item.dest}`); - renameFn(item.staged, item.dest); + const publication = publishSkillDirNoReplace(item.staged, item.dest, options); if (!skillPublicationMatches(item.dest, publication)) throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); published.push(publication); } @@ -1079,7 +1134,6 @@ function publishManagedMonoSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; const moveOptions = skillMoveOptions(options); @@ -1126,9 +1180,7 @@ function publishManagedMonoSkillSetAtomically( backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } - const publication = captureSkillPublication(stagedDir, destDir); - if (skillPathExistsLexically(destDir)) throw new Error(`Skill publish destination already exists: ${destDir}`); - renameFn(stagedDir, destDir); + const publication = publishSkillDirNoReplace(stagedDir, destDir, options); if (!skillPublicationMatches(destDir, publication)) throw new Error(`published Skill identity changed before confirmation: ${destDir}`); published.push(publication); } catch (err) { diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 79573471..94bc1eef 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -49,6 +49,10 @@ * 17. multi backup failure — restores every earlier successful backup. * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. + * 19. copy publish no-replace — mono/multi set publication refuses a + * concurrently occupied destination (atomic + * claim, no check-then-rename window) on + * POSIX and simulated win32. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools * install.js itself shells out to). Skips cleanly on win32. @@ -901,6 +905,113 @@ scenario("canonical link publication never clobbers or deletes concurrent user d } }); +scenario("mono and multi set publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-copy-race-")); + try { + { + // Real no-replace property for the multi copy publisher: the destination + // is occupied by data this transaction never backed up (it is not a + // victim), so publication must refuse rather than rename over it. The + // claim (mkdir) is the existence check itself — a concurrent creator + // either loses the claim race or is never touched. + const home = path.join(tmp, "multi-home"); + const source = path.join(tmp, "multi-source"); + const base = path.join(home, ".cursor", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + writeFile(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], []), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(path.join(base, "dingtalk-second")), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Same contract for the mono publisher (canonical publish and the + // copy fallback both flow through it). + const home = path.join(tmp, "mono-home"); + const source = path.join(tmp, "mono-source"); + const base = path.join(home, ".agents", "skills"); + writeFile(path.join(source, "SKILL.md"), "new mono\n"); + writeFile(path.join(base, "dws", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMonoSkillSetAtomically(home, source, base, []), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(path.join(base, "dws")), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(base, "dws", "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-mono-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Injected race: a concurrent creator occupies the destination the + // instant before the claim. The old check-then-rename code replaced it; + // the claim must now fail with EEXIST and leave the user object intact. + const home = path.join(tmp, "race-home"); + const source = path.join(tmp, "race-source"); + const base = path.join(home, ".zcode", "skills"); + const secondDest = path.join(base, "dingtalk-second"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], [], { + publishMkdirFn(target) { + if (target === secondDest) { + writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); + } + fs.mkdirSync(target); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(secondDest), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); + assert.equal(fs.readFileSync(path.join(secondDest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + } + + { + // The guarantee holds on the simulated Windows publisher path as well + // (libuv's rename replaces outright there, so the claim matters most). + const home = path.join(tmp, "win32-home"); + const source = path.join(tmp, "win32-source"); + const base = path.join(home, ".qoder", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "must survive\n"); + withSimulatedWin32(() => { + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first"], []), + /Skill publish destination already exists/, + ); + }); + assert.deepEqual(fs.readdirSync(path.join(base, "dingtalk-first")), ["concurrent-user-data.txt"]); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("simulated win32 publishes junctions with absolute canonical targets", () => { // Temp roots are created before entering the simulated platform: os.tmpdir() // would otherwise resolve through Windows environment variables. @@ -1080,7 +1191,10 @@ scenario("multi set publish failure restores the complete previous set", () => { ) { throw crossDeviceError(); } - if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { + originalRename(src, dest); + }, + publishRenameFn(src, dest) { + if (src.includes(".dws-multi-set.tmp-") && src.includes(`${path.sep}dingtalk-second${path.sep}`)) { throw new Error("injected second publish failure"); } originalRename(src, dest); @@ -1171,11 +1285,10 @@ for (const failureKind of ["backup", "publish"]) { ) { throw crossDeviceError(); } - if ( - failureKind === "publish" && - src.includes(".dws-mono-set.tmp-") && - path.basename(src) === "dws" - ) { + originalRename(src, target); + }, + publishRenameFn(src, target) { + if (failureKind === "publish" && src.includes(".dws-mono-set.tmp-")) { throw new Error("injected mono publish failure"); } originalRename(src, target); From e064d394ba028e3622007c567e6e80bac4c4d259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Tue, 18 Aug 2026 01:02:37 +0800 Subject: [PATCH 102/267] docs(skill): optimize dingtalk doc workflows --- skills/multi/dingtalk-doc/SKILL.md | 44 ++++++++++--------- .../dingtalk-doc/references/contracts.md | 10 +++-- skills/multi/dingtalk-doc/references/doc.md | 6 ++- .../dingtalk-doc/references/doc/doc-create.md | 14 +++++- .../dingtalk-doc/references/doc/doc-import.md | 3 +- .../dingtalk-doc/references/doc/doc-media.md | 14 +++++- .../dingtalk-doc/references/doc/doc-read.md | 16 ++++++- .../dingtalk-doc/references/doc/doc-update.md | 24 +++++++++- 8 files changed, 100 insertions(+), 31 deletions(-) diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 534cef40..023dd5e1 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -35,21 +35,22 @@ metadata: ## Golden Route -选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。 +走最小入口:ID/URL 直用;标题先搜索,唯一命中再执行。成本顺序:稳定 ID → shortcut → 局部读 → 精确写;禁以产品 Schema、全文或原子命令起步。 | 用户意图 | 唯一推荐入口 | 关键边界 | |---|---|---| | 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` | -| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 | +| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` | | 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node ` | 仅打开任务所需的 `--include-*`,不要默认全取 | -| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` | -| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation | +| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 | +| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite | | 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` | | 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 | | 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` | -| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` | -| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create | -| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 | +| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | 用户要在线编辑/转换时使用;只存原文件或明确不转换时切 `dingtalk-drive` | +| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog | +| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 | +| 搜索模板 | `dws doc +template-search --query <关键词> [--source MY\|PUBLIC]` | 继承来源;零命中停止,禁止拿无关模板替代;多候选消歧 | | 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search | | 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` | | 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 | @@ -58,18 +59,20 @@ metadata: ## 关键结果语义 -- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。 -- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。 +- 保留真实 `nodeId`、URL、资源类型和容器;同轮创建后直接复用返回 ID,禁止再按标题或钉盘搜索。 +- 先消费完整回执;Runtime 已分片/回读时不重复读取。只有局部验收、`partial_success` 或 commit-unknown 才再 `+fetch`。 +- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。 - 仅在结果明确且关键内容回读匹配后报告写入完成。 -- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。 +- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。 +- `+import` 已含上传、转换和轮询;按终态与 `steps` 判断,不能因有 `taskId` 就说仍异步。截断回读不算验证,改用 `keyword`/局部 scope 核对。 - 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。 ## 参数与安全边界 -- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。 +- `@file`:文件暂存 cwd 后传 `@相对路径`;生成文本优先 `--content -`;禁绝对路径和 `..`。 - `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。 -- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。 -- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。 +- Schema 门禁:路由/参数/安全语义明确时直用;只有 leaf 参数、确认或契约漂移时查一次精确 compact Schema;禁用产品级/`--all`。 +- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测。 - JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。 ## 按需加载 @@ -80,25 +83,24 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余 |---|---| | 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 | | 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) | -| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) | -| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) | +| 长文创作、局部读取或精准改写策略 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) | +| block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) | | 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) | -`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。 +常规 `+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;需策略时仅读上表一项,禁预加载/连读。 ## 错误最短路径 1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。 2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。 3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。 -4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。 -5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。 -6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。 -7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。 +4. `doc_write_commit_unknown`:先回读;禁止自动重试创建或追加。 +5. 认证、权限或 profile 错误:只读 `dingtalk-shared` 对应 reference,不用底层命令绕过。 +6. 导出/媒体失败:保留稳定 ID 后停止;禁止网络请求、安装依赖或本地文档库兜底。 ## 跨产品边界 -- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive` +- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`;文档空间原文件用 `drive upload --workspace`,在线协作转换用 `doc +import --workspace` - 知识库空间、节点层级和成员管理 → `dingtalk-wiki` - 原生 `.md` 文件读取和编辑 → `dingtalk-misc` - `axls` / `able` → 对应电子表格或多维表 Skill diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md index a75ff6d1..c70e1f79 100644 --- a/skills/multi/dingtalk-doc/references/contracts.md +++ b/skills/multi/dingtalk-doc/references/contracts.md @@ -14,9 +14,11 @@ | `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 | | `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 | | `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 | -| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 | +| `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 | -`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。 +`ok=true` 或进程退出码为零不能替代 `status/verified`。`status` 是当前 Doc 业务契约中的状态,不应与框架外层 `outcome` 混为一谈。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。 + +优先级从高到低为:稳定返回 ID/任务 ID → 写后读回匹配 → 明确服务端终态 → 仅 transport/退出码。只有前三类中与该操作匹配的证据足够时才报告成功;Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。 ## 分页与完整性 @@ -27,11 +29,11 @@ - `truncated=true`:因 maxPages/maxItems/timeout 等边界停止; - `failures[]`:已返回部分数据但某一页或后处理失败。 -只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。 +只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。 ## 错误 -结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。 +结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 与已有的 `actions`/details。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。不要发明 `suggestedAction` 或顶层 `nextCommand` 字段。 ## 安全落盘 diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md index 76570e83..9ecdfc67 100644 --- a/skills/multi/dingtalk-doc/references/doc.md +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -26,10 +26,14 @@ dws doc +template-search --query "周报" --source PUBLIC --format json ``` +来源按用户原话守门:“我的模板/我这边”只查 `MY`,明确“公开/钉钉模板库”才查 `PUBLIC`;不得为了凑结果跨来源扩展。未指定来源时保持默认 `MY`。 + - `selection.status=resolved`:取唯一候选的 `templateId`。 -- `selection.status=not_found`:报告零命中后停止。 +- `selection.status=not_found`:报告零命中后停止;不得改用语义不相干的热门模板,更不得擅自创建文档。 - `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。 +若返回 `hasMore=true`,沿原 query/source 使用 cursor 继续搜索;只有服务端返回完整结果后才能判断零命中或完整候选集。 + 选定后只创建一次: ```bash diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md index c5a23e3e..b3c51078 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-create.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md @@ -12,7 +12,7 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml -- - 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。 - `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。 -- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。 +- `--name` 是文档名称;默认不要再在正文开头重复同名 H1,只有用户明确要求正文一级标题时才保留。 - JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。 `+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含: @@ -35,6 +35,18 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml -- - `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。 - 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。 +## 创作与执行策略 + +借鉴 Lark 的 Plan → Execute → Observe → Iterate,但将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加: + +1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。 +2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。 +3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不再复制 Lark 的“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。 +4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。 +5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。 + +交付前检查标题是否重复、段落是否连贯、编号是否统一;只有真实行列数据才使用表格,富组件服务于理解而不是装饰。明确字数要求时应在写入前完成本地统计,不能凭模型估算宣称达标。 + ## 高级通道 只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md index c2bfe44e..ce25ce4a 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-import.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md @@ -8,7 +8,7 @@ dws doc +import --file ./report.docx --folder --format json dws doc +import --file ./notes.md --workspace --name "会议纪要" --format json ``` -`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。 +`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。返回 `taskId` 只是稳定任务标识;若 shortcut 同时返回终态 `success` 及已完成 `steps`,不需要再调用原子状态查询。只有终态缺失、`unknown`、超时或中断才进入恢复流程。 ## 本地文件边界 @@ -16,6 +16,7 @@ dws doc +import --file ./notes.md --workspace --name "会议纪 - `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。 - CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。 - 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。 +- “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace `,不要因容器叫“文档空间”就误报为在线文档。 ## 失败处理 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-media.md b/skills/multi/dingtalk-doc/references/doc/doc-media.md index b87c2e03..54767fb3 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-media.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-media.md @@ -16,10 +16,22 @@ dws doc +media-download --node --resource-id --output ./d dws doc +media-preview --node --resource-id --format json ``` +## 封面与背景 Shortcut + +```bash +dws doc +resource-update --node --file ./cover.png --format json +dws doc +resource-download --node --output ./cover.png --format json +dws doc +resource-delete --node --format json +dws doc +background-update --node --color "#E8F2FE" --format json +dws doc +background-delete --node --format json +``` + +封面不是正文媒体 block;背景仅接受 `#RRGGBB` 纯色。设置/清除后用一次 `+inspect --include-style` 验证,禁止为这些已知能力查询 shortcut Catalog。 + ## 稳定 ID 与结果 - `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。 -- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。 +- 插入后使用回执中的 `insertedBlockId/affectedBlockIds` 定位;`insertedBlockId` 是媒体容器,不是图后空块。只有 `position.followingBlockExists=true` 时才删除真实 `followingBlockId`;为 `false` 时不得删除媒体容器。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。 - 下载输出只接受工作目录内相对路径,默认 no-clobber。 - 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-read.md b/skills/multi/dingtalk-doc/references/doc/doc-read.md index dc8dd710..ed7857fc 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-read.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-read.md @@ -24,10 +24,24 @@ dws doc +fetch --node --scope keyword --keyword "风险|结论" --conte 只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。 +整篇读取使用默认 scope 或 `--scope full`;`full` 不是关键词,禁止写成 `--keyword full`。 + +## 最小读取漏斗 + +按用户已经给出的线索选最短路径,不先拉全文: + +1. 已知稳定 ID/URL,且任务确实涉及整篇:直接默认 `full + simple`,一次返回 Markdown。 +2. 用户给出具体术语、错误码或同义词:直接 `keyword`;`foo|bar` 是 OR,`context-before/after` 是字符数。该模式会缩小 Agent 输出与 token,但当前 Runtime 仍需取得正文后做本地投影,不把它误述为减少上游传输。 +3. 用户指向某章但没有 block ID:先 `outline --detail with-ids`,再用返回的真实标题 ID 执行 `section`。通常两次小结果比反复处理整篇更稳定。 +4. 已知起止 block:直接 `range`;只找表格、图片等结构时用 `tags`。 +5. 只有确需全文总结、全局一致性检查或整篇保真改写时,才读取完整内容。 + +`simple` 用于阅读;`with-ids` 用于定位下一次写操作;`full` 只用于必须保留样式、引用或 JSONML 结构的编辑。局部结果只能证明所选范围,不得据此声称已检查整篇。 + ## 后续路由 - 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。 - 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。 - 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。 -禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。 +禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档;同一次任务里已拿到稳定 `nodeId` 后,后续调用直接复用,避免再次按标题搜索。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-update.md b/skills/multi/dingtalk-doc/references/doc/doc-update.md index 502103c7..42bc6947 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-update.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-update.md @@ -4,6 +4,8 @@ 普通追加、覆盖和 block 编辑统一使用 `+update`: +`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。 + ```bash dws doc +update --node --command append --content "补充说明" --format json dws doc +update --node --command append --content @append.md --format json @@ -38,9 +40,29 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止 `--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。 +## 最小改写决策 + +| 已知条件 | 推荐路径 | 成本与成功率理由 | +|---|---|---| +| 用户明确要求在末尾追加 | 直接 `append` | 不为找末尾先拉全文;需要语气衔接时只读末节 | +| 已知唯一旧文本与新文本 | 直接 `str_replace --old --new` | 省掉 block 解析;旧文本不唯一时 Runtime 必须失败,不放宽匹配 | +| 指定章节但没有 block ID | `+fetch outline` → `+fetch section --detail with-ids` → block 动作 | 两个小读取换取稳定锚点,避免全文 token 和误改相邻章节 | +| 已知真实 block ID | 直接 `block_replace/delete/insert_after` | 最小副作用;不改无关 block | +| 多处富结构保真修改 | `+fetch --detail full` 后定点 JSONML 更新 | 保留图片、附件、引用、表格和样式;不要从 Markdown 有损重建 | +| 整篇重要覆盖 | `+checkpoint-update --mode overwrite` | 自动保存恢复点、执行并回读;普通 overwrite 只用于明确不需恢复点的场景 | + +同一篇文档的正文由一个主上下文串行维护:Plan(确定最小变更)→ Execute(一次写)→ Observe(先消费回执)→ Iterate(只修未达标部分)。不要按章节并行写同一文档,也不要每次迭代都重新读取全文或 Schema。 + +## Block ID 生命周期与保真 + +- `block_replace` / `block_delete` 后,受影响的旧 ID 不再作为后续锚点;需要继续操作该位置时局部 `+fetch` 重新取 ID。 +- `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。 +- `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。 +- 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。 + ## 确认与验证 -- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。 +- `+update` 与 `+checkpoint-update` 当前都要求用户确认。目标、动作、内容范围或参数变化后必须重新确认;只有发现本地文档与 live leaf 漂移时才查一次精确 Schema,禁止每次写入都重复发现。 - `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。 - `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。 - `partial_success` 只恢复未完成步骤,不能重放成功步骤。 From 2b131a103109756c4ded850d70ffad1f6b45fe54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 08:17:46 +0800 Subject: [PATCH 103/267] fix(skills): create canonical links at the destination atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishCanonicalLinkNoReplace checked the destination with lstat and then published, leaving a window the comment claimed did not exist: on Windows renameSync replaces a concurrent object outright (libuv passes MOVEFILE_REPLACE_EXISTING), and on POSIX ln -P source target links INTO a directory that appeared at the target, leaving a stray link inside foreign data that the rollback list never recorded. Create the symlink or junction directly at the destination instead — link creation fails with EEXIST when anything occupies the path and never treats the target as a container, so the publication itself is the atomic no-replace check. Identity confirmation re-reads the live link before the publication enters the rollback list. Covered by injected concurrent creators at the publish instant on POSIX and simulated Windows, asserting the foreign object and its contents stay completely unchanged. --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 66 ++++++++++---------- test/scripts/install_js_smoke.mjs | 86 ++++++++++++++++++-------- 3 files changed, 94 insertions(+), 60 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 6342df03..7e0b4936 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, so a concurrently created file, symlink, or directory is refused instead of being replaced by Node's replacing rename on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index 96baad6d..a361b93d 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -794,16 +794,6 @@ function skillPathFingerprint(target) { return hash.digest("hex"); } -function captureSkillPublication(source, destination) { - const stat = fs.lstatSync(source, { bigint: true }); - return { - destination, - device: stat.dev.toString(), - inode: stat.ino.toString(), - fingerprint: skillPathFingerprint(source), - }; -} - function skillPublicationMatches(target, publication) { const stat = fs.lstatSync(target, { bigint: true }); return stat.dev.toString() === publication.device @@ -914,33 +904,41 @@ function publishSkillDirNoReplace(staged, destination, options = {}) { return publication; } +// Publish a staged canonical link by creating the link directly at the +// destination. Link creation is the atomic no-replace primitive on every +// platform: the kernel refuses with EEXIST when anything — file, symlink, or +// directory — already occupies the path, and unlike a rename (which Windows +// performs with MOVEFILE_REPLACE_EXISTING) or `ln -P source target` (which +// links INTO a directory that appeared at the target) it has no +// check-then-publish window and never treats the destination as a container. +// Staging still proves beforehand that links can be created on this +// filesystem, before any victim moves. The publication identity is captured +// from the destination after creation — no primitive carries the staged +// inode across this publish — and confirmation re-reads the live path, so a +// concurrent replacement surfaces before the publication enters the rollback +// list instead of being recorded as owned. function publishCanonicalLinkNoReplace(staged, destination, options = {}) { - const publication = captureSkillPublication(staged, destination); - // No-replace is a hard property of this publisher, so it is checked before - // either platform's publish call. Windows rename replaces the destination - // outright (libuv passes MOVEFILE_REPLACE_EXISTING) and `ln -P` treats an - // existing directory as a container to link INTO; in both cases the identity - // confirmation below compares against the staged object and could not - // recover the user's data. The sibling copy publishers guard identically. - if (skillPathExistsLexically(destination)) { - throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + const linkTarget = fs.readlinkSync(staged); + const linkType = process.platform === "win32" ? "junction" : "dir"; + const symlinkFn = options.publishSymlinkFn || fs.symlinkSync; + try { + symlinkFn(linkTarget, destination, linkType); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; } - if (process.platform === "win32") { - const renameFn = options.publishRenameFn || fs.renameSync; - renameFn(staged, destination); - } else { - const linkFn = options.publishLinkFn || ((source, target) => { - const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); - if (result.error) throw result.error; - if (result.status !== 0) { - const detail = (result.stderr || result.stdout || "ln -P failed").trim(); - const error = new Error(detail); - if (skillPathExistsLexically(target)) error.code = "EEXIST"; - throw error; - } - }); - linkFn(staged, destination); + const stat = fs.lstatSync(destination, { bigint: true }); + if (!stat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) { + throw new Error(`published Skill identity changed before confirmation: ${destination}`); } + const publication = { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(destination), + }; if (!skillPublicationMatches(destination, publication)) { throw new Error(`published Skill identity changed before confirmation: ${destination}`); } diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 94bc1eef..f427d022 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -156,8 +156,9 @@ function crossDeviceError() { } // withSimulatedWin32 runs fn with process.platform === "win32" so the -// Windows-only publish branches (junction type, absolute link target, rename -// publish + its no-replace guard) get real execution coverage on a unix host. +// Windows-only publish branches (junction type, absolute link target, and +// the atomic junction creation at the destination) get real execution +// coverage on a unix host. // fs.symlinkSync ignores the type argument off Windows, so the same call is // safe here. Create every temp path BEFORE calling: os.tmpdir() follows the // simulated platform. @@ -327,7 +328,7 @@ function runCrossDeviceMoveContract() { } fs.renameSync(source, target); }, - publishLinkFn() { throw new Error("link publish failed"); }, + publishSymlinkFn() { throw new Error("link publish failed"); }, }), /link publish failed/, ); @@ -876,11 +877,10 @@ scenario("canonical link publication never clobbers or deletes concurrent user d ["dingtalk-first", "dingtalk-second"], [first, second], { - publishLinkFn(source, target) { + publishSymlinkFn(target, linkPath, type) { publishCalls += 1; if (publishCalls === 1) { - const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" }); - if (result.status !== 0) throw new Error(result.stderr || "ln -P failed"); + fs.symlinkSync(target, linkPath, type); return; } fs.unlinkSync(first); @@ -900,6 +900,42 @@ scenario("canonical link publication never clobbers or deletes concurrent user d assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine"); assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n"); } + + { + // Injected race: a concurrent creator occupies the destination at the + // instant of publication. Link creation must fail atomically with + // EEXIST, leaving the foreign object and its contents completely + // unchanged — nothing is replaced, and nothing is linked into it (the + // old `ln -P` publish treated an occupied directory as a container and + // left its stray link behind because the publication had not yet + // entered the rollback list). + const home = path.join(tmp, "injected-race-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".codex", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + assert.throws( + () => + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(dest), + ["concurrent-user-data.txt"], + "the foreign object is neither replaced nor linked into", + ); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.lstatSync(dest).isDirectory(), true, "the foreign directory is untouched"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); + } } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -1026,26 +1062,27 @@ scenario("simulated win32 publishes junctions with absolute canonical targets", writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n"); const symlinkCalls = []; - const renameCalls = []; + const publishCalls = []; withSimulatedWin32(() => { publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], { symlinkFn(target, linkPath, type) { symlinkCalls.push({ target, linkPath, type }); fs.symlinkSync(target, linkPath, type); }, - publishRenameFn(source, destination) { - renameCalls.push({ source, destination }); - fs.renameSync(source, destination); + publishSymlinkFn(target, linkPath, type) { + publishCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); }, }); }); assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill"); - assert.equal(symlinkCalls[0].type, "junction", "Windows must publish junctions, not symlinks"); + assert.equal(symlinkCalls[0].type, "junction", "Windows must stage junctions, not symlinks"); assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target"); assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store"); - assert.equal(renameCalls.length, 1, "win32 publishes by rename, not by ln -P"); - assert.equal(renameCalls[0].destination, dest); + assert.equal(publishCalls.length, 1, "win32 publishes by creating the junction directly at the destination"); + assert.equal(publishCalls[0].linkPath, dest); + assert.equal(publishCalls[0].type, "junction"); assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target"); assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); const backupRoot = path.join(home, ".dws", "skill-backups"); @@ -1062,25 +1099,24 @@ scenario("simulated win32 publishes junctions with absolute canonical targets", "the replaced copy stays recoverable", ); - // FIX 5: the win32 rename path must never replace an object that appeared - // at the destination (libuv passes MOVEFILE_REPLACE_EXISTING). + // FIX 5: an object appearing at the destination at publish time must never + // be replaced — junction creation fails atomically with EEXIST (the old + // win32 rename passed MOVEFILE_REPLACE_EXISTING and replaced it). const raceBase = path.join(home, ".qoder", "skills"); const occupied = path.join(raceBase, "dingtalk-chat"); - writeFile(path.join(occupied, "concurrent-user-data.txt"), "must survive\n"); - let renames = 0; withSimulatedWin32(() => { assert.throws( - () => publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { - publishRenameFn(source, destination) { - renames += 1; - fs.renameSync(source, destination); - }, - }), + () => + publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), /Skill publish destination already exists/, ); }); - assert.equal(renames, 0, "win32 publish must not rename over an existing destination"); - assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"]); + assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n"); assert.equal(process.platform !== "win32", true, "the simulated platform is restored"); } finally { From fa83ee579ce03288270ee381f2c6dd1c1427626a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Tue, 18 Aug 2026 08:25:06 +0800 Subject: [PATCH 104/267] docs(skill): remove lark-specific wording --- skills/multi/dingtalk-doc/references/doc/doc-create.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md index b3c51078..3e06ff19 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-create.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md @@ -37,11 +37,11 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml -- ## 创作与执行策略 -借鉴 Lark 的 Plan → Execute → Observe → Iterate,但将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加: +采用 Plan → Execute → Observe → Iterate,将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加: 1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。 2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。 -3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不再复制 Lark 的“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。 +3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不采用“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。 4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。 5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。 From e84743615fe347beb799f4995ec49fe46ae6c116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 09:30:19 +0800 Subject: [PATCH 105/267] fix(skills): retract a child move when the source shell cannot be removed On filesystems without atomic no-replace rename, the degraded publication moves the source children into a fresh claim and leaves an emptied source shell for the caller to remove once the move is confirmed. If that removal failed, moveSkillPathRecoverably reported a plain failure claiming both locations were preserved while the data existed only at the destination, so backupAndRemoveSkillDir never recorded the backup and the original path was left empty. Move the children back into the shell and withdraw the destination instead; a failed retraction reports the data location explicitly. Restores the contract that a failed move keeps the source intact. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/skill_path_move.go | 39 +++++++- .../upgrade/skill_path_move_errors_test.go | 12 +-- internal/upgrade/skill_path_move_test.go | 94 +++++++++++++++++++ 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 7e0b4936..7d5bcd27 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 4183df75..f091bb6f 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -53,10 +53,21 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { // The no-replace fallback may have published by moving the children // into a fresh claim, leaving an emptied source shell behind. Move - // semantics require the source to be gone afterwards. + // semantics require the source to be gone afterwards. If the shell + // cannot be removed, the children are moved back into it and the + // destination is retracted: reporting a plain failure while the data + // sits at dst would leave the caller without a recorded backup and + // the original path empty, breaking the contract that a failed move + // keeps the source intact. if _, shellErr := skillPathLstat(src); shellErr == nil { if removeErr := skillPathRemove(src); removeErr != nil { - return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, removeErr) + if os.IsNotExist(removeErr) { + return nil + } + if retractErr := retractSkillDirChildMove(src, dst); retractErr != nil { + return fmt.Errorf("Skill 移动状态不确定:源空壳 %s 删除失败: %v;撤回目标 %s 也失败: %v;数据位于 %s", src, removeErr, dst, retractErr, dst) + } + return fmt.Errorf("Skill 移动失败,原路径已恢复 %s: %w", src, removeErr) } } else if !os.IsNotExist(shellErr) { return fmt.Errorf("检查已移动 Skill 源路径失败 %s: %w", src, shellErr) @@ -122,6 +133,30 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return nil } +// retractSkillDirChildMove undoes the no-replace fallback child move when the +// emptied source shell cannot be removed afterwards: every child of the +// published destination moves back into the shell, then the destination is +// withdrawn. The renames mirror the forward child move — each targets a path +// inside a directory this transaction owns — so no step replaces a foreign +// object, and a failed retraction surfaces with the data location reported +// instead of being dropped by a plain failure. +func retractSkillDirChildMove(src, dst string) error { + entries, err := skillPathReadDir(dst) + if err != nil { + return fmt.Errorf("读取待撤回 Skill 目标失败 %s: %w", dst, err) + } + for _, entry := range entries { + name := entry.Name() + if err := skillPathRename(filepath.Join(dst, name), filepath.Join(src, name)); err != nil { + return fmt.Errorf("回迁 Skill 目录项失败 %s: %w", name, err) + } + } + if err := skillPathRemove(dst); err != nil { + return fmt.Errorf("撤回已清空 Skill 目标失败 %s: %w", dst, err) + } + return nil +} + type skillPathDirMode struct { path string mode os.FileMode diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 3b42073c..e8d39cd3 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -159,7 +159,7 @@ func TestCrossPlatformCoverageSkillMoveShellCleanup(t *testing.T) { } }) - t.Run("shell removal failure preserves both", func(t *testing.T) { + t.Run("shell removal failure retracts the publication and restores the source", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) forceSlowClaimMove(t, src, dst) originalRemove := skillPathRemove @@ -170,12 +170,12 @@ func TestCrossPlatformCoverageSkillMoveShellCleanup(t *testing.T) { return originalRemove(path) }) err := moveSkillPathRecoverably(src, dst) - if err == nil || !strings.Contains(err.Error(), "源路径删除失败") { - t.Fatalf("shell removal failure must surface, got %v", err) + if err == nil || !strings.Contains(err.Error(), "原路径已恢复") { + t.Fatalf("shell removal failure must surface with the source restored, got %v", err) } - assertUpgradeSkillContent(t, dst, "old") - if _, statErr := os.Lstat(src); statErr != nil { - t.Fatalf("source shell must be reported as preserved, stat err=%v", statErr) + assertUpgradeSkillContent(t, src, "old") + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("retracted destination must be gone, stat err=%v", statErr) } }) diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 6d598590..fb5cd4be 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -449,3 +449,97 @@ func assertNoCrossDeviceStage(t *testing.T, dst string) { } } } + +func TestCrossPlatformCoverageSkillPathMoveShellRetraction(t *testing.T) { + t.Run("shell removal failure retracts the published children", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + t.Fatal(err) + } + } + + // Force the degraded publication: no atomic no-replace rename, and + // the claim fast path refused, so the children move into the claim + // and an emptied source shell is left behind. + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, src, dst) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src { + return os.ErrPermission + } + return os.Remove(path) + }) + + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "原路径已恢复") { + t.Fatalf("shell removal failure must surface with the source restored, got %v", err) + } + if got, readErr := os.ReadFile(filepath.Join(src, "SKILL.md")); readErr != nil || string(got) != "chat\n" { + t.Fatalf("restored file = %q, %v", got, readErr) + } + if got, readErr := os.ReadFile(filepath.Join(src, "references", "guide.md")); readErr != nil || string(got) != "guide\n" { + t.Fatalf("restored nested file = %q, %v", got, readErr) + } + if runtime.GOOS != "windows" { + if got, readErr := os.Readlink(filepath.Join(src, "skill-link")); readErr != nil || got != "SKILL.md" { + t.Fatalf("restored symlink = %q, %v", got, readErr) + } + } + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("retracted destination must be gone, stat err=%v", statErr) + } + }) + + t.Run("retraction failure reports the data location", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src { + return os.ErrPermission + } + return os.Remove(path) + }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, destination string) error { + if source == src && destination == dst { + return os.ErrExist + } + if strings.HasPrefix(source, dst+string(filepath.Separator)) { + return os.ErrPermission + } + return originalRename(source, destination) + }) + + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("failed retraction must report the data location, got %v", err) + } + if got, readErr := os.ReadFile(filepath.Join(dst, "SKILL.md")); readErr != nil || string(got) != "chat\n" { + t.Fatalf("data must remain at the reported destination, got %q, %v", got, readErr) + } + if _, statErr := os.Lstat(src); statErr != nil { + t.Fatalf("source shell must still exist, stat err=%v", statErr) + } + }) +} From 7da423bf3cfbbb5d29de71594ead36711c872c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Tue, 18 Aug 2026 09:37:47 +0800 Subject: [PATCH 106/267] docs(skill): clarify import and recent document routes --- skills/multi/dingtalk-doc/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 023dd5e1..f1af08b0 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-doc -description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。 +description: 钉钉在线文字文档(adoc)的查找、创建、读取、编辑、块、评论、媒体、导入导出、版本、模板、权限与分享;本地文件转在线文档、“传上去后在线改”或协作编辑归 dingtalk-doc。保留原文件及文件夹管理归 dingtalk-drive,知识库节点归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。命令前缀:dws doc。 metadata: cli_version: ">=0.2.14" category: product @@ -40,6 +40,7 @@ metadata: | 用户意图 | 唯一推荐入口 | 关键边界 | |---|---|---| | 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` | +| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` | | 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` | | 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node ` | 仅打开任务所需的 `--include-*`,不要默认全取 | | 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 | @@ -47,7 +48,7 @@ metadata: | 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` | | 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 | | 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` | -| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | 用户要在线编辑/转换时使用;只存原文件或明确不转换时切 `dingtalk-drive` | +| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` | | 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog | | 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 | | 搜索模板 | `dws doc +template-search --query <关键词> [--source MY\|PUBLIC]` | 继承来源;零命中停止,禁止拿无关模板替代;多候选消歧 | From fcbddb090449dc9138ea590fb9fdf5fc7fba204b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 10:20:15 +0800 Subject: [PATCH 107/267] fix(skills): create shell-published links at the destination atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POSIX shell installers staged shared Skill links and published them with mv after an existence check; a file or symlink another process created at the destination between the check and the move was silently replaced, and the inode confirmation could not detect the loss. Publish by creating each link directly at its destination instead — symlink(2) refuses an occupied path with EEXIST, so the creation itself is the atomic no-replace check. A directory that appears at the destination turns ln -s into a container; the nested link is removed after an identity check and the transaction rolls back, leaving the foreign directory untouched. Applied to install.sh, install-skills.sh, install-event.sh, and install-devapp.sh. Also covers the remaining retraction branches of the Go shell-removal fallback so changed-code coverage is complete. Regression tests inject a concurrent occupant at the publish instant for regular-file and directory cases and assert the foreign object and its contents stay completely unchanged. --- .changes/996-canonical-agent-skills.md | 2 +- .../upgrade/skill_path_move_errors_test.go | 16 ++ internal/upgrade/skill_path_move_test.go | 43 +++++ scripts/install-devapp.sh | 22 ++- scripts/install-event.sh | 22 ++- scripts/install-skills.sh | 38 ++-- scripts/install.sh | 44 +++-- test/scripts/install_script_test.go | 168 +++++++++++++++++- 8 files changed, 306 insertions(+), 49 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 7d5bcd27..271e9896 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index e8d39cd3..4c1d4af5 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -159,6 +159,22 @@ func TestCrossPlatformCoverageSkillMoveShellCleanup(t *testing.T) { } }) + t.Run("shell vanished concurrently after publication completes the move", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceSlowClaimMove(t, src, dst) + originalRemove := skillPathRemove + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src { + return &os.PathError{Op: "remove", Path: path, Err: os.ErrNotExist} + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatalf("concurrent shell removal must complete the move, got %v", err) + } + assertUpgradeSkillContent(t, dst, "old") + }) + t.Run("shell removal failure retracts the publication and restores the source", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) forceSlowClaimMove(t, src, dst) diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index fb5cd4be..98885c03 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -542,4 +542,47 @@ func TestCrossPlatformCoverageSkillPathMoveShellRetraction(t *testing.T) { t.Fatalf("source shell must still exist, stat err=%v", statErr) } }) + + t.Run("retraction read failure reports the data location", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, src, dst) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src { + return os.ErrPermission + } + return os.Remove(path) + }) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(dir string) ([]os.DirEntry, error) { + if dir == dst { + return nil, os.ErrPermission + } + return originalReadDir(dir) + }) + + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("unreadable retraction target must be reported with the data location, got %v", err) + } + assertUpgradeSkillContent(t, dst, "old") + }) + + t.Run("retraction withdraw failure reports the data location", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, src, dst) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == src || path == dst { + return os.ErrPermission + } + return os.Remove(path) + }) + + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("withdraw failure must be reported with the data location, got %v", err) + } + assertUpgradeSkillContent(t, src, "old") + }) } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 30cbdeda..6cef00fa 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -244,10 +244,26 @@ link_or_copy_skill() { return 1 fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + # Create the link directly at the destination: symlink(2) refuses an + # occupied path with EEXIST, so publication itself is the atomic no-replace + # check (`mv` after the backup could still replace a file or symlink a + # concurrent writer created in between). A directory that slipped in turns + # `ln -s` into a container: remove exactly our nested link and roll back. + if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then + rm -rf "$stage" + return 0 + fi + nested="$dest/${relative##*/}" + if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then + rm -f "$nested" || true + fi rm -rf "$stage" - if [ -n "$backup" ] && ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + if [ -n "$backup" ]; then + if [ -e "$dest" ] || [ -L "$dest" ]; then + printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 + elif ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi fi return 1 } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index bca8bb25..eeaf2c37 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -313,10 +313,26 @@ link_or_copy_skill() { return 1 fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi + # Create the link directly at the destination: symlink(2) refuses an + # occupied path with EEXIST, so publication itself is the atomic no-replace + # check (`mv` after the backup could still replace a file or symlink a + # concurrent writer created in between). A directory that slipped in turns + # `ln -s` into a container: remove exactly our nested link and roll back. + if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then + rm -rf "$stage" + return 0 + fi + nested="$dest/${relative##*/}" + if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then + rm -f "$nested" || true + fi rm -rf "$stage" - if [ -n "$backup" ] && ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + if [ -n "$backup" ]; then + if [ -e "$dest" ] || [ -L "$dest" ]; then + printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 + elif ! mv "$backup" "$dest"; then + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi fi return 1 } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 88c58ad4..d3b25e0a 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -720,25 +720,29 @@ link_canonical_skills_to_base() { _lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } - # The staged symlink keeps its inode across the rename below (the staging - # directory is a sibling inside the same Agent root), so the identity - # recorded in the publication manifest stays exact. - _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 - } - # Anything still present here appeared after the pre-flight scan, i.e. a - # concurrent writer. Never let `mv` clobber it. - if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + # Publish by creating the link directly at the destination: symlink(2) + # refuses an occupied path with EEXIST, so the creation itself is the + # atomic no-replace check. `mv` after any pre-check could still replace a + # file or symlink a concurrent writer created in between, and `ln -P` + # stays unusable (absent from BusyBox `ln`, which silently degraded every + # non-universal Agent to the copy layout on Alpine and most containers). + if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi - # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is - # absent from BusyBox `ln`, which silently degraded every non-universal - # Agent to the copy layout on Alpine and most containers). install-event.sh - # and install-devapp.sh already publish this way. The staged basename stays - # unique, so a destination a concurrent process turned into a directory can - # only receive our link and post-validation removes exactly that. - if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true + # A directory that appeared at the destination turns `ln -s` into a + # container: the link lands inside it under the target basename. Remove + # exactly our link after an identity check and roll back; the foreign + # directory stays untouched. + if [ ! -L "$_lcs_dest" ]; then + _lcs_nested_name="${_lcs_link_target##*/}" + _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode="" + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then diff --git a/scripts/install.sh b/scripts/install.sh index 62d23fbd..4052924d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -961,29 +961,35 @@ link_canonical_skills_to_base() { rm -rf "$_lcs_stage" return 1 } - # The staged symlink keeps its inode across the rename below (the staging - # directory is a sibling inside the same Agent root), so the identity - # recorded in the publication manifest stays exact. - _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { - restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true - rm -rf "$_lcs_stage" - return 1 - } - # Anything still present here appeared after the pre-flight scan, i.e. a - # concurrent writer. Never let `mv` clobber it. - if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then + # Publish by creating the link directly at the destination: symlink(2) + # refuses an occupied path with EEXIST, so the creation itself is the + # atomic no-replace check. `mv` after any pre-check could still replace a + # file or symlink a concurrent writer created in between, and `ln -P` + # stays unusable (absent from BusyBox `ln`, which silently degraded every + # non-universal Agent to the copy layout on Alpine and most containers). + if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 fi - # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is - # absent from BusyBox `ln`, which silently degraded every non-universal - # Agent to the copy layout on Alpine and most containers). install-event.sh - # and install-devapp.sh already publish this way. The staged basename stays - # unique, so a destination a concurrent process turned into a directory can - # only receive our link and post-validation removes exactly that. - if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then - cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true + # A directory that appeared at the destination turns `ln -s` into a + # container: the link lands inside it under the target basename. Remove + # exactly our link after an identity check and roll back; the foreign + # directory stays untouched. + if [ ! -L "$_lcs_dest" ]; then + _lcs_nested_name="${_lcs_link_target##*/}" + _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode="" + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true rm -rf "$_lcs_stage" return 1 diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 483840aa..4618384e 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -575,6 +575,160 @@ exit 0 } } +func TestInstallScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.sh main section not found") + } + harness := string(data[:cut]) + ` +ROOT="$DWS_TEST_HOME/root" +BASE="$ROOT/agent/skills" +CANONICAL="$ROOT/.agents/skills" +BUNDLE="$DWS_TEST_HOME/bundle" +mkdir -p "$CANONICAL/dingtalk-chat" "$BASE" "$BUNDLE/dingtalk-chat" || exit 9 +printf 'canonical\n' > "$CANONICAL/dingtalk-chat/SKILL.md" || exit 9 +printf 'bundled\n' > "$BUNDLE/dingtalk-chat/SKILL.md" || exit 9 + +race_case="$1" +dest="$BASE/dingtalk-chat" + +# Inject the concurrent writer at the exact instant before publication: the +# publish loop reads every staged link first, so the override occupies the +# destination right before the real creation attempt. The override runs in a +# command-substitution subshell, so only its on-disk effects are asserted. +readlink() { + case "$1" in + *.dws-link-set.*dingtalk-chat) + case "$race_case" in + file) printf 'must survive\n' > "$dest" ;; + dir) + mkdir -p "$dest" || exit 9 + printf 'must survive\n' > "$dest/concurrent-user-data.txt" || exit 9 + ;; + esac + ;; + esac + command readlink "$1" +} + +if link_canonical_skills_to_base "$ROOT" "$BASE" multi "$BUNDLE"; then + printf 'UNEXPECTED-SUCCESS\n' + exit 1 +fi +case "$race_case" in + file) + [ -f "$dest" ] || { printf 'MISSING-FILE\n'; exit 3; } + [ "$(cat "$dest")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; } + ;; + dir) + [ -d "$dest" ] || { printf 'MISSING-DIR\n'; exit 3; } + [ "$(cat "$dest/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; } + if [ -n "$(ls -A "$dest" | grep -v '^concurrent-user-data\.txt$')" ]; then + printf 'NESTED-LEFTOVER\n' + exit 5 + fi + ;; +esac +case "$(ls -A "$BASE")" in + *".dws-link-set."*) printf 'STAGE-LEAK\n'; exit 6 ;; +esac +exit 0 +` + for _, raceCase := range []string{"file", "dir"} { + harnessPath := filepath.Join(t.TempDir(), "link-publication-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath, raceCase) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("install.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output)) + } + } +} + +func TestInstallEventScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-event.sh main call not found") + } + harness := string(data[:cut]) + ` +ROOT="$DWS_TEST_HOME" +CANONICAL="$ROOT/.agents/skills/dingtalk-chat" +SRC="$ROOT/src/dingtalk-chat" +DEST="$ROOT/agent/skills/dingtalk-chat" +mkdir -p "$CANONICAL" "$SRC" || exit 9 +printf 'canonical\n' > "$CANONICAL/SKILL.md" || exit 9 +printf 'src\n' > "$SRC/SKILL.md" || exit 9 + +# Inject the concurrent writer at the exact instant before publication: +# backup_skill_dir is the last call before the link is created at DEST. The +# override runs in a command-substitution subshell, so only its on-disk +# effects are asserted. +backup_skill_dir() { + case "$RACE_CASE" in + file) printf 'must survive\n' > "$DEST" || exit 9 ;; + dir) + mkdir -p "$DEST" || exit 9 + printf 'must survive\n' > "$DEST/concurrent-user-data.txt" || exit 9 + ;; + esac + printf '\n' +} + +if link_or_copy_skill "$CANONICAL" "$SRC" "$DEST"; then + printf 'UNEXPECTED-SUCCESS\n' + exit 1 +fi +case "$RACE_CASE" in + file) + [ -f "$DEST" ] || { printf 'MISSING-FILE\n'; exit 3; } + [ "$(cat "$DEST")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; } + ;; + dir) + [ -d "$DEST" ] || { printf 'MISSING-DIR\n'; exit 3; } + [ "$(cat "$DEST/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; } + if [ -n "$(ls -A "$DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then + printf 'NESTED-LEFTOVER\n' + exit 5 + fi + ;; +esac +exit 0 +` + for _, raceCase := range []string{"file", "dir"} { + harnessPath := filepath.Join(t.TempDir(), "event-link-publication-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir(), "EVENT_VERSION=1.0.0-test", "RACE_CASE="+raceCase) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("install-event.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output)) + } + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1866,18 +2020,20 @@ func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *test mustWriteFile(t, filepath.Join(bundle, "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) mustWriteFile(t, filepath.Join(bundle, "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) - // Publication now moves a staged symlink instead of using `ln -P` - // (BusyBox has no -P), and multi mode requires the bundle source so - // that only bundle skills are linked. Inject the race on that move. + // Publication creates the link directly at the destination with + // `ln -s` (symlink(2) refuses an occupied path atomically; BusyBox + // has no `ln -P`), and multi mode requires the bundle source so + // that only bundle skills are linked. Inject the race on that + // creation. harness := `. "$DWS_TEST_LIBRARY" -mv() { - if [ "$2" = "$DWS_TEST_SECOND" ]; then +ln() { + if [ "$#" -eq 3 ] && [ "$1" = "-s" ] && [ "$3" = "$DWS_TEST_SECOND" ]; then rm -f "$DWS_TEST_FIRST" mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" fi - command mv "$@" + command ln "$@" } if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then exit 2 From 5b9234d8fed7d280216e17bce13e5d45ac3d9af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Tue, 18 Aug 2026 11:16:04 +0800 Subject: [PATCH 108/267] fix(recruit): align job creation contract --- .../app/schema_agent_examples_dry_run_test.go | 2 +- internal/helpers/recruit.go | 173 +++++++++++++++++- internal/helpers/recruit_test.go | 106 ++++++++++- skills/mono/references/products/recruit.md | 15 +- .../multi/dingtalk-misc/references/recruit.md | 42 ++++- 5 files changed, 311 insertions(+), 27 deletions(-) diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index 5497810d..6458979e 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -291,7 +291,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"), jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`), batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), - job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL_TIME","requiredEdu":1,"minSalary":20000,"maxSalary":35000}`), + job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`), binary: []byte("%PDF-1.4\n%%EOF\n"), image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, } { diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index a64c7de4..8152d7a7 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -379,11 +379,11 @@ func newRecruitJobCreateCommand() *cobra.Command { Interface: recruitMCPInterface(recruitCreateJobTool), Selection: contract.SelectionSpec{ AgentSummary: "使用结构化 JSON 创建招聘职位", - UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、minSalary、maxSalary"}, + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData;jobNature 固定为 FULL-TIME;创建人和负责人分别使用真实 creatorUserId 与 ownerUserIds"}, AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, }, - Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 读取后作为 atsAddJobParam 对象发送"}}, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为创建人 userId,ownerUserIds 为负责人 userId 字符串数组"}}, }, }) } @@ -479,7 +479,7 @@ func loadRecruitJobFile(path string) (any, error) { } func validateRecruitJob(job map[string]any) error { - for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "minSalary", "maxSalary"} { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData"} { value, ok := job[name] if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) @@ -490,13 +490,170 @@ func validateRecruitJob(job map[string]any) error { return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) } } - for _, name := range []string{"requiredEdu", "minSalary", "maxSalary"} { - if _, ok := job[name].(float64); !ok { - return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + if job["jobNature"].(string) != "FULL-TIME" { + return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME") + } + requiredEdu, ok := job["requiredEdu"].(float64) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字") + } + if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数") + } + + minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary") + if err != nil { + return err + } + maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary") + if err != nil { + return err + } + if hasMinSalary && hasMaxSalary && minSalary > maxSalary { + return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + } + + for _, name := range []string{"province", "city", "district", "category"} { + if value, exists := job[name]; exists && value != nil && !isString(value) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) } } - if job["minSalary"].(float64) > job["maxSalary"].(float64) { - return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + if value, exists := job["campus"]; exists && value != nil { + if _, ok := value.(bool); !ok { + return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值") + } + } + if err := validateRecruitIdentityFields(job); err != nil { + return err + } + if err := validateRecruitAddress(job); err != nil { + return err + } + extData, ok := job["extData"].(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象") + } + if err := validateRecruitExtData(extData); err != nil { + return err + } + return nil +} + +func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) { + value, exists := values[name] + if !exists || value == nil { + return 0, false, nil + } + number, ok := value.(float64) + if !ok { + return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + } + return number, true, nil +} + +func validateRecruitIdentityFields(job map[string]any) error { + if value, exists := job["creatorUserId"]; exists && value != nil { + creatorUserID, ok := value.(string) + if !ok || strings.TrimSpace(creatorUserID) == "" { + return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串") + } + } + if value, exists := job["ownerUserIds"]; exists && value != nil { + ownerUserIDs, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组") + } + for _, owner := range ownerUserIDs { + ownerUserID, ok := owner.(string) + if !ok || strings.TrimSpace(ownerUserID) == "" { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串") + } + } + } + return nil +} + +func validateRecruitAddress(job map[string]any) error { + value, exists := job["address"] + if !exists || value == nil { + return nil + } + address, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 address 必须是对象") + } + for _, name := range []string{"name", "detail", "longitude", "latitude"} { + field, exists := address[name] + text, ok := field.(string) + if !exists || !ok || strings.TrimSpace(text) == "" { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name)) + } + } + return nil +} + +func validateRecruitExtData(extData map[string]any) error { + if value, exists := extData["headCount"]; exists && value != nil { + headCount, ok := value.(float64) + if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) { + return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数") + } + } + if value, exists := extData["source"]; exists && value != nil && !isString(value) { + return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串") + } + if value, exists := extData["fullTimeExtData"]; exists && value != nil { + fullTime, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象") + } + if err := validateRecruitFullTimeExtData(fullTime); err != nil { + return err + } + } + if value, exists := extData["tags"]; exists && value != nil { + tags, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组") + } + for _, value := range tags { + tag, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象") + } + name, ok := tag["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串") + } + } + } + return nil +} + +func validateRecruitFullTimeExtData(fullTime map[string]any) error { + salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth") + if err != nil { + return err + } + if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数") + } + minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience") + if err != nil { + return err + } + maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience") + if err != nil { + return err + } + if hasMinExperience && minExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0") + } + if hasMaxExperience && maxExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0") + } + if hasMinExperience && hasMaxExperience && minExperience > maxExperience { + return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience") } return nil } diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 0222d08c..e7632c28 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -207,8 +207,13 @@ func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { t.Helper() path := filepath.Join(t.TempDir(), "job.json") job := map[string]any{ - "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", - "requiredEdu": 1, "minSalary": 20000, "maxSalary": 35000, + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": 6, "minSalary": 20000, "maxSalary": 35000, + "creatorUserId": "creator-user-id", "ownerUserIds": []string{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": 1, + "fullTimeExtData": map[string]any{"salaryMonth": 12, "minJobExperience": 1, "maxJobExperience": 3}, + }, } data, err := json.Marshal(job) if err != nil { @@ -254,8 +259,13 @@ func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool) } wantArgs := map[string]any{"atsAddJobParam": map[string]any{ - "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", - "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, }} if !reflect.DeepEqual(call.args, wantArgs) { t.Fatalf("args = %#v, want %#v", call.args, wantArgs) @@ -307,8 +317,13 @@ func TestRecruitValidationBranches(t *testing.T) { } base := map[string]any{ - "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", - "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, } for _, test := range []struct { name string @@ -317,7 +332,61 @@ func TestRecruitValidationBranches(t *testing.T) { }{ {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, + {name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"}, + {name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"}, + {name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"}, + {name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"}, {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, + {name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"}, + {name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"}, + {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "creatorUserId 必须是非空字符串"}, + {name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"}, + {name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"}, + {name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"}, + {name: "region type", mutate: func(job map[string]any) { job["city"] = 330100 }, want: "city 必须是字符串"}, + {name: "address type", mutate: func(job map[string]any) { job["address"] = "杭州" }, want: "address 必须是对象"}, + {name: "address required field", mutate: func(job map[string]any) { + job["address"] = map[string]any{"name": "亲橙Park", "detail": "杭州市余杭区", "longitude": "120.0"} + }, want: "address.latitude 必须是非空字符串"}, + {name: "head count range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"headCount": float64(0)} + }, want: "headCount 必须是 1 到 999 的整数"}, + {name: "source type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"source": true} + }, want: "extData.source 必须是字符串"}, + {name: "full time ext data type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": "invalid"} + }, want: "fullTimeExtData 必须是对象"}, + {name: "salary month range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": float64(25)}} + }, want: "salaryMonth 必须是 12 到 24 的整数"}, + {name: "salary month type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": "12"}} + }, want: "salaryMonth 必须是数字"}, + {name: "min experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": "1"}} + }, want: "minJobExperience 必须是数字"}, + {name: "max experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": "3"}} + }, want: "maxJobExperience 必须是数字"}, + {name: "negative min experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(-1)}} + }, want: "minJobExperience 不能小于 0"}, + {name: "negative max experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": float64(-1)}} + }, want: "maxJobExperience 不能小于 0"}, + {name: "experience range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(5), "maxJobExperience": float64(3)}} + }, want: "minJobExperience 不能大于 maxJobExperience"}, + {name: "tags type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": "五险一金"} + }, want: "extData.tags 必须是数组"}, + {name: "tag item type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{"五险一金"}} + }, want: "extData.tags 只能包含对象"}, + {name: "tag name", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{map[string]any{"name": ""}}} + }, want: "tags[].name 必须是非空字符串"}, } { t.Run(test.name, func(t *testing.T) { job := make(map[string]any, len(base)) @@ -330,6 +399,31 @@ func TestRecruitValidationBranches(t *testing.T) { } }) } + + withoutSalary := make(map[string]any, len(base)) + for key, value := range base { + withoutSalary[key] = value + } + delete(withoutSalary, "minSalary") + delete(withoutSalary, "maxSalary") + if err := validateRecruitJob(withoutSalary); err != nil { + t.Fatalf("optional salary validation error = %v, want nil", err) + } + + completeAddress := make(map[string]any, len(base)+1) + for key, value := range base { + completeAddress[key] = value + } + completeAddress["address"] = map[string]any{ + "name": "亲橙Park", "detail": "浙江省杭州市余杭区", "longitude": "120.008822", "latitude": "30.270739", + } + completeAddress["campus"] = false + completeAddress["province"] = "330000" + completeAddress["city"] = "330100" + completeAddress["district"] = "330110" + if err := validateRecruitJob(completeAddress); err != nil { + t.Fatalf("complete job validation error = %v, want nil", err) + } } func TestRecruitListResultData(t *testing.T) { diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md index 6c2b48f8..2e035201 100644 --- a/skills/mono/references/products/recruit.md +++ b/skills/mono/references/products/recruit.md @@ -24,6 +24,15 @@ dws recruit job create --from ./job.json --dry-run --format json dws recruit job create --from ./job.json --format json ``` -JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、 -`minSalary`、`maxSalary`。后三者为数字,且最低薪资不得大于最高薪资。文件只写 -职位对象,不要添加 MCP 信封字段 `atsAddJobParam`。 +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`。 +`jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、 +5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时 +提供时最低薪资不得高于最高薪资。 + +职位对象应显式包含真实的 `creatorUserId` 和 `ownerUserIds`;前者是当前创建人 +userId,后者是负责人 userId 的 JSON 字符串数组。所有 userId 必须来自同一 profile +下的真实通讯录结果,禁止猜测。`extData.headCount` 范围 1–999,薪资月份范围 +12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。 + +文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode` +或 `opUserId`;这些字段由 CLI 或 Connector 处理。 diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md index d6209be9..36827e56 100644 --- a/skills/multi/dingtalk-misc/references/recruit.md +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -37,8 +37,14 @@ dws recruit job get --job-id JOB_ID --format json ## 创建职位 创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 -`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资和负责人,取得明确 -确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资、创建人和负责人,取得 +明确确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 + +创建人和负责人必须来自同一 profile 下的真实通讯录结果,不得猜测或复用其他组织的 +userId。`creatorUserId` 使用当前操作者的 userId;`ownerUserIds` 是负责人 userId 的 +JSON 字符串数组。用户说“负责人是我”时先运行 +`dws contact user get-self --format json` 取得当前 userId;用户指定其他负责人时按 +通讯录 Skill 解析唯一 userId。未指定负责人时先向用户确认,不要静默省略或默认选人。 ```bash dws recruit job create --from ./job.json --dry-run --format json @@ -51,14 +57,32 @@ dws recruit job create --from ./job.json --format json { "name": "Java 开发工程师", "description": "负责服务端系统开发", - "jobNature": "FULL_TIME", - "requiredEdu": 1, + "jobNature": "FULL-TIME", + "requiredEdu": 6, "minSalary": 20000, - "maxSalary": 35000 + "maxSalary": 35000, + "extData": { + "headCount": 1, + "fullTimeExtData": { + "salaryMonth": 12 + } + }, + "creatorUserId": "CURRENT_USER_ID", + "ownerUserIds": ["OWNER_USER_ID"] } ``` -六个字段均为必填;`requiredEdu`、`minSalary`、`maxSalary` 必须为数字,且 -`minSalary` 不得大于 `maxSalary`。可选字段以当前 -`dws recruit job create --help` 和 leaf Schema 为准;不要把 MCP 信封字段 -`atsAddJobParam` 写进文件,CLI 会自动包装。 +必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`。 +`jobNature` 当前固定为 `FULL-TIME`;`requiredEdu` 为 1–9 的整数(1小学、2初中、 +3高中、4中专、5大专、6本科、7硕士、8博士、9其他)。`minSalary` 与 +`maxSalary` 可选;两者同时提供时最低薪资不得高于最高薪资。 + +`extData.headCount` 范围 1–999;`extData.fullTimeExtData.salaryMonth` 范围 +12–24;最高工作年限不得小于最低工作年限。提供 `address` 时必须同时包含 +`name`、`detail`、`longitude`、`latitude`,经纬度只能来自地图选点或可信地址解析, +不得猜测。`source` 可省略,由 Connector 默认补充为 `manual`;不传 `category` 时 +Connector 会设置 `checkJobCategory=false`。 + +可选字段以当前 `dws recruit job create --help` 和 leaf Schema 为准。不要把 MCP +信封字段 `atsAddJobParam`、`corpId`、`bizCode` 或 `opUserId` 写进文件;CLI 与 +Connector 会负责包装和身份注入。 From 7fa4ee7bac38da2d500eced6a9f9c89326aa02d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 11:26:37 +0800 Subject: [PATCH 109/267] docs(skills): describe the actual degraded no-replace publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC section on filesystems that reject the atomic no-replace rename still described the retired existence-check-plus-plain-rename fallback and its accepted race window. The implementation (and the npm and shell surfaces) claim the destination with mkdir or a hard link — or create the link directly at the destination — and never release the claim mid transaction, so a concurrently created object is refused rather than overwritten. Record that contract and its only relaxed property (child moves are not all-or-nothing visible) so future maintainers do not port the racy description back into code. --- docs/rfc-skill-installation-and-upgrade.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a2e90013..f1aea95c 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -197,9 +197,14 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件 系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与 overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级 -为“显式存在性检查 + 普通 rename”:不覆盖既有目标的契约保持不变,仅失去原子性, -其检查与 rename 之间的竞态窗口与普通 rename 本身一致,并由发布后的身份与内容校验 -兜底。Windows `MoveFile` 本身即拒绝已存在的目标,无需降级。 +为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务 +持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入); +普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会 +回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会 +被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受 +边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标, +无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在 +目标路径创建(symlink(2) 原子拒绝已占用路径)。 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 From 5f2344d16d6715c168770125dba846975be714f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 13:00:03 +0800 Subject: [PATCH 110/267] fix(skills): claim shell copy publications atomically and verify rollback identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell mono/multi set publishers staged each Skill directory and published it with a plain mv after the backup; anything another process created at the destination between the backup and the move was silently replaced, and restore_multi_skill_set then blind-deleted manifest paths, so a concurrently replaced object could also be destroyed during rollback. Publish through an atomic mkdir claim instead — EEXIST refuses any occupant, staged children move into the claim one by one, and a failed child move relocates them and removes only the claim. The published manifest now records :, and rollback deletes a destination only when its inode still matches the publication, skipping concurrently replaced paths with a warning. Also fixes a latent unbound-variable expansion where a shell variable was followed directly by a full-width parenthesis in a message. Regression tests publish a first Skill, replace it with a foreign directory, fail the second publication, and assert rollback retains the foreign object untouched while restoring the rest from backups. --- .changes/996-canonical-agent-skills.md | 2 +- scripts/install-skills.sh | 71 ++++++++++++++++----- scripts/install.sh | 82 ++++++++++++++++++------ test/scripts/install_script_test.go | 87 +++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 37 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 271e9896..61babdf0 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — and publish copied mono/multi sets through an atomic mkdir claim with per-child moves, while rollback verifies the recorded destination inode before removing anything, so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move or deleted by a path-blind rollback. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index d3b25e0a..2c5abb21 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -286,9 +286,17 @@ restore_multi_skill_set() { _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; } + if [ "$(skill_link_inode "$_rms_dest")" = "$_rms_inode" ]; then + rm -rf "$_rms_dest" || _rms_ok=0 + else + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rms_dest" + _rms_ok=0 + fi done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then @@ -347,6 +355,49 @@ cleanup_nested_staged_link() { fi } +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever deletes this transaction's +# object. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_link_inode "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir @@ -875,12 +926,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_ms_stage" @@ -934,12 +980,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_mono_stage" diff --git a/scripts/install.sh b/scripts/install.sh index 4052924d..a6d611d9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -290,7 +290,7 @@ backup_and_record_skill_dir() { backup_and_remove_skill_dir "$_bars_victim" || return 1 if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then - mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)" + mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: ${_bars_victim}(备份位于 ${DWS_LAST_SKILL_BACKUP})" return 1 fi fi @@ -298,23 +298,34 @@ backup_and_record_skill_dir() { # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported -# installer path contract; spaces are preserved. +# its exact backup path. Publication manifest entries are : +# pairs recorded at publish time; a destination whose inode no longer matches +# was concurrently replaced after publication and is preserved untouched — +# never blind-deleted by path. Paths containing newlines are outside the +# supported installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; } + if [ "$(skill_link_inode "$_rms_dest")" = "$_rms_inode" ]; then + rm -rf "$_rms_dest" || _rms_ok=0 + else + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rms_dest" + _rms_ok=0 + fi done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then - say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)" + say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})" _rms_ok=0 fi done < "$_rms_backups" @@ -366,6 +377,49 @@ cleanup_nested_staged_link() { fi } +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever deletes this transaction's +# object. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_link_inode "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Copies a complete cache into a sibling staging directory, then publishes it # with rename. Copy/publish failures retain the previous cache; if restoration @@ -753,12 +807,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_mono_stage" @@ -1196,12 +1245,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_ms_stage" diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 4618384e..f5f29034 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -2097,6 +2097,89 @@ rm -f "$identity_anchor" } } +func TestInstallerShellCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inject the race inside the second publication's child move: the + // first skill has already been published and recorded, so this + // simulates a concurrent writer replacing it and the transaction + // failing afterwards — rollback must verify identity and retain + // the foreign object instead of blind-deleting the path. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + rm -rf "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/concurrent-user-data.txt" + return 1 + fi + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s copy race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip identity-mismatched rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "concurrent-user-data.txt")) + if err != nil || string(got) != "first-user-data\n" { + t.Fatalf("%s concurrent directory was deleted or modified: %q, %v", scriptName, string(got), err) + } + entries, err := os.ReadDir(first) + if err != nil || len(entries) != 1 || entries[0].Name() != "concurrent-user-data.txt" { + t.Fatalf("%s foreign directory contents changed: %v, %v", scriptName, entries, err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { @@ -2694,7 +2777,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-multi-set.*/dingtalk-second) return 1 ;; + */.dws-multi-set.*/dingtalk-second/*) return 1 ;; esac fi command mv "$@" @@ -2828,7 +2911,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-mono-set.*/dws) return 1 ;; + */.dws-mono-set.*/dws/*) return 1 ;; esac fi command mv "$@" From 21395ed12ddcbc9a2a601fae31f21ed42316f236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 13:48:23 +0800 Subject: [PATCH 111/267] fix(skills): retract unrecorded dest after cross-device publish Cross-filesystem Skill moves now record publication identity as soon as the staging path is renamed onto dest. A later mode-restore, copy verification, or staging-cleanup failure retracts that proven dest so retries are not blocked by an untracked leftover. A failed retract reports an uncertain state naming both retained locations. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/skill_path_move.go | 44 ++++++++-- .../upgrade/skill_path_move_errors_test.go | 22 ++++- internal/upgrade/skill_path_move_test.go | 87 ++++++++++++++++++- internal/upgrade/skill_publication.go | 29 +++++-- internal/upgrade/skill_publication_test.go | 22 +++++ 6 files changed, 188 insertions(+), 18 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 61babdf0..502a5bd7 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — and publish copied mono/multi sets through an atomic mkdir claim with per-child moves, while rollback verifies the recorded destination inode before removing anything, so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move or deleted by a path-blind rollback. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — and publish copied mono/multi sets through an atomic mkdir claim with per-child moves, while rollback verifies the recorded destination inode before removing anything, so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move or deleted by a path-blind rollback. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index f091bb6f..11bccab2 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -39,8 +39,14 @@ var ( // moveSkillPathRecoverably moves one managed Skill path without weakening the // backup contract when source and destination are on different filesystems. // The cross-device path publishes a fully copied and verified sibling staging -// path before removing the source. Any failure before source removal leaves the -// source intact; a removal failure deliberately leaves both verified copies. +// path before removing the source. Any failure before the destination is +// published leaves the source intact. After the staging path has been renamed +// onto the destination, a later failure (mode restore, copy verification, or +// staging cleanup) retracts that identity-proven destination so the caller is +// not left with an unrecorded leftover that would block retries; a retract +// failure returns an uncertain-state error naming both retained locations. A +// source-removal failure after a verified publication deliberately leaves both +// copies. func moveSkillPathRecoverably(src, dst string) (err error) { if _, statErr := skillPathLstat(dst); statErr == nil { return fmt.Errorf("目标已存在: %s", dst) @@ -115,16 +121,32 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathRenameNoReplace(stage, dst); err != nil { return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) } + // The destination is now occupied by this transaction, but the caller has + // not recorded it as a backup. Capture a verifiable publication identity + // immediately so any later failure can retract dest instead of leaving an + // untracked leftover that would make retries fail with "目标已存在". + publication, recErr := recordSkillPathPublication(dst) + if recErr != nil { + return fmt.Errorf("Skill 移动状态不确定:跨设备发布后无法登记目标 %s: %w;源 %s 与目标 %s 均保留", dst, recErr, src, dst) + } if stageInfo.IsDir() { if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { - return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + return retractCrossDevicePublication(src, dst, publication, fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)) } + // chmod updates mode (and ctime on some filesystems). Fingerprint and + // incarnation include both, so refresh the record before later steps + // can fail; a stale record would refuse to retract dest. + refreshed, recErr := recordSkillPathPublication(dst) + if recErr != nil { + return fmt.Errorf("Skill 移动状态不确定:跨设备发布后无法登记已恢复权限的目标 %s: %w;源 %s 与目标 %s 均保留", dst, recErr, src, dst) + } + publication = refreshed } if err := skillPathVerify(src, dst); err != nil { - return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + return retractCrossDevicePublication(src, dst, publication, fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)) } if err := skillPathRemoveAll(stageRoot); err != nil { - return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + return retractCrossDevicePublication(src, dst, publication, fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)) } stageCleaned = true if err := removePublishedSkillSource(src); err != nil { @@ -133,6 +155,18 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return nil } +// retractCrossDevicePublication withdraws an identity-proven destination +// published by the cross-device copy path. The source is still intact and the +// caller has not recorded this destination as a backup. If retract fails, the +// error names both retained locations so a later retry is not mistaken for a +// clean original layout. +func retractCrossDevicePublication(src, dst string, publication SkillPathPublication, cause error) error { + if retractErr := rollbackSkillPathPublication(publication); retractErr != nil { + return fmt.Errorf("Skill 移动状态不确定:%v;撤回目标 %s 失败: %v;源 %s 与目标 %s 均保留", cause, dst, retractErr, src, dst) + } + return fmt.Errorf("Skill 移动失败,目标已撤回,原路径保留 %s: %w", src, cause) +} + // retractSkillDirChildMove undoes the no-replace fallback child move when the // emptied source shell cannot be removed afterwards: every child of the // published destination moves back into the shell, then the destination is diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go index 4c1d4af5..daed02d2 100644 --- a/internal/upgrade/skill_path_move_errors_test.go +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -87,13 +87,24 @@ func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { } return original(path, mode) }) - if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("error = %v", err) } + if tc.name != "restore published mode" { + return + } + if !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("post-publish chmod failure must retract dest, got %v", err) + } + assertSkillSourcePreserved(t, src) + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("unrecorded destination must be retracted: %v", statErr) + } }) } - t.Run("published staging cleanup", func(t *testing.T) { + t.Run("published staging cleanup retracts dest", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) forceCrossDeviceRename(t, src, dst) original := skillPathRemoveAll @@ -103,9 +114,14 @@ func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { } return original(path) }) - if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "清理已发布") || !strings.Contains(err.Error(), "目标已撤回") { t.Fatalf("error = %v", err) } + assertSkillSourcePreserved(t, src) + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("unrecorded destination must be retracted: %v", statErr) + } }) t.Run("source deletion cannot be confirmed", func(t *testing.T) { diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 98885c03..6a3b4633 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -198,7 +198,7 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { assertNoCrossDeviceStage(t, dst) }) - t.Run("published verification failure preserves both", func(t *testing.T) { + t.Run("published verification failure retracts dest", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) forceCrossDeviceRename(t, src, dst) calls := 0 @@ -209,16 +209,95 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { } return nil }) - if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "published verify failed") || !strings.Contains(err.Error(), "目标已撤回") { t.Fatalf("error = %v", err) } assertSkillSourcePreserved(t, src) - if _, err := os.Lstat(dst); err != nil { - t.Fatalf("verified backup candidate must remain: %v", err) + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("unrecorded destination must be retracted: %v", statErr) } assertNoCrossDeviceStage(t, dst) }) + t.Run("publication record failure after publish reports uncertain state", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + info, err := originalLstat(path) + if path == dst && err == nil { + return nil, os.ErrPermission + } + return info, err + }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("unrecordable destination must be reported as uncertain, got %v", err) + } + assertSkillSourcePreserved(t, src) + if _, statErr := os.Lstat(dst); statErr != nil { + t.Fatalf("unrecordable destination must remain at the reported location: %v", statErr) + } + }) + + t.Run("publication record refresh after mode restore reports uncertain state", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + modeRestored := false + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + err := originalChmod(path, mode) + if path == dst && err == nil { + modeRestored = true + } + return err + }) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst && modeRestored { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("unrecordable restored destination must be reported as uncertain, got %v", err) + } + assertSkillSourcePreserved(t, src) + if _, statErr := os.Lstat(dst); statErr != nil { + t.Fatalf("unrecordable destination must remain at the reported location: %v", statErr) + } + }) + + t.Run("published verification retract failure reports uncertain state", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + originalMkdirTemp := skillPathMkdirTemp + testseam.Swap(t, &skillPathMkdirTemp, func(dir, pattern string) (string, error) { + if strings.Contains(pattern, ".rollback-") { + return "", os.ErrPermission + } + return originalMkdirTemp(dir, pattern) + }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) { + t.Fatalf("failed retract must report the retained locations, got %v", err) + } + assertSkillSourcePreserved(t, src) + if _, statErr := os.Lstat(dst); statErr != nil { + t.Fatalf("destination must remain when retract fails: %v", statErr) + } + }) + t.Run("source removal failure preserves both", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) forceCrossDeviceRename(t, src, dst) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 9abd9b5b..36ff6824 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -60,13 +60,32 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication } else if stagedErr != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): 无法确认 staging 状态", destination) } + return recordSkillPathPublicationFrom(destination, publishedIdentity, fingerprint, publishedFileID), nil +} + +// recordSkillPathPublication captures the live identity of a path that this +// transaction just published so a later retract can prove the object is still +// the one it created. Callers must not invent records for paths they do not own. +func recordSkillPathPublication(path string) (SkillPathPublication, error) { + identity, err := skillPathLstat(path) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("读取已发布 Skill 身份失败 %s: %w", path, err) + } + fingerprint, err := fingerprintSkillPath(path) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("计算已发布 Skill 身份失败 %s: %w", path, err) + } + return recordSkillPathPublicationFrom(path, identity, fingerprint, skillPathFileIdentity(path)), nil +} + +func recordSkillPathPublicationFrom(path string, identity os.FileInfo, fingerprint [sha256.Size]byte, fileID string) SkillPathPublication { return SkillPathPublication{ - Destination: destination, + Destination: path, fingerprint: fingerprint, - identity: publishedIdentity, - incarnation: skillPathFileIncarnation(publishedIdentity), - fileID: publishedFileID, - }, nil + identity: identity, + incarnation: skillPathFileIncarnation(identity), + fileID: fileID, + } } // RollbackSkillPathPublications removes only objects that can still be proven diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 6adeae17..4a0b1892 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -287,6 +287,28 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("record published identity", func(t *testing.T) { + if _, err := recordSkillPathPublication(filepath.Join(t.TempDir(), "missing")); err == nil || !strings.Contains(err.Error(), "读取已发布 Skill 身份失败") { + t.Fatalf("missing record identity error = %v", err) + } + }) + + t.Run("record published fingerprint", func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "published") + seedUpgradeSkill(t, path, "value", false) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(dir string) ([]os.DirEntry, error) { + if dir == path { + return nil, failure + } + return originalReadDir(dir) + }) + if _, err := recordSkillPathPublication(path); !errors.Is(err, failure) { + t.Fatalf("record fingerprint error = %v", err) + } + }) + t.Run("rollback temp", func(t *testing.T) { testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { From 46b641f22799735d29ed33f7566b8aac22eec2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 15:53:56 +0800 Subject: [PATCH 112/267] fix(skills): retract leftover dest and qualify Windows junctions Record dest on occupy and retract it when confirmation, verify, or staging cleanup fails. Restore unmatched quarantine with a no-replace publish. Event/devapp copy uses mkdir-claim; shell rollback claims dest before delete. Release copy now says npm/PowerShell create junctions and Go uses os.Symlink, with copy fallback when linking is unavailable. --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 273 ++++++++++++++++++--- docs/rfc-skill-installation-and-upgrade.md | 7 +- internal/upgrade/skill_publication.go | 46 ++-- internal/upgrade/skill_publication_test.go | 83 ++++++- scripts/install-devapp.ps1 | 35 ++- scripts/install-devapp.sh | 43 +++- scripts/install-event.sh | 43 +++- scripts/install-skills.sh | 122 +++++++-- scripts/install.ps1 | 39 ++- scripts/install.sh | 126 ++++++++-- test/scripts/install_js_smoke.mjs | 76 +++++- test/scripts/install_script_test.go | 105 +++++++- 13 files changed, 891 insertions(+), 109 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 502a5bd7..56e32a02 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — and publish copied mono/multi sets through an atomic mkdir claim with per-child moves, while rollback verifies the recorded destination inode before removing anything, so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move or deleted by a path-blind rollback. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index a361b93d..b649ba3d 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -343,8 +343,134 @@ function verifyPathCopySync(src, dest) { } } +function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + try { + fs.mkdirSync(destination, { mode: 0o700 }); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + const moved = []; + try { + for (const name of fs.readdirSync(source)) { + renameFn(path.join(source, name), path.join(destination, name)); + moved.push(name); + } + fs.chmodSync(destination, sourceStat.mode & 0o777); + try { + fs.rmdirSync(source); + } catch (err) { + // Dest already holds the children. A leftover source shell would make + // callers think the move succeeded while the original path still + // exists (empty) — retract dest so source remains the only live copy. + for (let i = moved.length - 1; i >= 0; i -= 1) { + try { + renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); + } catch (_) { + // Best effort: claim cleanup below reports whatever is left. + } + } + try { + fs.rmdirSync(destination); + } catch (cleanupErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${cleanupErr.message}`); + } + throw err; + } + } catch (err) { + for (let i = moved.length - 1; i >= 0; i -= 1) { + try { + renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); + } catch (_) { + // Best effort: claim cleanup below reports whatever is left. + } + } + try { + fs.rmdirSync(destination); + } catch (cleanupErr) { + throw new Error(`${err.message}; failed to remove the claimed Skill destination ${destination}: ${cleanupErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isSymbolicLink()) { + try { + fs.symlinkSync(fs.readlinkSync(source), destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + fs.unlinkSync(destination); + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fs.linkSync(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +function recordSkillPathPublicationSync(destination) { + const stat = fs.lstatSync(destination, { bigint: true }); + return { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(destination), + }; +} + +function retractUnconfirmedSkillPublicationSync(src, dest, publication, cause, options = {}) { + try { + rollbackPublishedSkillPath(publication, { + quarantineRenameFn: options.quarantineRenameFn, + restoreRenameFn: options.restoreRenameFn || options.renameFn, + removeFn: options.removeFn, + }); + } catch (retractErr) { + throw new Error(`Skill move state uncertain: ${cause.message}; failed to retract ${dest}: ${retractErr.message}; source ${src} and dest ${dest} retained`); + } + throw new Error(`Skill move failed, dest retracted, source retained (${src}): ${cause.message}`); +} + function movePathRecoverablySync(src, dest, options = {}) { - const renameFn = options.renameFn || fs.renameSync; + const renameFn = options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); const copyFn = options.copyFn || copyPathLexicallySync; const verifyFn = options.verifyFn || verifyPathCopySync; const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); @@ -364,18 +490,39 @@ function movePathRecoverablySync(src, dest, options = {}) { const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); const stage = path.join(stageRoot, "payload"); let stageCleaned = false; + let destOccupied = false; try { copyFn(src, stage); verifyFn(src, stage); const stageInfo = fs.lstatSync(stage); const stageMode = stageInfo.mode & 0o777; if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); - renameFn(stage, dest); - if (stageInfo.isDirectory()) chmodFn(dest, stageMode); - verifyFn(src, dest); - makeTreeWritableBestEffortSync(stageRoot); - removeFn(stageRoot); - stageCleaned = true; + // Dest publication is always no-replace. The injected renameFn is the + // same-volume EXDEV probe; using it here would re-open a replacing + // rename on Windows (MOVEFILE_REPLACE_EXISTING). + renamePathNoReplaceSync(stage, dest, renameFn); + destOccupied = true; + let publication; + try { + publication = recordSkillPathPublicationSync(dest); + } catch (recErr) { + if (!pathExistsLexicallySync(dest)) throw recErr; + throw new Error(`Skill move state uncertain: published dest ${dest} could not be recorded: ${recErr.message}; source ${src} and dest ${dest} retained`); + } + try { + if (stageInfo.isDirectory()) { + chmodFn(dest, stageMode); + publication = recordSkillPathPublicationSync(dest); + } + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + } catch (postErr) { + retractUnconfirmedSkillPublicationSync(src, dest, publication, postErr, { + removeFn, + }); + } try { removePublishedSourceSync(src, removeFn, chmodFn); } catch (err) { @@ -389,8 +536,11 @@ function movePathRecoverablySync(src, dest, options = {}) { try { makeTreeWritableBestEffortSync(stageRoot); removeFn(stageRoot); + stageCleaned = true; } catch (cleanupErr) { - throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + if (!destOccupied) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } } } throw err; @@ -821,13 +971,31 @@ function skillPathExistsLexically(target) { } function rollbackPublishedSkillPath(publication, options = {}) { - const renameFn = options.renameFn || fs.renameSync; + // Quarantine must be a same-inode rename. A no-replace mkdir-claim (or a + // fresh symlink/hard-link) would give the payload a new identity and make + // this transaction's own publication look foreign. Restore of an unmatched + // object is the no-replace publish: dest must not already hold a replacement. + const quarantineRenameFn = options.quarantineRenameFn || fs.renameSync; + const restoreRenameFn = options.restoreRenameFn || options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); const removeFn = options.removeFn || removeSkillPathLexically; const destination = publication.destination; const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`)); const quarantine = path.join(quarantineRoot, "payload"); + const restoreUnowned = (cause) => { + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${cause}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + try { + fs.rmSync(quarantineRoot, { recursive: true, force: true }); + } catch (_) { + // Dest is restored; leftover empty quarantine root is best-effort. + } + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${cause}`); + }; try { - renameFn(destination, quarantine); + quarantineRenameFn(destination, quarantine); } catch (err) { fs.rmSync(quarantineRoot, { recursive: true, force: true }); if (err && err.code === "ENOENT") return; @@ -837,10 +1005,10 @@ function rollbackPublishedSkillPath(publication, options = {}) { try { owned = skillPublicationMatches(quarantine, publication); } catch (err) { - throw new Error(`refusing to delete unverifiable Skill ${destination}; object retained at ${quarantine}: ${err.message}`); + restoreUnowned(err.message); } if (!owned) { - throw new Error(`refusing to delete non-transaction Skill ${destination}; concurrent object retained at ${quarantine}`); + restoreUnowned("concurrent object identity changed"); } removeFn(quarantine); fs.rmSync(quarantineRoot, { recursive: true, force: true }); @@ -929,17 +1097,34 @@ function publishCanonicalLinkNoReplace(staged, destination, options = {}) { } throw err; } - const stat = fs.lstatSync(destination, { bigint: true }); - if (!stat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) { + let publication; + try { + publication = recordSkillPathPublicationSync(destination); + } catch (recErr) { + if (!skillPathExistsLexically(destination)) throw recErr; + throw new Error(`Skill publish state uncertain: dest ${destination} occupied but unrecorded: ${recErr.message}`); + } + let destStat; + try { + destStat = fs.lstatSync(destination, { bigint: true }); + } catch (err) { + throw new Error(`published Skill identity changed before confirmation: ${destination}: ${err.message}`); + } + if (!destStat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) { + // Dest is not the link this transaction created. Leave the occupant. throw new Error(`published Skill identity changed before confirmation: ${destination}`); } - const publication = { - destination, - device: stat.dev.toString(), - inode: stat.ino.toString(), - fingerprint: skillPathFingerprint(destination), - }; - if (!skillPublicationMatches(destination, publication)) { + const sameInode = publication + && destStat.dev.toString() === publication.device + && destStat.ino.toString() === publication.inode; + if (!publication || !sameInode || !skillPublicationMatches(destination, publication)) { + if (sameInode) { + try { + rollbackPublishedSkillPath(publication); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: published dest ${destination} confirmation failed; retract failed: ${retractErr.message}`); + } + } throw new Error(`published Skill identity changed before confirmation: ${destination}`); } return publication; @@ -948,7 +1133,7 @@ function publishCanonicalLinkNoReplace(staged, destination, options = {}) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { const moveOptions = skillMoveOptions(options); const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; - const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); // Injectable so the Windows junction type and absolute link target can be // asserted from a non-Windows host. const symlinkFn = options.symlinkFn || fs.symlinkSync; @@ -963,7 +1148,7 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn: publishRemoveFn }); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn: publishRemoveFn }); } catch (err) { restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } @@ -1056,7 +1241,7 @@ function publishManagedMultiSkillSetAtomically( ) { const copyFn = options.copyFn || copyChildren; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); - const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); @@ -1068,7 +1253,7 @@ function publishManagedMultiSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } catch (err) { restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } @@ -1106,7 +1291,22 @@ function publishManagedMultiSkillSetAtomically( for (const item of staged) { const publication = publishSkillDirNoReplace(item.staged, item.dest, options); - if (!skillPublicationMatches(item.dest, publication)) throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + if (!skillPublicationMatches(item.dest, publication)) { + let live = null; + try { + live = fs.lstatSync(item.dest, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${item.dest} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + } published.push(publication); } } catch (err) { @@ -1133,7 +1333,7 @@ function publishManagedMonoSkillSetAtomically( ) { const copyFn = options.copyFn || copyChildren; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); - const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync; + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); @@ -1146,7 +1346,7 @@ function publishManagedMonoSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn }); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } catch (err) { restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } @@ -1179,7 +1379,22 @@ function publishManagedMonoSkillSetAtomically( } const publication = publishSkillDirNoReplace(stagedDir, destDir, options); - if (!skillPublicationMatches(destDir, publication)) throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + if (!skillPublicationMatches(destDir, publication)) { + let live = null; + try { + live = fs.lstatSync(destDir, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${destDir} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + } published.push(publication); } catch (err) { try { diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index f1aea95c..01db6595 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -143,7 +143,9 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen 升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 universal 的 Agent 不再保留 Agent 私有副本;检测到的 非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical -的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。 +的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` / +`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的 +直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。 自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 `GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 `.moltbot` 必须按 Agent 实际优先级解析。 @@ -252,7 +254,8 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; -- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; +- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` / + `dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复; - Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; - `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 36ff6824..c1c6066a 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -36,17 +36,25 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } - publishedIdentity, err := skillPathLstat(destination) - if err != nil { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + // Dest is now occupied by this transaction, but callers only record a + // publication after this function returns. Capture identity immediately + // so a later confirmation failure can retract dest instead of leaving an + // untracked leftover that blocks backup restore. + publication, recErr := recordSkillPathPublication(destination) + if recErr != nil { + if _, statErr := skillPathLstat(destination); os.IsNotExist(statErr) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s: %w", destination, recErr) + } + return SkillPathPublication{}, fmt.Errorf("Skill 发布状态不确定:发布后无法登记目标 %s: %w;目标 %s 保留", destination, recErr, destination) } - publishedFingerprint, err := fingerprintSkillPath(destination) - if err != nil { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) - } - publishedFileID := skillPathFileIdentity(destination) - if publishedFingerprint != fingerprint { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 内容已变化", destination) + if publication.fingerprint != fingerprint { + if _, stagedErr := skillPathLstat(staged); os.IsNotExist(stagedErr) && + !skillPathIdentityProven(identity, publication.identity, stagedFileID, publication.fileID) { + // Dest is not the staged object: a concurrent writer won the path + // after the rename returned. Leave that object in place. + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s: staging 内容已变化", destination) + } + return SkillPathPublication{}, retractUnconfirmedSkillPublication(publication, fmt.Errorf("确认已发布 Skill 身份失败 %s: staging 内容已变化", destination)) } // The rename consumed the staged path: the published object is the staged // object itself, which identity proves. The rename left the staged path @@ -54,13 +62,23 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication // identity cannot span that move, so the proof is the content equality // verified above. if _, stagedErr := skillPathLstat(staged); os.IsNotExist(stagedErr) { - if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + if !skillPathIdentityProven(identity, publication.identity, stagedFileID, publication.fileID) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s: staging 身份已变化", destination) } } else if stagedErr != nil { - return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): 无法确认 staging 状态", destination) + return SkillPathPublication{}, retractUnconfirmedSkillPublication(publication, fmt.Errorf("确认已发布 Skill 身份失败 %s: 无法确认 staging 状态", destination)) } - return recordSkillPathPublicationFrom(destination, publishedIdentity, fingerprint, publishedFileID), nil + return publication, nil +} + +// retractUnconfirmedSkillPublication withdraws a dest that this transaction +// occupied but never handed to the caller. A failed retract names dest so a +// later restore is not mistaken for a clean original layout. +func retractUnconfirmedSkillPublication(publication SkillPathPublication, cause error) error { + if retractErr := rollbackSkillPathPublication(publication); retractErr != nil { + return fmt.Errorf("Skill 发布状态不确定:%v;撤回目标 %s 失败: %v;目标 %s 保留", cause, publication.Destination, retractErr, publication.Destination) + } + return fmt.Errorf("Skill 发布失败,目标已撤回: %w", cause) } // recordSkillPathPublication captures the live identity of a path that this diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 4a0b1892..6d103eae 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -151,9 +151,12 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } return os.RemoveAll(target) }) - if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "确认已发布 Skill 身份失败") { t.Fatalf("publish confirmation error = %v", err) } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("missing dest after publish must stay missing: %v", statErr) + } }) t.Run("publish confirmation content changed", func(t *testing.T) { @@ -175,6 +178,7 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") { t.Fatalf("publish content error = %v", err) } + assertUpgradeSkillContent(t, destination, "replacement") }) t.Run("publish confirmation identity changed", func(t *testing.T) { @@ -207,6 +211,7 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) } + assertUpgradeSkillContent(t, destination, "value") }) t.Run("publish confirmation accepts child-move publication", func(t *testing.T) { @@ -256,8 +261,12 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } return originalLstat(path) }) - if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "无法确认 staging 状态") { - t.Fatalf("unreadable staged state must fail closed, got %v", err) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "无法确认 staging 状态") || !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("unreadable staged state must retract dest, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("unconfirmed dest must be retracted: %v", statErr) } }) @@ -282,9 +291,73 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } return originalReadDir(path) }) - if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") { - t.Fatalf("publish fingerprint confirmation error = %v", err) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !errors.Is(err, failure) || !strings.Contains(err.Error(), "状态不确定") { + t.Fatalf("unrecordable dest must be reported as uncertain, got %v", err) } + assertUpgradeSkillContent(t, destination, "value") + }) + + t.Run("owned dest content drift after publish retracts dest", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + return os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("mutated\n"), 0o644) + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "staging 内容已变化") || !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("owned dest drift must retract dest, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("mutated owned dest must be retracted: %v", statErr) + } + }) + + t.Run("confirm retract failure reports uncertain state", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + return os.Rename(filepath.Join(source, "SKILL.md"), filepath.Join(target, "SKILL.md")) + }) + renamed := false + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + renamed = true + return nil + }) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if renamed && path == staged { + return nil, errors.New("stat denied") + } + return originalLstat(path) + }) + originalMkdirTemp := skillPathMkdirTemp + testseam.Swap(t, &skillPathMkdirTemp, func(dir, pattern string) (string, error) { + if strings.Contains(pattern, ".rollback-") { + return "", os.ErrPermission + } + return originalMkdirTemp(dir, pattern) + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), destination) { + t.Fatalf("failed retract must name dest, got %v", err) + } + assertUpgradeSkillContent(t, destination, "value") }) t.Run("record published identity", func(t *testing.T) { diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 7a8b7fd2..58fa7822 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -115,7 +115,18 @@ function Move-DevSkillPath([string]$Source, [string]$Destination) { if ($sourceItem.PSIsContainer) { [System.IO.Directory]::Move($Source, $Destination) } else { - [System.IO.File]::Move($Source, $Destination) + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-DevSkillPathLexically $Source + } catch { + $removeErr = $_ + try { + Remove-DevSkillPathLexically $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } } } @@ -235,13 +246,25 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false try { Copy-DevSkillPathLexically $Source $stage Assert-DevSkillPathCopy $Source $stage Move-DevSkillPath $stage $Destination - Assert-DevSkillPathCopy $Source $Destination - if (!(Remove-DevLinkStageRoot $stageRoot)) { - throw "Skill staging cleanup failed (published content untouched): $stageRoot" + $published = $true + try { + Assert-DevSkillPathCopy $Source $Destination + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "Skill staging cleanup failed: $stageRoot" + } + } catch { + $postErr = $_ + try { + if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } + } catch { + throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained" + } + throw "Skill move failed, dest retracted, source retained ${Source}: $postErr" } try { Remove-DevSkillPathLexically $Source } catch { throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" @@ -251,7 +274,9 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { if (!(Remove-DevLinkStageRoot $stageRoot)) { - throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" + if (-not $published) { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" + } } } throw $failure diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 6cef00fa..fe6c0d3f 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -54,10 +54,47 @@ copy_tree() { stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - if mv "$stage" "$dest"; then return 0; fi + # mkdir is the atomic no-replace claim: POSIX `mv` of a staging directory + # onto an occupied dest treats dest as a container and nests the stage + # inside it. Children then move into a directory this transaction owns. + if mkdir "$dest" 2>/dev/null; then + _ct_failed=0 + for _ct_child in "$stage"/* "$stage"/.[!.]* "$stage"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + if ! mv "$_ct_child" "$dest/"; then + _ct_failed=1 + break + fi + done + if [ "$_ct_failed" -eq 0 ]; then + rm -rf "$stage" + return 0 + fi + for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + mv "$_ct_child" "$stage/" || true + done + rmdir "$dest" 2>/dev/null || true + fi rm -rf "$stage" - if [ -n "$backup" ] && ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + if [ -n "$backup" ]; then + if mkdir "$dest" 2>/dev/null; then + _ct_restore_failed=0 + for _ct_child in "$backup"/* "$backup"/.[!.]* "$backup"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + if ! mv "$_ct_child" "$dest/"; then + _ct_restore_failed=1 + break + fi + done + if [ "$_ct_restore_failed" -eq 0 ]; then + rmdir "$backup" 2>/dev/null || true + else + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi fi return 1 } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index eeaf2c37..75933141 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -119,10 +119,47 @@ copy_tree() { stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - if mv "$stage" "$dest"; then return 0; fi + # mkdir is the atomic no-replace claim: POSIX `mv` of a staging directory + # onto an occupied dest treats dest as a container and nests the stage + # inside it. Children then move into a directory this transaction owns. + if mkdir "$dest" 2>/dev/null; then + _ct_failed=0 + for _ct_child in "$stage"/* "$stage"/.[!.]* "$stage"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + if ! mv "$_ct_child" "$dest/"; then + _ct_failed=1 + break + fi + done + if [ "$_ct_failed" -eq 0 ]; then + rm -rf "$stage" + return 0 + fi + for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + mv "$_ct_child" "$stage/" || true + done + rmdir "$dest" 2>/dev/null || true + fi rm -rf "$stage" - if [ -n "$backup" ] && ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + if [ -n "$backup" ]; then + if mkdir "$dest" 2>/dev/null; then + _ct_restore_failed=0 + for _ct_child in "$backup"/* "$backup"/.[!.]* "$backup"/..?*; do + [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue + if ! mv "$_ct_child" "$dest/"; then + _ct_restore_failed=1 + break + fi + done + if [ "$_ct_restore_failed" -eq 0 ]; then + rmdir "$backup" 2>/dev/null || true + else + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi fi return 1 } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 2c5abb21..4287560c 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -277,9 +277,113 @@ backup_and_record_skill_dir() { fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Claims dest into a sibling quarantine, re-checks identity there, deletes +# only on match, and restores with a no-replace publish on mismatch. An +# in-place inode check followed by rm would delete a concurrent replacement +# that won dest after the check. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + printf ' ⚠️ 无法隔离待回滚 Skill: %s\n' "$_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s(并发对象保留于 %s)\n' "$_qrr_dest" "$_qrr_payload" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported +# its exact backup path. Dest is claimed into quarantine first; identity is +# re-checked there so a concurrent replacement is renamed back instead of +# being deleted by path. Paths containing newlines are outside the supported # installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" @@ -291,18 +395,13 @@ restore_multi_skill_set() { _rms_dest="${_rms_entry%:*}" _rms_inode="${_rms_entry##*:}" [ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; } - if [ "$(skill_link_inode "$_rms_dest")" = "$_rms_inode" ]; then - rm -rf "$_rms_dest" || _rms_ok=0 - else - printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rms_dest" - _rms_ok=0 - fi + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi @@ -334,12 +433,7 @@ restore_linked_skill_set() { if [ -f "$_rls_published" ]; then while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do [ -n "$_rls_dest" ] || continue - if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then - rm -f "$_rls_dest" || _rls_ok=0 - else - printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest" - _rls_ok=0 - fi + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 done < "$_rls_published" fi restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f170e556..22d49438 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -269,7 +269,21 @@ function Move-SkillPath { # either path. [System.IO.Directory]::Move($Source, $Destination) } else { - [System.IO.File]::Move($Source, $Destination) + # File.Move replaces an occupied dest on Windows. Copy without + # overwrite refuses that dest, then the source is removed only after + # the exclusive copy succeeds. + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-SkillPathLexically -Path $Source + } catch { + $removeErr = $_ + try { + Remove-SkillPathLexically -Path $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } } } @@ -453,13 +467,26 @@ function Move-SkillPathRecoverably { $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) $stage = Join-Path $stageRoot "payload" New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false try { Copy-SkillPathLexically -Source $Source -Destination $stage Assert-SkillPathCopy -Source $Source -Destination $stage Move-SkillPath -Source $stage -Destination $Destination - Assert-SkillPathCopy -Source $Source -Destination $Destination - if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { - throw "Skill staging 清理失败(已发布内容不受影响): $stageRoot" + $published = $true + $publication = [pscustomobject]@{ Path = $Destination; Source = $Source } + try { + Assert-SkillPathCopy -Source $Source -Destination $Destination + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "Skill staging 清理失败: $stageRoot" + } + } catch { + $postErr = $_ + try { + Remove-PublishedSkillPathSafely -Record $publication + } catch { + throw "Skill 移动状态不确定:$postErr;撤回目标 $Destination 失败: $_;源 $Source 与目标 $Destination 均保留" + } + throw "Skill 移动失败,目标已撤回,原路径保留 ${Source}: $postErr" } try { Remove-SkillPathLexically -Path $Source @@ -473,7 +500,9 @@ function Move-SkillPathRecoverably { $failure = $_ if (Test-Path -LiteralPath $stageRoot) { if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { - throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" + if (-not $published) { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" + } } } throw $failure diff --git a/scripts/install.sh b/scripts/install.sh index a6d611d9..33006272 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -296,13 +296,115 @@ backup_and_record_skill_dir() { fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Claims dest into a sibling quarantine, re-checks identity there, deletes +# only on match, and restores with a no-replace publish on mismatch. An +# in-place inode check followed by rm would delete a concurrent replacement +# that won dest after the check. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + say " ⚠️ 无法隔离待回滚 Skill: $_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest(并发对象保留于 $_qrr_payload)" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from # its exact backup path. Publication manifest entries are : -# pairs recorded at publish time; a destination whose inode no longer matches -# was concurrently replaced after publication and is preserved untouched — -# never blind-deleted by path. Paths containing newlines are outside the -# supported installer path contract; spaces are preserved. +# pairs recorded at publish time. Dest is claimed into quarantine first; +# identity is re-checked there so a concurrent replacement is renamed back +# instead of being deleted by path. Paths containing newlines are outside +# the supported installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" @@ -313,18 +415,13 @@ restore_multi_skill_set() { _rms_dest="${_rms_entry%:*}" _rms_inode="${_rms_entry##*:}" [ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; } - if [ "$(skill_link_inode "$_rms_dest")" = "$_rms_inode" ]; then - rm -rf "$_rms_dest" || _rms_ok=0 - else - say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rms_dest" - _rms_ok=0 - fi + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})" _rms_ok=0 fi @@ -356,12 +453,7 @@ restore_linked_skill_set() { if [ -f "$_rls_published" ]; then while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do [ -n "$_rls_dest" ] || continue - if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then - rm -f "$_rls_dest" || _rls_ok=0 - else - say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rls_dest" - _rls_ok=0 - fi + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 done < "$_rls_published" fi restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index f427d022..da04a34b 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -295,6 +295,67 @@ function runCrossDeviceMoveContract() { assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); } + { + const { src, dest } = fixture("dest-verify"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + verifyFn(left, right) { + if (right === dest) throw new Error("dest-verify failed"); + }, + }), + /dest retracted|dest-verify failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy verify failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-chmod"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + chmodFn(target, mode) { + if (target === dest) throw new Error("chmod failed"); + fs.chmodSync(target, mode); + }, + }), + /dest retracted|chmod failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy chmod failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-cleanup"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target !== src && path.basename(target).startsWith(".skill.cross-device-")) { + throw new Error("cleanup failed"); + } + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /dest retracted|cleanup failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy staging cleanup failure retracts dest"); + } + + { + const { src, dest } = fixture("same-volume-occupied"); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => movePathRecoverablySync(src, dest), + /already exists/, + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + { const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); roots.push(root); @@ -889,16 +950,23 @@ scenario("canonical link publication never clobbers or deletes concurrent user d }, }, ), - /concurrent object retained|rollback failed/, + /rollback failed|refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(first, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "unmatched quarantine must be restored onto dest", + ); + assert.ok( + !fs.existsSync(path.join(first, "SKILL.md")), + "restored concurrent dest must not be replaced by the backup", ); - assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n"); assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n"); const retained = fs.readdirSync(base) .filter((name) => name.startsWith(".dingtalk-first.rollback-")) .map((name) => path.join(base, name, "payload", "concurrent-user-data.txt")) .filter((candidate) => fs.existsSync(candidate)); - assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine"); - assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n"); + assert.equal(retained.length, 0, "restored concurrent object must not stay hidden in quarantine"); } { diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index f5f29034..06581f5d 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -729,6 +729,76 @@ exit 0 } } +func TestInstallerStandaloneCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + if [ "$1" = "$DWS_TEST_DEST" ]; then + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" +} +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +[ -f "$DWS_TEST_DEST/concurrent-user-data.txt" ] || exit 3 +[ "$(cat "$DWS_TEST_DEST/concurrent-user-data.txt")" = "must-survive" ] || exit 4 +if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then + exit 5 +fi +[ -n "$(find "$HOME/.dws/skill-backups" -name SKILL.md -print 2>/dev/null)" ] || exit 6 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy publication race must preserve the concurrent object: %v\n%s", scriptName, err, output) + } + got, err := os.ReadFile(filepath.Join(dest, "concurrent-user-data.txt")) + if err != nil || strings.TrimSpace(string(got)) != "must-survive" { + t.Fatalf("%s concurrent dest = %q, %v", scriptName, got, err) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish", scriptName) + } + }) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1638,6 +1708,21 @@ func TestInstallScriptSourceModeMonoMultiMonoExclusion(t *testing.T) { // directory named name whose SKILL.md equals content, returning its path // ("" when absent). The layout is // with // optional -N collision suffixes on the stamp. +func findSkillBackupContent(fakeHome, content string) string { + root := filepath.Join(fakeHome, ".dws", "skill-backups") + var found string + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || found != "" || info.IsDir() || info.Name() != "SKILL.md" { + return nil + } + if data, err := os.ReadFile(p); err == nil && string(data) == content { + found = p + } + return nil + }) + return found +} + func findSkillBackup(fakeHome, name, content string) string { root := filepath.Join(fakeHome, ".dws", "skill-backups") var found string @@ -2134,12 +2219,16 @@ func TestInstallerShellCopyPublicationRacePreservesConcurrentDirectories(t *test // the foreign object instead of blind-deleting the path. harness := `. "$DWS_TEST_LIBRARY" mv() { - if [ "$2" = "$DWS_TEST_SECOND/" ]; then - rm -rf "$DWS_TEST_FIRST" - mkdir -p "$DWS_TEST_FIRST" - printf '%s\n' first-user-data > "$DWS_TEST_FIRST/concurrent-user-data.txt" - return 1 - fi + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + rm -rf "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/concurrent-user-data.txt" + return 1 + fi + ;; + esac command mv "$@" } if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then @@ -3051,7 +3140,7 @@ func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { } prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") - for _, phase := range []string{"success", "copy", "verify", "remove", "permission"} { + for _, phase := range []string{"success", "copy", "verify", "remove", "permission", "dest-verify"} { phase := phase t.Run(phase, func(t *testing.T) { root := t.TempDir() @@ -3099,6 +3188,7 @@ function Copy-SkillPathLexically { function Assert-SkillPathCopy { param([string]$Source, [string]$Destination) if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $Destination -eq $env:DWS_TEST_DESTINATION) { throw "dest-verify failed" } & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination } function Remove-SkillPathLexically { @@ -3117,6 +3207,7 @@ try { if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $_ -notmatch "目标已撤回|dest-verify failed|状态不确定") { Write-Error $_; exit 8 } exit 0 } ` From 63a6de7b493a7107bd81e6a028dc7ea343179552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 16:40:21 +0800 Subject: [PATCH 113/267] fix(skills): prove npm rollback ownership before quarantine Match the Go dest-first identity check so a concurrent replacement is never moved into .rollback-*; only a post-quarantine mismatch is restored with no-replace. Cover both races in the npm smoke suite. --- .changes/996-canonical-agent-skills.md | 2 +- build/npm/install.js | 62 +++++++++++++++++++------- test/scripts/install_js_smoke.mjs | 58 ++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index 56e32a02..a0376676 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/build/npm/install.js b/build/npm/install.js index b649ba3d..882a17c5 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -971,33 +971,47 @@ function skillPathExistsLexically(target) { } function rollbackPublishedSkillPath(publication, options = {}) { - // Quarantine must be a same-inode rename. A no-replace mkdir-claim (or a - // fresh symlink/hard-link) would give the payload a new identity and make - // this transaction's own publication look foreign. Restore of an unmatched - // object is the no-replace publish: dest must not already hold a replacement. + // Prove ownership at dest first. Quarantining before that check would move + // a concurrent replacement off its original path; if the later match fails + // and restore cannot republish, user data stays hidden under .rollback-*. + // Quarantine itself is a same-inode rename — a no-replace mkdir-claim + // would mint a new identity and make this transaction's own dest look + // foreign. A post-quarantine mismatch is restored with no-replace. const quarantineRenameFn = options.quarantineRenameFn || fs.renameSync; const restoreRenameFn = options.restoreRenameFn || options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); const removeFn = options.removeFn || removeSkillPathLexically; const destination = publication.destination; const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`)); const quarantine = path.join(quarantineRoot, "payload"); - const restoreUnowned = (cause) => { - try { - restoreRenameFn(quarantine, destination); - } catch (restoreErr) { - throw new Error(`refusing to delete non-transaction Skill ${destination}: ${cause}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); - } + const cleanupRoot = () => { try { fs.rmSync(quarantineRoot, { recursive: true, force: true }); - } catch (_) { - // Dest is restored; leftover empty quarantine root is best-effort. + } catch (cleanupErr) { + throw new Error(`failed to clean Skill rollback quarantine ${quarantineRoot}: ${cleanupErr.message}`); } - throw new Error(`refusing to delete non-transaction Skill ${destination}: ${cause}`); }; + try { + if (!skillPublicationMatches(destination, publication)) { + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); + } + } catch (err) { + if (err && err.code === "ENOENT") { + cleanupRoot(); + return; + } + if (String(err.message || "").startsWith("refusing to delete non-transaction Skill") + || String(err.message || "").startsWith("failed to clean Skill rollback quarantine")) { + throw err; + } + cleanupRoot(); + throw new Error(`refusing to delete unverifiable Skill ${destination}: ${err.message}`); + } + try { quarantineRenameFn(destination, quarantine); } catch (err) { - fs.rmSync(quarantineRoot, { recursive: true, force: true }); + cleanupRoot(); if (err && err.code === "ENOENT") return; throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`); } @@ -1005,13 +1019,25 @@ function rollbackPublishedSkillPath(publication, options = {}) { try { owned = skillPublicationMatches(quarantine, publication); } catch (err) { - restoreUnowned(err.message); + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}`); } if (!owned) { - restoreUnowned("concurrent object identity changed"); + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); } removeFn(quarantine); - fs.rmSync(quarantineRoot, { recursive: true, force: true }); + cleanupRoot(); } // Publish a staged Skill directory with a truly atomic no-replace claim. @@ -1687,5 +1713,7 @@ module.exports = { publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, verifyPathCopySync, }; diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index da04a34b..da0cbc35 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -93,6 +93,8 @@ const { publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, } = require(installJsSource); assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned"); @@ -969,6 +971,62 @@ scenario("canonical link publication never clobbers or deletes concurrent user d assert.equal(retained.length, 0, "restored concurrent object must not stay hidden in quarantine"); } + { + // Direct rollback of a recorded publication after a concurrent writer + // replaced dest: ownership is proven at dest first, so the foreign + // object is never claimed into quarantine. + const dest = path.join(tmp, "direct-rollback", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + fs.rmSync(dest, { recursive: true, force: true }); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "concurrent replacement stays at the original destination", + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-")), + false, + "unowned dest must not be moved into quarantine", + ); + } + + { + // Dest matched at the pre-check, then the quarantined object no longer + // matches: restore it onto dest with no-replace instead of deleting. + const dest = path.join(tmp, "post-quarantine", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + assert.throws( + () => rollbackPublishedSkillPath(publication, { + quarantineRenameFn(source, target) { + fs.renameSync(source, target); + fs.writeFileSync(path.join(target, "SKILL.md"), "mutated-in-quarantine\n"); + }, + }), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), + "mutated-in-quarantine\n", + "post-quarantine mismatch is restored onto dest", + ); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-") + && fs.existsSync(path.join(parent, name, "payload", "SKILL.md"))), + false, + "restored dest must not stay hidden in quarantine", + ); + } + { // Injected race: a concurrent creator occupies the destination at the // instant of publication. Link creation must fail atomically with From 6c8e7e082b79577dcc69401efc1ab9ac7bee0a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Tue, 18 Aug 2026 10:41:30 +0800 Subject: [PATCH 114/267] fix(chat): expose single group role set flag --- internal/cli/param_aliases_generated.go | 4 +- internal/cli/param_concepts.json | 3 - internal/helpers/chat.go | 39 ++++++++++--- internal/helpers/chat_command_edges_test.go | 65 ++++++++++++++++++++- internal/shortcut/chat/chat_group.go | 41 +++++++++++-- skills/mono/references/products/chat.md | 14 ++--- 6 files changed, 138 insertions(+), 28 deletions(-) diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index fc5b2840..90d29569 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -2044,7 +2044,7 @@ var generatedParamAliases = []ParamAliasEntry{ "user-id": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat +chat-role-update", @@ -2941,7 +2941,7 @@ var generatedParamAliases = []ParamAliasEntry{ "uid": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role update", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 2ea26e92..9c1a4810 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -112,9 +112,7 @@ "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, - "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, - "chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, "doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true}, @@ -397,7 +395,6 @@ {"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"}, {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index b2cbdf17..5d702937 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -28,6 +28,25 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) +func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { + roleIDChanged := cmd.Flags().Changed("role-id") + roleIDsChanged := cmd.Flags().Changed("role-ids") + switch { + case roleIDChanged && roleIDsChanged: + return nil, apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + case roleIDChanged: + roleID := mustGetFlag(cmd, "role-id") + if roleID == "" { + return nil, apperrors.NewValidation("--role-id 不能为空") + } + return []string{roleID}, nil + case roleIDsChanged: + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + default: + return nil, apperrors.NewValidation("缺少必填参数 --role-id") + } +} + func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error { if deps.Caller.DryRun() { return callMCPToolOnServer("chat", toolName, args) @@ -7706,17 +7725,20 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", - Example: ` dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2 + Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } - roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) + roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + return err + } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), @@ -7751,13 +7773,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Selection: contract.SelectionSpec{ AgentSummary: "为指定群成员设置自定义角色", - UseWhen: []string{"需要把一个或多个已有角色分配给成员时"}, + UseWhen: []string{"需要把一个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, - Examples: []string{"dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role set-user --conversation-id --user --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, - {Name: "role-ids", Property: "openRoleIds"}, + {Name: "role-id", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, @@ -7767,8 +7789,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") - chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份") - _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids") + chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") + chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") + _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 118cc983..d0a3f8e5 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -243,6 +243,67 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing. } } +func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "public single role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, + want: []string{"r1"}, + }, + { + name: "hidden legacy role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"}, + want: []string{"r1", "r2"}, + }, + { + name: "hidden legacy empty role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := runChatCoverageCommand(t, caller, tc.args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("tool calls = %d, want 1", caller.calls) + } + if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2") + if err == nil { + t.Fatal("set-user accepted --role-id with --role-ids, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -293,7 +354,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, {"mark-read", "--conversation-id=cid", "--message-id=mid"}, {"text", "translate", "--query=hello", "--to=zh_CN"}, - {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"}, + {"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, {"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"}, {"group-role", "query-user", "--group=cid", "--user=D1"}, {"group", "set-admin", "--group=cid", "--users=u1,D1"}, @@ -523,7 +584,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"}, {name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"}, {name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"}, - {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"}, {name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, {name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"}, {name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"}, diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index a959874e..cd13be36 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -1882,7 +1882,7 @@ var ChatRoleSetUser = shortcut.Shortcut{ Command: "+chat-role-set-user", Product: "im", Description: "设置用户的群身份(覆盖该用户的全部群身份)", - Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。", + Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和一个 openRoleId。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -1904,22 +1904,29 @@ var ChatRoleSetUser = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "设置用户的群身份(覆盖该用户的全部群身份)", - UseWhen: []string{"当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。"}, + UseWhen: []string{"当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和一个 openRoleId。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws chat +chat-role-set-user --group --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat +chat-role-set-user --group --user --role-id "}, }, }, Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, - {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId 列表(空则清除全部)", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId"}, + {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "已隐藏的兼容参数:群身份 openRoleId 列表", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"role-id"}, Description: "公开入口必须提供一个非空群身份 openRoleId"}, + }, + Tips: []string{`dws chat +chat-role-set-user --group --user --role-id `}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateChatRoleSetUserRoleFlags(rt) }, - Tips: []string{`dws chat +chat-role-set-user --group --user --role-ids roleId1,roleId2`}, Execute: func(rt *shortcut.RuntimeContext) error { user := rt.Str("user") params := map[string]any{ "openConversationId": rt.Str("group"), - "openRoleIds": rt.StrSlice("role-ids"), + "openRoleIds": chatRoleSetUserRoleIDs(rt), } if isOpenID(user) { params["openDingTalkId"] = user @@ -1930,6 +1937,28 @@ var ChatRoleSetUser = shortcut.Shortcut{ }, } +func validateChatRoleSetUserRoleFlags(rt *shortcut.RuntimeContext) error { + roleIDChanged := rt.Changed("role-id") + roleIDsChanged := rt.Changed("role-ids") + switch { + case roleIDChanged && roleIDsChanged: + return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + case roleIDChanged && rt.Str("role-id") == "": + return apperrors.NewValidation("--role-id 不能为空") + case !roleIDChanged && !roleIDsChanged: + return apperrors.NewValidation("缺少必填参数 --role-id") + default: + return nil + } +} + +func chatRoleSetUserRoleIDs(rt *shortcut.RuntimeContext) []string { + if rt.Changed("role-id") { + return []string{rt.Str("role-id")} + } + return rt.StrSlice("role-ids") +} + // ChatRoleRemoveUser removes specific roles from a user (remove_custom_user_roles, im). var ChatRoleRemoveUser = shortcut.Shortcut{ Service: "chat", diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 24b322b9..5f8bd868 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -437,23 +437,23 @@ Flags: --role-id string 群身份 openRoleId,由 group-role list 返回 (必填) ``` -#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份) +#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份 ``` Usage: dws chat group-role set-user [flags] Example: - dws chat group-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group-role set-user --group --user --role-id # 查询人员: dws aisearch person --keyword "姓名" --dimension name # 查询 role-id: dws chat group-role list --group Flags: - --group string 群聊 openConversationId (必填) - --user string 用户 userId(必填) - --role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份 + --group string 群聊 openConversationId (必填) + --user string 用户 userId(必填) + --role-id string 群身份 openRoleId,由 group-role list 返回 ``` #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -2493,7 +2493,7 @@ Flags: - `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送 - `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) - `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10` -- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` +- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用 `--role-id` 覆盖某人的群身份,`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` - 消息**换行符**(`send` / `send-by-bot` / `send-by-webhook` 的 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行) - `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner` - `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久) From 2f056492778a3ea874ab82c185655b334dc6c289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 17:34:49 +0800 Subject: [PATCH 115/267] fix(skills): keep concurrent dest across inode reuse Linux overlayfs recycles device+inode, so SameFile and a lone inode token treated a replacement as owned and retracted it. Stamp staged inodes with an xattr mark, prove Linux/Darwin identity with birth time, and make shell copied-set rollback check dest first with inode plus child names. --- .changes/996-canonical-agent-skills.md | 2 +- internal/upgrade/skill_publication.go | 30 ++++++++++-- .../skill_publication_identity_darwin.go | 16 +++++-- .../skill_publication_identity_linux.go | 28 +++++++++-- .../upgrade/skill_publication_mark_other.go | 7 +++ .../upgrade/skill_publication_mark_unix.go | 40 ++++++++++++++++ scripts/install-skills.sh | 46 +++++++++++++++---- scripts/install.sh | 46 +++++++++++++++---- 8 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 internal/upgrade/skill_publication_mark_other.go create mode 100644 internal/upgrade/skill_publication_mark_unix.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index a0376676..c499f5ec 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Linux Go identity proof now uses a Statx token (inode plus birth/change time) because overlayfs recycles device+inode immediately and `os.SameFile` would otherwise treat a concurrent dest as owned and retract it; Shell copied-set rollback proves dest first with inode plus child names for the same reason. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index c1c6066a..508aa7ee 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -33,6 +33,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) } stagedFileID := skillPathFileIdentity(staged) + token, marked := markSkillPublication(staged) if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } @@ -48,10 +49,13 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("Skill 发布状态不确定:发布后无法登记目标 %s: %w;目标 %s 保留", destination, recErr, destination) } if publication.fingerprint != fingerprint { - if _, stagedErr := skillPathLstat(staged); os.IsNotExist(stagedErr) && - !skillPathIdentityProven(identity, publication.identity, stagedFileID, publication.fileID) { - // Dest is not the staged object: a concurrent writer won the path - // after the rename returned. Leave that object in place. + // Retract only dest this transaction still owns. Linux overlayfs + // recycles device+inode, so SameFile can be true for a concurrent + // replacement. The pre-rename xattr mark lives only on that inode; + // dest without the mark is left in place unless staged still exists + // (child-move created a fresh claim). Without a mark, fall back to + // identity proof plus the same staged-exists rule. + if !skillPublicationOwned(destination, staged, token, marked, identity, publication, stagedFileID) { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s: staging 内容已变化", destination) } return SkillPathPublication{}, retractUnconfirmedSkillPublication(publication, fmt.Errorf("确认已发布 Skill 身份失败 %s: staging 内容已变化", destination)) @@ -71,6 +75,24 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return publication, nil } +// skillPublicationOwned reports whether dest is still this transaction's +// object after a content mismatch. The pre-rename xattr mark lives only on +// the staged inode; dest without that mark is left in place unless staged +// still exists (child-move created a fresh claim). Without a mark, fall back +// to identity proof plus the same staged-exists rule. +func skillPublicationOwned(destination, staged, token string, marked bool, stagedIdentity os.FileInfo, publication SkillPathPublication, stagedFileID string) bool { + if marked && skillPublicationHasMark(destination, token) { + return true + } + if _, stagedErr := skillPathLstat(staged); stagedErr == nil { + return true + } + if marked { + return false + } + return skillPathIdentityProven(stagedIdentity, publication.identity, stagedFileID, publication.fileID) +} + // retractUnconfirmedSkillPublication withdraws a dest that this transaction // occupied but never handed to the caller. A failed retract names dest so a // later restore is not mistaken for a clean original layout. diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index b7eeead7..5503d47b 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -21,10 +21,18 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } -func skillPathFileIdentityImpl(_ string) string { - return "" +func skillPathFileIdentityImpl(path string) string { + var stat syscall.Stat_t + if err := syscall.Lstat(path, &stat); err != nil { + return "" + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) } -func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { - return skillPathSameFileIdentity(staged, published) +func skillPathIdentityProven(staged, published os.FileInfo, expected, actual string) bool { + if expected != "" || actual != "" { + return expected != "" && expected == actual + } + return skillPathSameFileIdentity(staged, published) && + skillPathFileIncarnation(staged) == skillPathFileIncarnation(published) } diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 88c88f35..c2dcc414 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "syscall" + + "golang.org/x/sys/unix" ) func skillPathFileIncarnationImpl(info os.FileInfo) string { @@ -20,10 +22,28 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } -func skillPathFileIdentityImpl(_ string) string { - return "" +// skillPathFileIdentityImpl records device, inode, and birth time. os.SameFile +// on Linux compares only device+inode; ext4/overlayfs recycle those numbers +// immediately, so a concurrent replacement can look like the staged object. +// ctime is omitted: writing a child updates the directory ctime and would +// make an owned dest look foreign. +func skillPathFileIdentityImpl(path string) string { + var stx unix.Statx_t + if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_SYMLINK_NOFOLLOW, unix.STATX_BASIC_STATS|unix.STATX_BTIME, &stx); err != nil { + return "" + } + if stx.Mask&unix.STATX_BTIME == 0 { + return "" + } + return fmt.Sprintf("%d:%d:%d:%d:%d", stx.Dev_major, stx.Dev_minor, stx.Ino, stx.Btime.Sec, stx.Btime.Nsec) } -func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { - return skillPathSameFileIdentity(staged, published) +// skillPathIdentityProven prefers the Statx token. An empty expected ID means +// identity cannot be proven and the caller must refuse auto-delete. +func skillPathIdentityProven(staged, published os.FileInfo, expected, actual string) bool { + if expected != "" || actual != "" { + return expected != "" && expected == actual + } + return skillPathSameFileIdentity(staged, published) && + skillPathFileIncarnation(staged) == skillPathFileIncarnation(published) } diff --git a/internal/upgrade/skill_publication_mark_other.go b/internal/upgrade/skill_publication_mark_other.go new file mode 100644 index 00000000..7a567fde --- /dev/null +++ b/internal/upgrade/skill_publication_mark_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package upgrade + +func markSkillPublication(string) (string, bool) { return "", false } + +func skillPublicationHasMark(string, string) bool { return false } diff --git a/internal/upgrade/skill_publication_mark_unix.go b/internal/upgrade/skill_publication_mark_unix.go new file mode 100644 index 00000000..356f5331 --- /dev/null +++ b/internal/upgrade/skill_publication_mark_unix.go @@ -0,0 +1,40 @@ +//go:build linux || darwin + +package upgrade + +import ( + "crypto/rand" + "encoding/hex" + + "golang.org/x/sys/unix" +) + +const skillPublicationXattr = "user.dws.skill-pub" + +// markSkillPublication stamps the staged inode with a unique xattr. The +// fingerprint hasher ignores xattrs, so the mark does not change content +// identity. After a same-inode rename the dest still carries the mark; a +// concurrent replacement that recycled the inode number does not. +func markSkillPublication(path string) (string, bool) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", false + } + token := hex.EncodeToString(raw) + if err := unix.Lsetxattr(path, skillPublicationXattr, []byte(token), 0); err != nil { + return "", false + } + return token, true +} + +func skillPublicationHasMark(path, token string) bool { + if token == "" { + return false + } + buf := make([]byte, 64) + n, err := unix.Lgetxattr(path, skillPublicationXattr, buf) + if err != nil || n != len(token) { + return false + } + return string(buf[:n]) == token +} diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 4287560c..6e67ec13 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -333,10 +333,12 @@ restore_quarantine_no_replace() { } # quarantine_retract_or_restore [expected-link-target] -# Claims dest into a sibling quarantine, re-checks identity there, deletes -# only on match, and restores with a no-replace publish on mismatch. An -# in-place inode check followed by rm would delete a concurrent replacement -# that won dest after the check. +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. quarantine_retract_or_restore() { _qrr_dest="$1" _qrr_inode="$2" @@ -344,6 +346,20 @@ quarantine_retract_or_restore() { if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then return 0 fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi _qrr_parent="$(dirname "$_qrr_dest")" _qrr_base="$(basename "$_qrr_dest")" _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 @@ -363,7 +379,7 @@ quarantine_retract_or_restore() { [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then _qrr_owned=1 fi - elif [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then _qrr_owned=1 fi if [ "$_qrr_owned" -eq 1 ]; then @@ -418,6 +434,20 @@ skill_link_inode() { printf '%s\n' "$_sli_entry" | awk '{print $1}' } +# Directory publication identity is inode plus a stable child-name list. +# Inode reuse on Linux overlayfs would otherwise make a concurrent directory +# look owned. The token has no colon so dest:identity manifest parsing stays +# dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + printf '%s,%s\n' "$_sdi_inode" "$_sdi_names" +} + skill_link_matches() { [ -L "$1" ] || return 1 _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 @@ -459,8 +489,8 @@ cleanup_nested_staged_link() { # staging directory is created under the same umask as the claim, so their # modes match by construction). A failed child move relocates the children # back and removes only the claim. On success the destination's inode is -# recorded as : so rollback only ever deletes this transaction's -# object. +# recorded as : so rollback only ever deletes this +# transaction's object even when the filesystem recycles the claim inode. publish_skill_dir_no_replace() { _psd_stage="$1" _psd_dest="$2" @@ -478,7 +508,7 @@ publish_skill_dir_no_replace() { fi done if [ "$_psd_failed" -eq 0 ]; then - _psd_inode="$(skill_link_inode "$_psd_dest")" || _psd_failed=1 + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then return 0 fi diff --git a/scripts/install.sh b/scripts/install.sh index 33006272..aa511c5c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -352,10 +352,12 @@ restore_quarantine_no_replace() { } # quarantine_retract_or_restore [expected-link-target] -# Claims dest into a sibling quarantine, re-checks identity there, deletes -# only on match, and restores with a no-replace publish on mismatch. An -# in-place inode check followed by rm would delete a concurrent replacement -# that won dest after the check. +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. quarantine_retract_or_restore() { _qrr_dest="$1" _qrr_inode="$2" @@ -363,6 +365,20 @@ quarantine_retract_or_restore() { if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then return 0 fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi _qrr_parent="$(dirname "$_qrr_dest")" _qrr_base="$(basename "$_qrr_dest")" _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 @@ -382,7 +398,7 @@ quarantine_retract_or_restore() { [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then _qrr_owned=1 fi - elif [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then _qrr_owned=1 fi if [ "$_qrr_owned" -eq 1 ]; then @@ -438,6 +454,20 @@ skill_link_inode() { printf '%s\n' "$_sli_entry" | awk '{print $1}' } +# Directory publication identity is inode plus a stable child-name list. +# Inode reuse on Linux overlayfs would otherwise make a concurrent directory +# look owned. The token has no colon so dest:identity manifest parsing stays +# dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + printf '%s,%s\n' "$_sdi_inode" "$_sdi_names" +} + skill_link_matches() { [ -L "$1" ] || return 1 _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 @@ -479,8 +509,8 @@ cleanup_nested_staged_link() { # staging directory is created under the same umask as the claim, so their # modes match by construction). A failed child move relocates the children # back and removes only the claim. On success the destination's inode is -# recorded as : so rollback only ever deletes this transaction's -# object. +# recorded as : so rollback only ever deletes this +# transaction's object even when the filesystem recycles the claim inode. publish_skill_dir_no_replace() { _psd_stage="$1" _psd_dest="$2" @@ -498,7 +528,7 @@ publish_skill_dir_no_replace() { fi done if [ "$_psd_failed" -eq 0 ]; then - _psd_inode="$(skill_link_inode "$_psd_dest")" || _psd_failed=1 + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then return 0 fi From 26638cbd98f42ddde5164d234138c960a8700754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Tue, 18 Aug 2026 17:20:34 +0800 Subject: [PATCH 116/267] fix(chat): complete group role set-user flag compatibility --- internal/helpers/chat.go | 1 + internal/shortcut/chat/chat_group.go | 28 ++++++++++++++++++------- skills/mono/references/products/chat.md | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 5d702937..043418f3 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -7792,6 +7792,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") + corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index cd13be36..f2bb5a4c 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -1912,8 +1912,7 @@ var ChatRoleSetUser = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, - {Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId"}, - {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "已隐藏的兼容参数:群身份 openRoleId 列表", Hidden: true}, + {Name: "role-id", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId;公开入口必须提供一个非空群身份 openRoleId", Aliases: []string{"role-ids"}}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"role-id"}, Description: "公开入口必须提供一个非空群身份 openRoleId"}, @@ -1943,22 +1942,37 @@ func validateChatRoleSetUserRoleFlags(rt *shortcut.RuntimeContext) error { switch { case roleIDChanged && roleIDsChanged: return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") - case roleIDChanged && rt.Str("role-id") == "": - return apperrors.NewValidation("--role-id 不能为空") + case roleIDChanged: + roleIDs := compactChatRoleSetUserRoleIDs(rt.StrSlice("role-id")) + if len(roleIDs) == 0 { + return apperrors.NewValidation("--role-id 不能为空") + } + if len(roleIDs) > 1 { + return apperrors.NewValidation("--role-id 只允许指定一个群身份") + } case !roleIDChanged && !roleIDsChanged: return apperrors.NewValidation("缺少必填参数 --role-id") - default: - return nil } + return nil } func chatRoleSetUserRoleIDs(rt *shortcut.RuntimeContext) []string { if rt.Changed("role-id") { - return []string{rt.Str("role-id")} + return compactChatRoleSetUserRoleIDs(rt.StrSlice("role-id")) } return rt.StrSlice("role-ids") } +func compactChatRoleSetUserRoleIDs(values []string) []string { + out := values[:0] + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + // ChatRoleRemoveUser removes specific roles from a user (remove_custom_user_roles, im). var ChatRoleRemoveUser = shortcut.Shortcut{ Service: "chat", diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 5f8bd868..9200dc16 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -2456,7 +2456,7 @@ Flags: | `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 | | `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file-path` | | `chat message list` | `openMsgId` | message read-status 的 --message-id | -| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id | +| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids | | `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | | `chat category list` | `categoryId` | category list-conversations 的 --category-id | | `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId | From 91d2e2992513fb4240316d6946857953907156ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 17:50:39 +0800 Subject: [PATCH 117/267] test(skills): cover publication mark ownership branches Windows coverage gate only runs TestCrossPlatformCoverage*, and the xattr mark helpers are Unix-only. Inject seams so marked dest is retracted on owned drift, left in place when the mark is gone, and the helper error paths are exercised on every platform. --- internal/upgrade/skill_path_move.go | 48 +++++------ internal/upgrade/skill_publication.go | 4 +- internal/upgrade/skill_publication_test.go | 93 ++++++++++++++++++++++ 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 11bccab2..8ec10a46 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -11,29 +11,31 @@ import ( ) var ( - skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } - skillPathRenameNoReplace = renameSkillPathNoReplace - skillPathCopy = copySkillPathLexically - skillPathVerify = verifySkillPathCopy - skillPathRemoveAll = os.RemoveAll - skillPathMkdirAll = os.MkdirAll - skillPathMkdirTemp = os.MkdirTemp - skillPathChmod = os.Chmod - skillPathLstat = os.Lstat - skillPathMkdir = os.Mkdir - skillPathLink = os.Link - skillPathRemove = os.Remove - skillPathReadDir = os.ReadDir - skillPathFileIdentity = skillPathFileIdentityImpl - skillPathFileIncarnation = skillPathFileIncarnationImpl - skillPathSameFileIdentity = skillPathSameFileIdentityImpl - skillPathReadlink = os.Readlink - skillPathSymlink = os.Symlink - skillPathOpen = os.Open - skillPathOpenFile = os.OpenFile - skillPathCopyBytes = io.Copy - skillPathSync = func(file *os.File) error { return file.Sync() } - skillPathWalkDir = filepath.WalkDir + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove + skillPathReadDir = os.ReadDir + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathSameFileIdentity = skillPathSameFileIdentityImpl + skillPathMarkPublication = markSkillPublication + skillPathPublicationHasMark = skillPublicationHasMark + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir ) // moveSkillPathRecoverably moves one managed Skill path without weakening the diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 508aa7ee..1ba6ba0e 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -33,7 +33,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) } stagedFileID := skillPathFileIdentity(staged) - token, marked := markSkillPublication(staged) + token, marked := skillPathMarkPublication(staged) if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } @@ -81,7 +81,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication // still exists (child-move created a fresh claim). Without a mark, fall back // to identity proof plus the same staged-exists rule. func skillPublicationOwned(destination, staged, token string, marked bool, stagedIdentity os.FileInfo, publication SkillPathPublication, stagedFileID string) bool { - if marked && skillPublicationHasMark(destination, token) { + if marked && skillPathPublicationHasMark(destination, token) { return true } if _, stagedErr := skillPathLstat(staged); stagedErr == nil { diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 6d103eae..2e6adb2a 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -298,6 +298,78 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { assertUpgradeSkillContent(t, destination, "value") }) + t.Run("marked dest without mark and staged gone is left in place", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathMarkPublication, func(string) (string, bool) { return "tok", true }) + testseam.Swap(t, &skillPathPublicationHasMark, func(string, string) bool { return false }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + if err := os.RemoveAll(target); err != nil { + return err + } + seedUpgradeSkill(t, target, "replacement", false) + return nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") || strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("unmarked replacement must stay on dest, got %v", err) + } + assertUpgradeSkillContent(t, destination, "replacement") + }) + + t.Run("marked dest without mark but staged remains retracts dest", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathMarkPublication, func(string) (string, bool) { return "tok", true }) + testseam.Swap(t, &skillPathPublicationHasMark, func(string, string) bool { return false }) + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + if err := os.Rename(filepath.Join(source, "SKILL.md"), filepath.Join(target, "SKILL.md")); err != nil { + return err + } + return os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("mutated\n"), 0o644) + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "staging 内容已变化") || !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("child-move dest still owned via staged leftover must retract, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("child-move dest must be retracted: %v", statErr) + } + }) + + t.Run("marked dest with mark retracts on content drift", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + testseam.Swap(t, &skillPathMarkPublication, func(string) (string, bool) { return "tok", true }) + testseam.Swap(t, &skillPathPublicationHasMark, func(_, token string) bool { return token == "tok" }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + return os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("mutated\n"), 0o644) + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "staging 内容已变化") || !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("marked owned dest drift must retract dest, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("marked mutated dest must be retracted: %v", statErr) + } + }) + t.Run("owned dest content drift after publish retracts dest", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") @@ -360,6 +432,27 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { assertUpgradeSkillContent(t, destination, "value") }) + t.Run("publication mark helpers", func(t *testing.T) { + if skillPublicationHasMark("", "tok") || skillPublicationHasMark(filepath.Join(t.TempDir(), "missing"), "tok") || skillPublicationHasMark(t.TempDir(), "") { + t.Fatal("missing dest must not report a publication mark") + } + if token, ok := markSkillPublication(filepath.Join(t.TempDir(), "missing")); ok || token != "" { + t.Fatal("missing dest must not accept a publication mark") + } + dir := t.TempDir() + seedUpgradeSkill(t, dir, "value", false) + token, ok := markSkillPublication(dir) + if !ok { + if skillPublicationHasMark(dir, "tok") { + t.Fatal("unmarked dest must not report a publication mark") + } + return + } + if !skillPublicationHasMark(dir, token) || skillPublicationHasMark(dir, "other") { + t.Fatal("publication mark must match only the token written on dest") + } + }) + t.Run("record published identity", func(t *testing.T) { if _, err := recordSkillPathPublication(filepath.Join(t.TempDir(), "missing")); err == nil || !strings.Contains(err.Error(), "读取已发布 Skill 身份失败") { t.Fatalf("missing record identity error = %v", err) From a192e988c4f5980bac8df98cd33eb23ee3171cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 19:33:51 +0800 Subject: [PATCH 118/267] fix(skills): refuse unplanned dests in copy fallback --- internal/app/skill_setup.go | 39 +++- internal/app/skill_setup_canonical_test.go | 221 +++++++++++++++++++++ 2 files changed, 254 insertions(+), 6 deletions(-) diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 7a615734..7d58958b 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -158,8 +158,12 @@ type skillSetupTargetPlan struct { Destination string CanonicalBase string Backups []skillSetupBackup - CleanupOnly bool - LinkCanonical bool + // OmittedCanonical lists replacement dests left out of Backups because + // they already resolved to canonical at plan time. Only these may be + // re-added by the copy fallback. + OmittedCanonical []string + CleanupOnly bool + LinkCanonical bool } type skillSetupPlan struct { @@ -1314,6 +1318,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } for _, path := range replacements { if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + if !seen[path] { + target.OmittedCanonical = append(target.OmittedCanonical, path) + } continue } _, statErr := skillSetupLstat(path) @@ -1326,6 +1333,7 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt add(path, skillSetupBackupReplace) } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) + sort.Strings(target.OmittedCanonical) if !target.CleanupOnly || len(target.Backups) > 0 { plan.Targets = append(plan.Targets, target) } @@ -2065,16 +2073,36 @@ const ( // direct-copy target. buildSkillSetupPlan omits the replacement backup for a // destination that already points at canonical, because linking would leave it // untouched. A copy must replace that destination instead, and -// PublishSkillPathNoReplace refuses to overwrite it, so those paths are -// re-added before the retry. +// PublishSkillPathNoReplace refuses to overwrite it, so exactly those plan-time +// omitted canonical links are re-added — and only while they still resolve to +// canonical. This is not a live rescan of every dest: anything else that exists +// now was absent from or has changed since the confirmed plan, so the fallback +// aborts and the destination is left for a regenerated, reconfirmed plan. func skillSetupCopyFallbackTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (skillSetupTargetPlan, error) { fallback := target fallback.LinkCanonical = false fallback.Backups = append([]skillSetupBackup(nil), target.Backups...) + fallback.OmittedCanonical = nil seen := make(map[string]bool, len(fallback.Backups)) for _, backup := range fallback.Backups { seen[backup.Path] = true } + for _, path := range target.OmittedCanonical { + if seen[path] { + continue + } + if _, err := skillSetupLstat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return skillSetupTargetPlan{}, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, err) + } + if !samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + return skillSetupTargetPlan{}, fmt.Errorf("确认后 Skill 目录已不再指向 canonical,需重新生成并确认安装计划 %s", path) + } + seen[path] = true + fallback.Backups = append(fallback.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } replacements := []string{target.Destination} if plan.Mode == skillSetupModeMulti { replacements = make([]string, 0, len(plan.MultiSkillNames)) @@ -2092,8 +2120,7 @@ func skillSetupCopyFallbackTarget(plan *skillSetupPlan, target skillSetupTargetP } return skillSetupTargetPlan{}, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, err) } - seen[path] = true - fallback.Backups = append(fallback.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + return skillSetupTargetPlan{}, fmt.Errorf("确认后同名 Skill 目录被占用,需重新生成并确认安装计划 %s", path) } sort.Slice(fallback.Backups, func(i, j int) bool { return fallback.Backups[i].Path < fallback.Backups[j].Path }) return fallback, nil diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 246739f2..92889aff 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -474,6 +474,227 @@ func TestCrossPlatformCoverageSkillSetupCopyFallbackReportsUnreadableReplacement } } +func TestCrossPlatformCoverageSkillSetupCopyFallbackRefusesDestCreatedAfterConfirm(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + // The Claude destination does not exist at plan time, so the confirmed + // plan records neither a backup nor an omitted canonical link for it. + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + var claudePlan *skillSetupTargetPlan + for i := range plan.Targets { + if plan.Targets[i].Destination == claude { + claudePlan = &plan.Targets[i] + } + } + if claudePlan == nil { + t.Fatal("claude target missing from plan") + } + dest := filepath.Join(claude, "dingtalk-chat") + if len(claudePlan.Backups) != 0 || len(claudePlan.OmittedCanonical) != 0 { + t.Fatalf("claude backups = %#v omitted = %#v", claudePlan.Backups, claudePlan.OmittedCanonical) + } + // A concurrent writer occupies the destination after the user confirmed. + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("concurrent"), 0o644); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil { + t.Fatalf("execute err = %v; stderr=%s", err, errOut.String()) + } + if installed != 1 || skipped != 1 { + t.Fatalf("execute = installed %d skipped %d; stderr=%s", installed, skipped, errOut.String()) + } + if !strings.Contains(errOut.String(), "确认后同名 Skill 目录被占用,需重新生成并确认安装计划") { + t.Fatalf("reconfirm guidance missing: %s", errOut.String()) + } + body, readErr := os.ReadFile(filepath.Join(dest, "SKILL.md")) + if readErr != nil || string(body) != "concurrent" { + t.Fatalf("concurrent dest disturbed: %q, %v", body, readErr) + } + if strings.Contains(out.String(), "已备份并移除同名 Skill "+dest) { + t.Fatalf("unplanned dest was backed up: %s", out.String()) + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackReconfirmBacksUpNewDest(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + // The destination exists before the plan is built, so a regenerated plan + // lists it as a confirmed replacement backup. + dest := filepath.Join(claude, "dingtalk-chat") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + var claudePlan *skillSetupTargetPlan + for i := range plan.Targets { + if plan.Targets[i].Destination == claude { + claudePlan = &plan.Targets[i] + } + } + if claudePlan == nil { + t.Fatal("claude target missing from plan") + } + planned := map[string]bool{} + for _, backup := range claudePlan.Backups { + planned[backup.Path] = true + } + if !planned[dest] { + t.Fatalf("regenerated plan does not back up %s: %#v", dest, claudePlan.Backups) + } + + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("fallback message missing: %s", errOut.String()) + } + // Every backup the run performed must be one the confirmed plan listed. + lastBackup := "" + for _, line := range strings.Split(out.String(), "\n") { + if !strings.HasPrefix(line, " × 已备份并移除同名 Skill ") { + continue + } + fields := strings.SplitN(strings.TrimPrefix(line, " × 已备份并移除同名 Skill "), " → ", 2) + if len(fields) != 2 || !planned[fields[0]] { + t.Fatalf("unplanned backup %q; plan = %#v", line, claudePlan.Backups) + } + if fields[0] == dest { + lastBackup = fields[1] + } + } + if lastBackup == "" { + t.Fatalf("dest never backed up: %s", out.String()) + } + body, readErr := os.ReadFile(filepath.Join(lastBackup, "SKILL.md")) + if readErr != nil || string(body) != "stale" { + t.Fatalf("backup payload = %q, %v", body, readErr) + } + body, readErr = os.ReadFile(filepath.Join(dest, "SKILL.md")) + if readErr != nil || string(body) != "chat" { + t.Fatalf("published payload = %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackAbortsWhenOmittedLinkNoLongerCanonical(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonical, "dingtalk-chat", "SKILL.md"), []byte("canonical"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + dest := filepath.Join(claude, "dingtalk-chat") + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-chat"), dest); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + var claudePlan *skillSetupTargetPlan + for i := range plan.Targets { + if plan.Targets[i].Destination == claude { + claudePlan = &plan.Targets[i] + } + } + if claudePlan == nil { + t.Fatal("claude target missing from plan") + } + if len(claudePlan.OmittedCanonical) != 1 || claudePlan.OmittedCanonical[0] != dest { + t.Fatalf("omitted canonical = %#v", claudePlan.OmittedCanonical) + } + // After confirmation the link is replaced by unrelated content, so the + // fallback must not treat it as the planned canonical link anymore. + if err := os.Remove(dest); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("hijack"), 0o644); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil { + t.Fatalf("execute err = %v; stderr=%s", err, errOut.String()) + } + if installed != 1 || skipped != 1 { + t.Fatalf("execute = installed %d skipped %d; stderr=%s", installed, skipped, errOut.String()) + } + if !strings.Contains(errOut.String(), "确认后 Skill 目录已不再指向 canonical,需重新生成并确认安装计划") { + t.Fatalf("reconfirm guidance missing: %s", errOut.String()) + } + body, readErr := os.ReadFile(filepath.Join(dest, "SKILL.md")) + if readErr != nil || string(body) != "hijack" { + t.Fatalf("hijacked dest disturbed: %q, %v", body, readErr) + } + if strings.Contains(out.String(), "已备份并移除同名 Skill "+dest) { + t.Fatalf("unplanned dest was backed up: %s", out.String()) + } +} + func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) From a3566f39c43f39146be74c37529247962f902090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 19:33:56 +0800 Subject: [PATCH 119/267] test(skills): cover unix publication identity fallbacks --- internal/upgrade/skill_path_move.go | 2 + .../upgrade/skill_publication_mark_unix.go | 3 +- internal/upgrade/skill_publication_test.go | 53 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 8ec10a46..80e92255 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -1,6 +1,7 @@ package upgrade import ( + "crypto/rand" "crypto/sha256" "errors" "fmt" @@ -29,6 +30,7 @@ var ( skillPathSameFileIdentity = skillPathSameFileIdentityImpl skillPathMarkPublication = markSkillPublication skillPathPublicationHasMark = skillPublicationHasMark + skillPathRandomRead = rand.Read skillPathReadlink = os.Readlink skillPathSymlink = os.Symlink skillPathOpen = os.Open diff --git a/internal/upgrade/skill_publication_mark_unix.go b/internal/upgrade/skill_publication_mark_unix.go index 356f5331..99f3bebf 100644 --- a/internal/upgrade/skill_publication_mark_unix.go +++ b/internal/upgrade/skill_publication_mark_unix.go @@ -3,7 +3,6 @@ package upgrade import ( - "crypto/rand" "encoding/hex" "golang.org/x/sys/unix" @@ -17,7 +16,7 @@ const skillPublicationXattr = "user.dws.skill-pub" // concurrent replacement that recycled the inode number does not. func markSkillPublication(path string) (string, bool) { raw := make([]byte, 16) - if _, err := rand.Read(raw); err != nil { + if _, err := skillPathRandomRead(raw); err != nil { return "", false } token := hex.EncodeToString(raw) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 2e6adb2a..9ee376e1 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -370,6 +371,51 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + t.Run("unmarked publication falls back to identity proof", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + // Filesystems without usable xattrs leave the transaction unmarked; + // ownership must then fall back to inode/file-ID identity. + testseam.Swap(t, &skillPathMarkPublication, func(string) (string, bool) { return "", false }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { + if err := originalRename(source, target); err != nil { + return err + } + return os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("mutated\n"), 0o644) + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if err == nil || !strings.Contains(err.Error(), "staging 内容已变化") || !strings.Contains(err.Error(), "目标已撤回") { + t.Fatalf("unmarked identity-proven drift must retract dest, got %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("identity-proven dest must be retracted: %v", statErr) + } + }) + + t.Run("identity proof falls back without file IDs", func(t *testing.T) { + dir := t.TempDir() + info, err := os.Lstat(dir) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS == "windows" { + if skillPathIdentityProven(info, info, "", "") { + t.Fatal("windows must refuse identity proof without file IDs") + } + } else if !skillPathIdentityProven(info, info, "", "") { + t.Fatal("same object must stay proven without file IDs") + } + if skillPathIdentityProven(info, info, "", "id") || skillPathIdentityProven(info, info, "id", "") { + t.Fatal("mixed empty and non-empty file IDs must not count as proven") + } + if !skillPathIdentityProven(info, info, "id", "id") { + t.Fatal("equal non-empty file IDs must be proven") + } + }) + t.Run("owned dest content drift after publish retracts dest", func(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "staged") @@ -439,6 +485,9 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if token, ok := markSkillPublication(filepath.Join(t.TempDir(), "missing")); ok || token != "" { t.Fatal("missing dest must not accept a publication mark") } + if skillPathFileIdentity(filepath.Join(t.TempDir(), "missing")) != "" { + t.Fatal("missing dest must not report a file identity") + } dir := t.TempDir() seedUpgradeSkill(t, dir, "value", false) token, ok := markSkillPublication(dir) @@ -451,6 +500,10 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if !skillPublicationHasMark(dir, token) || skillPublicationHasMark(dir, "other") { t.Fatal("publication mark must match only the token written on dest") } + testseam.Swap(t, &skillPathRandomRead, func([]byte) (int, error) { return 0, errors.New("entropy unavailable") }) + if token, ok := markSkillPublication(dir); ok || token != "" { + t.Fatal("entropy failure must refuse to mark dest") + } }) t.Run("record published identity", func(t *testing.T) { From d11e69fbdb93166088c8671ab4d5b1479a6e2941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 19:41:33 +0800 Subject: [PATCH 120/267] test(skills): cover copy fallback dest scan branches --- internal/app/skill_setup_canonical_test.go | 124 +++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 92889aff..2e3af949 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -695,6 +695,130 @@ func TestCrossPlatformCoverageSkillSetupCopyFallbackAbortsWhenOmittedLinkNoLonge } } +func TestCrossPlatformCoverageSkillSetupCopyFallbackDedupesOmittedCanonicalBackup(t *testing.T) { + home := t.TempDir() + claude := filepath.Join(home, ".claude", "skills") + dest := filepath.Join(claude, "dingtalk-chat") + plan := &skillSetupPlan{ + Mode: skillSetupModeMulti, + MultiSkillNames: []string{"dingtalk-chat"}, + } + target := skillSetupTargetPlan{ + Destination: claude, + LinkCanonical: true, + Backups: []skillSetupBackup{{Path: dest, Reason: skillSetupBackupReplace}}, + OmittedCanonical: []string{dest}, + } + fallback, err := skillSetupCopyFallbackTarget(plan, target) + if err != nil { + t.Fatalf("converter error = %v", err) + } + if fallback.LinkCanonical { + t.Fatal("fallback must install a direct copy") + } + if len(fallback.Backups) != 1 || fallback.Backups[0].Path != dest { + t.Fatalf("fallback backups = %#v, want exactly %s once", fallback.Backups, dest) + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackSkipsRemovedOmittedLink(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonical, "dingtalk-chat", "SKILL.md"), []byte("canonical"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + dest := filepath.Join(claude, "dingtalk-chat") + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-chat"), dest); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + // The confirmed omitted link disappears before the fallback runs; the + // converter must treat the missing dest as nothing to back up and the + // copy retry must publish a fresh directory. + if err := os.Remove(dest); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + info, statErr := os.Lstat(dest) + if statErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("republished dest = %#v, %v", info, statErr) + } + body, readErr := os.ReadFile(filepath.Join(dest, "SKILL.md")) + if readErr != nil || string(body) != "chat" { + t.Fatalf("republished payload = %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCopyFallbackReportsUnplannedUnreadableDest(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + rejectSymlinkPublish(t) + dest := filepath.Join(claude, "dingtalk-chat") + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, false) + if err != nil { + t.Fatal(err) + } + // The destination is absent from the confirmed plan, so the converter's + // final rescan has to stat it; an error other than "not exist" must abort + // the retry instead of guessing. + testseam.Swap(t, &skillSetupLstat, func(path string) (os.FileInfo, error) { + if path == dest { + return nil, os.ErrPermission + } + return os.Lstat(path) + }) + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 1 || skipped != 1 { + t.Fatalf("execute = installed %d skipped %d err %v; stderr=%s", installed, skipped, err, errOut.String()) + } + if !strings.Contains(errOut.String(), "检查将被替换的 Skill 失败") { + t.Fatalf("converter failure not reported: %s", errOut.String()) + } + if _, statErr := os.Lstat(dest); !os.IsNotExist(statErr) { + t.Fatalf("unreadable dest must be left alone: %v", statErr) + } +} + func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) From 78086734312b16070052aebc5cd53909f1e162a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Tue, 18 Aug 2026 20:31:07 +0800 Subject: [PATCH 121/267] fix(doc): align media receipt contract --- skills/multi/dingtalk-doc/references/doc/doc-media.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/multi/dingtalk-doc/references/doc/doc-media.md b/skills/multi/dingtalk-doc/references/doc/doc-media.md index 54767fb3..87a5e694 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-media.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-media.md @@ -31,7 +31,7 @@ dws doc +background-delete --node --format json ## 稳定 ID 与结果 - `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。 -- 插入后使用回执中的 `insertedBlockId/affectedBlockIds` 定位;`insertedBlockId` 是媒体容器,不是图后空块。只有 `position.followingBlockExists=true` 时才删除真实 `followingBlockId`;为 `false` 时不得删除媒体容器。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。 +- 插入成功回执在 `data.blockId` 返回已回读验证的媒体块 ID;后续定位只复用该 `blockId`。回执不提供相邻空块字段,不得据此猜测或自动删除其他块。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。 - 下载输出只接受工作目录内相对路径,默认 no-clobber。 - 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。 From 6a4803d85ac5dcffe98e203c0c59c8f3b09b07bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 20:42:01 +0800 Subject: [PATCH 122/267] fix(skills): verify backup ownership marker before pruning --- build/npm/install.js | 50 +++- internal/upgrade/paths.go | 44 +++- internal/upgrade/paths_multi_test.go | 131 +++++++++-- scripts/install-devapp.ps1 | 53 ++++- scripts/install-devapp.sh | 24 +- scripts/install-event.sh | 24 +- scripts/install-skills.sh | 30 ++- scripts/install.ps1 | 56 ++++- scripts/install.sh | 30 ++- test/scripts/install_js_smoke.mjs | 101 ++++++++- test/scripts/install_script_test.go | 290 +++++++++++++++++++++++- test/scripts/skill_backup_prune_test.go | 57 ++++- 12 files changed, 815 insertions(+), 75 deletions(-) diff --git a/build/npm/install.js b/build/npm/install.js index 882a17c5..ac74bb8d 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -163,6 +163,31 @@ const SKILL_BACKUP_KEEP = 5; // transaction still needs them to roll back. const currentRunBackupRoots = new Set(); +// Ownership marker: every stamp root DWS creates carries .dws-skill-backup +// with exactly these bytes — the same bytes internal/upgrade/paths.go, +// install.sh, and the PowerShell installers write. A stamp-shaped name alone +// is not ownership proof, so pruning only deletes directories whose marker +// content matches byte-for-byte (cross-surface exact-LF contract; a +// PowerShell writer's hypothetical CRLF is normalized away on that surface, +// while every writer here emits LF). +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +// writeSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +function writeSkillBackupMarker(root) { + fs.writeFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + +// isProvenSkillBackupRoot reports whether a stamp root carries the ownership +// marker with the exact expected bytes; anything else is foreign data. +function isProvenSkillBackupRoot(root) { + try { + return fs.readFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), "utf8") === SKILL_BACKUP_MARKER_BODY; + } catch { + return false; + } +} + // skillBackupStampPattern matches only directory names DWS itself writes: // UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry // in the backup root is foreign (user data, unrelated tooling) and must be @@ -175,9 +200,10 @@ function isSkillBackupStamp(name) { // pruneSkillBackups removes the oldest stamped backup directories once more // than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS -// backup stamp format are candidates; unknown directories are preserved. -// Stamps sort lexicographically in chronological order (`YYYYmmdd-HHMMSS`), -// so name order is age order. +// backup stamp format AND whose ownership marker verifies are candidates; +// unknown or unmarked directories are foreign data — preserved and never +// counted against SKILL_BACKUP_KEEP. Stamps sort lexicographically in +// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. function pruneSkillBackups(homeDir) { const root = path.join(homeDir, ".dws", "skill-backups"); let entries; @@ -189,6 +215,7 @@ function pruneSkillBackups(homeDir) { } const names = entries .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) + .filter((entry) => isProvenSkillBackupRoot(path.join(root, entry.name))) .map((entry) => entry.name) .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); let excess = names.length - SKILL_BACKUP_KEEP; @@ -587,6 +614,23 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { } try { fs.mkdirSync(targetRoot, { recursive: true }); + // Stamp ownership immediately after creating the stamp root and before + // the skill directory moves into it, so an interrupted backup can never + // leave an unmarked (never-prunable) stamp behind. + writeSkillBackupMarker(targetRoot); + } catch (err) { + // Never leave an empty unowned stamp root behind. The removal is + // deliberately non-recursive so a pre-existing non-empty root (foreign + // data) is never destroyed. + try { + fs.rmSync(targetRoot, { force: true }); + } catch { + // Non-empty pre-existing root: foreign data stays. + } + console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); + } + try { movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 349b156c..88df2324 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -151,6 +151,8 @@ var ( upgradeMkdirAll = os.MkdirAll upgradeMkdirTemp = os.MkdirTemp upgradeReadDir = os.ReadDir + upgradeReadFile = os.ReadFile + upgradeWriteFile = os.WriteFile upgradeStat = os.Stat upgradeLstat = os.Lstat upgradeSymlink = os.Symlink @@ -172,6 +174,28 @@ var ( // ask for confirmation, so deletions must stay reversible instead. const skillBackupSubdir = ".dws/skill-backups" +// skillBackupMarkerFile is the ownership proof written into every stamp root +// DWS creates. A stamp-shaped name alone is not evidence: users and other +// tools can legitimately write `YYYYMMDD-HHMMSS` directories into the backup +// root, so pruning only deletes directories whose marker content matches. +// Installers on every surface (Go, sh, PowerShell, npm) write and verify the +// exact same bytes. +const skillBackupMarkerFile = ".dws-skill-backup" + +const skillBackupMarkerBody = "dws skill backup v1\n" + +// writeSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +func writeSkillBackupMarker(root string) error { + return upgradeWriteFile(filepath.Join(root, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) +} + +// isProvenSkillBackupRoot reports whether root carries the DWS ownership +// marker with the exact expected content. +func isProvenSkillBackupRoot(root string) bool { + body, err := upgradeReadFile(filepath.Join(root, skillBackupMarkerFile)) + return err == nil && string(body) == skillBackupMarkerBody +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it, and returns the backup path. It is // fail-safe: a path that cannot be backed up is NOT removed and the @@ -212,6 +236,10 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } + if err := writeSkillBackupMarker(backupRoot); err != nil { + _ = upgradeRemoveAll(backupRoot) + return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err) + } if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } @@ -422,9 +450,11 @@ func isSkillBackupRunRoot(path string) bool { // pruneSkillBackups removes the oldest backup directories when more than // skillBackupKeep remain. Only directories whose names match the DWS backup -// stamp format are candidates; unknown directories and stamps created by the -// current run are preserved. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// stamp format AND whose ownership marker verifies are candidates; a +// stamp-shaped name alone is not proof of ownership, so unmarked directories +// are neither counted nor deleted, and stamps created by the current run are +// preserved. Best-effort: a removal failure never aborts, but pruning +// failures are reported so callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -436,9 +466,13 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() && isSkillBackupStamp(e.Name()) { - names = append(names, e.Name()) + if !e.IsDir() || !isSkillBackupStamp(e.Name()) { + continue } + if !isProvenSkillBackupRoot(filepath.Join(root, e.Name())) { + continue + } + names = append(names, e.Name()) } sort.Strings(names) var firstErr error diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ddd0f238..2790bdf3 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -824,6 +824,17 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) } +// seedSkillBackupStamp creates a prune-eligible DWS backup stamp directory. +func seedSkillBackupStamp(t *testing.T, root, name string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, name, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644); err != nil { + t.Fatal(err) + } +} + // TestCrossPlatformCoveragePruneKeepsCurrentRunBackups pins the reversibility // guarantee. The backup stamp only has second precision, so a migration that // retires copies across many Agent roots produces far more than @@ -834,9 +845,7 @@ func TestCrossPlatformCoveragePruneKeepsCurrentRunBackups(t *testing.T) { root := filepath.Join(home, skillBackupSubdir) // Pre-existing backups from earlier runs are still prunable. for i := 0; i < 3; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20250101-00000%d", i)), 0o755); err != nil { - t.Fatal(err) - } + seedSkillBackupStamp(t, root, fmt.Sprintf("20250101-00000%d", i)) } stamp := 0 testseam.Swap(t, &upgradeBackupStamp, func() string { @@ -868,7 +877,7 @@ func TestCrossPlatformCoveragePruneKeepsCurrentRunBackups(t *testing.T) { for i := 0; i < 3; i++ { stale := filepath.Join(root, fmt.Sprintf("20250101-00000%d", i)) if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Errorf("foreign backup %s must still be pruned, stat err=%v", stale, err) + t.Errorf("marked stale backup %s must still be pruned, stat err=%v", stale, err) } } } @@ -876,13 +885,92 @@ func TestCrossPlatformCoveragePruneKeepsCurrentRunBackups(t *testing.T) { // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: // oldest stamps beyond skillBackupKeep are pruned and a prune failure is // reported without aborting callers. +// TestCrossPlatformCoverageSkillBackupMarkerOwnership pins the prune ownership +// contract: backups carry the DWS marker, a mismatched marker is foreign data, +// and a backup whose marker cannot be written never removes the source. +func TestCrossPlatformCoverageSkillBackupMarkerOwnership(t *testing.T) { + t.Run("backup creation writes the ownership marker", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + backup, err := backupAndRemoveSkillDir(home, victim) + if err != nil { + t.Fatal(err) + } + body, readErr := os.ReadFile(filepath.Join(filepath.Dir(backup), skillBackupMarkerFile)) + if readErr != nil || string(body) != skillBackupMarkerBody { + t.Fatalf("backup marker = %q, %v", body, readErr) + } + }) + + t.Run("stamp with mismatched marker content is preserved", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+1; i++ { + seedSkillBackupStamp(t, root, fmt.Sprintf("20261210-00000%d", i)) + } + forged := filepath.Join(root, "20261210-999999") + if err := os.MkdirAll(forged, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(forged, skillBackupMarkerFile), []byte("forged\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := pruneSkillBackups(home); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(forged); err != nil { + t.Fatalf("forged-marker backup must be preserved: %v", err) + } + }) + + t.Run("unreadable marker is treated as foreign", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+1; i++ { + seedSkillBackupStamp(t, root, fmt.Sprintf("20261211-00000%d", i)) + } + testseam.Swap(t, &upgradeReadFile, func(string) ([]byte, error) { return nil, errors.New("read denied") }) + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("prune with unreadable markers = %v", err) + } + testseam.Swap(t, &upgradeReadFile, os.ReadFile) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != skillBackupKeep+1 { + t.Fatalf("unreadable markers must disable pruning, entries = %d", len(entries)) + } + }) + + t.Run("marker write failure keeps the source in place", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write denied") }) + backup, err := backupAndRemoveSkillDir(home, victim) + if err == nil || !strings.Contains(err.Error(), "备份所有权标记") { + t.Fatalf("marker write failure must fail the backup, got %v", err) + } + if backup != "" { + t.Fatalf("failed backup must not return a path: %q", backup) + } + if _, statErr := os.Stat(victim); statErr != nil { + t.Fatalf("victim must stay in place: %v", statErr) + } + }) +} + func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil { - t.Fatal(err) - } + seedSkillBackupStamp(t, root, fmt.Sprintf("20260810-00000%d", i)) } if err := pruneSkillBackups(home); err != nil { t.Fatalf("pruneSkillBackups() error = %v", err) @@ -915,9 +1003,7 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { // Removal failure is reported as the first error. Seed more than // skillBackupKeep dirs again so the prune loop actually runs. for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil { - t.Fatal(err) - } + seedSkillBackupStamp(t, root, fmt.Sprintf("20260811-00000%d", i)) } testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") }) if err := pruneSkillBackups(home); err == nil { @@ -928,20 +1014,17 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { // TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that // pruneSkillBackups only retires directories whose names match the DWS backup -// stamp format. Any foreign directory in the backup root must survive even when -// more than skillBackupKeep stamped backups exist. +// stamp format and whose ownership marker verifies. Any foreign or unmarked +// directory in the backup root must survive even when more than +// skillBackupKeep stamped backups exist. func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil { - t.Fatal(err) - } + seedSkillBackupStamp(t, root, fmt.Sprintf("20260910-00000%d", i)) } // A collision-suffixed stamp is still a valid DWS backup and is eligible. - if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil { - t.Fatal(err) - } + seedSkillBackupStamp(t, root, "20260910-000000-7") unknown := []string{ "user-personal-backup", "20260910-000000-abc", // stamp-shaped but non-numeric suffix @@ -953,12 +1036,18 @@ func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { t.Fatal(err) } } + // A stamp-shaped directory without the DWS ownership marker is foreign + // data: never counted toward the keep limit, never deleted. + unmarked := "20991231-235959" + if err := os.MkdirAll(filepath.Join(root, unmarked), 0o755); err != nil { + t.Fatal(err) + } if err := pruneSkillBackups(home); err != nil { t.Fatalf("pruneSkillBackups() error = %v", err) } - for _, name := range unknown { + for _, name := range append(append([]string{}, unknown...), unmarked) { if _, err := os.Stat(filepath.Join(root, name)); err != nil { t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) } @@ -975,8 +1064,8 @@ func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { stamps++ } } - if stamps != skillBackupKeep { - t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep) + if stamps != skillBackupKeep+1 { + t.Errorf("stamped backups after prune = %d, want %d (kept plus the unmarked foreign one)", stamps, skillBackupKeep+1) } } diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 58fa7822..84558494 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -312,14 +312,49 @@ function Get-DevSkillBackupName([string]$Dir) { $DevSkillBackupKeep = 5 $script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$DevSkillBackupMarkerFile = ".dws-skill-backup" +$DevSkillBackupMarkerBody = "dws skill backup v1" + +# Write-DevSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-DevSkillBackupMarker([string]$Root) { + [System.IO.File]::WriteAllText( + (Join-Path $Root $DevSkillBackupMarkerFile), + "$DevSkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-DevSkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-DevSkillBackupMarker([string]$Dir) { + try { + $marker = Join-Path $Dir $DevSkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $DevSkillBackupMarkerBody) + } catch { + return $false + } +} + function Remove-OldDevSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } # Only directories whose names match the DWS backup stamp format (UTC - # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any - # other entry is foreign data and is preserved. + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $DevSkillBackupKeep. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-DevSkillBackupMarker $_.FullName) } | Sort-Object -Property Name) $excess = $dirs.Count - $DevSkillBackupKeep foreach ($dir in $dirs) { @@ -346,6 +381,18 @@ function Backup-DevSkill([string]$path, [ref]$BackupPath) { if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" } } New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null + # Stamp ownership immediately after creating the stamp root and before + # any skill directory moves into it, so an interrupted backup can never + # leave an unmarked (never-prunable) stamp behind. + try { + Write-DevSkillBackupMarker $backupRoot + } catch { + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $backupRoot -Force -ErrorAction SilentlyContinue + throw + } Move-DevSkillPathRecoverably $path $target if ($null -ne $BackupPath) { $BackupPath.Value = $target } try { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index fe6c0d3f..d5716fd5 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -149,13 +149,23 @@ backup_skill_dir() { done record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + rm -rf "$backup_root" + return 1 + } mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup # directories from earlier runs under $HOME/.dws/skill-backups, matching Go's -# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the # current process are never pruned (mirroring Go's run-root registry and # install.js's currentRunBackupRoots), so a migration that retires more than # SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and @@ -175,10 +185,12 @@ record_current_run_backup_stamp() { CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" } -# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC -# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in -# the backup root is foreign (user data, unrelated tooling) and is preserved -# so pruning can never remove a directory it cannot prove DWS created. +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. is_skill_backup_stamp() { case "$1" in [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) @@ -202,6 +214,7 @@ prune_skill_backups() { for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -210,6 +223,7 @@ prune_skill_backups() { [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue case " $CURRENT_RUN_BACKUP_STAMPS " in *" $prune_entry "*) continue ;; esac diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 75933141..0665461d 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -218,13 +218,23 @@ backup_skill_dir() { done record_current_run_backup_stamp "$backup_root" mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + rm -rf "$backup_root" + return 1 + } mv "$victim" "$backup" || return 1 printf '%s\n' "$backup" } # prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup # directories from earlier runs under $HOME/.dws/skill-backups, matching Go's -# skillBackupKeep / pruneSkillBackups. Stamp directories created by the +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the # current process are never pruned (mirroring Go's run-root registry and # install.js's currentRunBackupRoots), so a migration that retires more than # SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and @@ -244,10 +254,12 @@ record_current_run_backup_stamp() { CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" } -# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC -# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in -# the backup root is foreign (user data, unrelated tooling) and is preserved -# so pruning can never remove a directory it cannot prove DWS created. +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. is_skill_backup_stamp() { case "$1" in [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) @@ -271,6 +283,7 @@ prune_skill_backups() { for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -279,6 +292,7 @@ prune_skill_backups() { [ "$prune_drop" -gt 0 ] || break [ -d "$prune_entry" ] || continue is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue case " $CURRENT_RUN_BACKUP_STAMPS " in *" $prune_entry "*) continue ;; esac diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 6e67ec13..5ed3f101 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -67,11 +67,20 @@ backup_and_remove_skill_dir() { return 1 fi done - record_current_run_backup_stamp "$(dirname "$_bed_target")" - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + mkdir -p "$_bed_stamp_root" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + rm -rf "$_bed_stamp_root" + printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target" @@ -83,7 +92,10 @@ backup_and_remove_skill_dir() { # prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped # backup directories from earlier runs under $HOME/.dws/skill-backups, -# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created # by the current process are never pruned (mirroring Go's run-root registry # and install.js's currentRunBackupRoots), so a migration that retires more # than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal @@ -102,10 +114,12 @@ record_current_run_backup_stamp() { DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" } -# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC -# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in -# the backup root is foreign (user data, unrelated tooling) and is preserved -# so pruning can never remove a directory it cannot prove DWS created. +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. is_skill_backup_stamp() { case "$1" in [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) @@ -129,6 +143,7 @@ prune_skill_backups() { for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -137,6 +152,7 @@ prune_skill_backups() { [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in *" $_psb_entry "*) continue ;; esac diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 22d49438..23e0e652 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -765,6 +765,42 @@ function Get-SkillBackupName { $SkillBackupKeep = 5 $script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$SkillBackupMarkerFile = ".dws-skill-backup" +$SkillBackupMarkerBody = "dws skill backup v1" + +# Write-SkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-SkillBackupMarker { + param([string]$Root) + [System.IO.File]::WriteAllText( + (Join-Path $Root $SkillBackupMarkerFile), + "$SkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-SkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-SkillBackupMarker { + param([string]$Dir) + try { + $marker = Join-Path $Dir $SkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $SkillBackupMarkerBody) + } catch { + return $false + } +} + # Remove-OldSkillBackups deletes the oldest stamped backup directories once # more than $SkillBackupKeep remain. Stamps sort lexicographically in # chronological order, so name order is age order. Roots created during this @@ -774,10 +810,11 @@ function Remove-OldSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } # Only directories whose names match the DWS backup stamp format (UTC - # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any - # other entry is foreign data and is preserved. + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $SkillBackupKeep. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-SkillBackupMarker -Dir $_.FullName) } | Sort-Object -Property Name) $excess = $dirs.Count - $SkillBackupKeep foreach ($dir in $dirs) { @@ -817,6 +854,19 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null + # Stamp ownership immediately after creating the stamp root and + # before any skill directory moves into it, so an interrupted backup + # can never leave an unmarked (never-prunable) stamp behind. + Write-SkillBackupMarker -Root $targetRoot + } catch { + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $targetRoot -Force -ErrorAction SilentlyContinue + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" + return $false + } + try { Move-SkillPathRecoverably -Source $Dir -Destination $target } catch { Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" diff --git a/scripts/install.sh b/scripts/install.sh index aa511c5c..2beb6c93 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -85,11 +85,20 @@ backup_and_remove_skill_dir() { return 1 fi done - record_current_run_backup_stamp "$(dirname "$_bed_target")" - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + mkdir -p "$_bed_stamp_root" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + rm -rf "$_bed_stamp_root" + say " ⚠️ 无法写入备份所有权标记,保留原目录 $_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" say " × 已备份并移除 $_bed_dir → $_bed_target" @@ -101,7 +110,10 @@ backup_and_remove_skill_dir() { # prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped # backup directories from earlier runs under $HOME/.dws/skill-backups, -# matching Go's skillBackupKeep / pruneSkillBackups. Stamp directories created +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created # by the current process are never pruned (mirroring Go's run-root registry # and install.js's currentRunBackupRoots), so a migration that retires more # than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal @@ -120,10 +132,12 @@ record_current_run_backup_stamp() { DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" } -# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC -# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in -# the backup root is foreign (user data, unrelated tooling) and is preserved -# so pruning can never remove a directory it cannot prove DWS created. +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. is_skill_backup_stamp() { case "$1" in [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) @@ -147,6 +161,7 @@ prune_skill_backups() { for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -155,6 +170,7 @@ prune_skill_backups() { [ "$_psb_drop" -gt 0 ] || break [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in *" $_psb_entry "*) continue ;; esac diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index da0cbc35..220b9320 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -53,6 +53,9 @@ * concurrently occupied destination (atomic * claim, no check-then-rename window) on * POSIX and simulated win32. + * 20. unmarked stamps — stamp-shaped directories without the exact + * ownership marker survive pruning and never + * consume a keep slot. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools * install.js itself shells out to). Skips cleanly on win32. @@ -136,6 +139,18 @@ function writeFile(filePath, content, mode = 0o644) { fs.writeFileSync(filePath, content, { mode }); } +// Cross-surface backup ownership contract (mirrors internal/upgrade/paths.go, +// scripts/install.ps1, scripts/install-devapp.ps1, and scripts/install.sh): +// every DWS-created stamp root carries .dws-skill-backup whose entire content +// is exactly these bytes. Hardcoded here on purpose so drift from any writer +// fails the smoke test. +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +function markBackupStamp(stampRoot) { + writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + function writeManagedState(home, names) { const state = { version: "old", @@ -1215,9 +1230,14 @@ scenario("simulated win32 publishes junctions with absolute canonical targets", const stamps = fs.readdirSync(backupRoot); assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp"); assert.deepEqual( - fs.readdirSync(path.join(backupRoot, stamps[0])), - [".zcode-skills-dingtalk-chat"], - "backups encode the HOME-relative origin of the retired copy", + fs.readdirSync(path.join(backupRoot, stamps[0])).sort(), + [SKILL_BACKUP_MARKER_FILE, ".zcode-skills-dingtalk-chat"], + "the stamp root carries the ownership marker next to the backup payload", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], SKILL_BACKUP_MARKER_FILE), "utf8"), + SKILL_BACKUP_MARKER_BODY, + "backup creation stamps the exact cross-surface marker bytes", ); assert.equal( fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"), @@ -1259,7 +1279,9 @@ scenario("skill backups stay bounded to the newest five stamps", () => { "20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", ]) { - writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); } const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); @@ -1288,7 +1310,9 @@ scenario("prune preserves unknown directories in skill-backups", () => { "20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", ]) { - writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); } const unknowns = [ "user-personal-backup", @@ -1322,6 +1346,73 @@ scenario("prune preserves unknown directories in skill-backups", () => { } }); +scenario("prune preserves unmarked stamp directories without consuming keep slots", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unmarked-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + // Exactly SKILL_BACKUP_KEEP verified stamps: nothing may be pruned, even + // though foreign stamp-shaped siblings would push the naive count past + // the limit. + const marked = ["20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004", "20260101-000005"]; + for (const stamp of marked) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const foreign = [ + { stamp: "20260102-000001", body: null }, // no marker at all + { stamp: "20260102-000002", body: "dws skill backup v2\n" }, // wrong body + { stamp: "20260101-000000", body: "dws skill backup v1" }, // missing trailing LF + { stamp: "20260101-000099", body: "dws skill backup v1\r\n" }, // CRLF is not the exact contract bytes + ]; + for (const { stamp, body } of foreign) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "user-data.txt"), `foreign:${stamp}\n`); + if (body !== null) writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), body); + } + + pruneSkillBackups(home); + + let remaining = fs.readdirSync(backupRoot).sort(); + assert.deepEqual( + remaining, + [...marked, ...foreign.map(({ stamp }) => stamp)].sort(), + "with exactly keep-many verified stamps, nothing is pruned and unmarked stamps never count", + ); + + // Force real pruning: three more verified stamps push marked history to + // eight, so exactly the three oldest verified stamps go while every + // unmarked sibling stays put (still not consuming a keep slot). + for (const stamp of ["20260103-000001", "20260103-000002", "20260103-000003"]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + + pruneSkillBackups(home); + + remaining = fs.readdirSync(backupRoot).sort(); + for (const pruned of marked.slice(0, 3)) { + assert.ok(!remaining.includes(pruned), `oldest verified stamp ${pruned} is pruned`); + } + assert.deepEqual( + remaining, + [marked[3], marked[4], ...foreign.map(({ stamp }) => stamp), "20260103-000001", "20260103-000002", "20260103-000003"].sort(), + "pruning keeps exactly the newest five verified stamps and preserves every unmarked sibling", + ); + for (const { stamp } of foreign) { + assert.equal( + fs.readFileSync(path.join(backupRoot, stamp, "user-data.txt"), "utf8"), + `foreign:${stamp}\n`, + `unmarked stamp ${stamp} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 06581f5d..300d2c0f 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -575,6 +575,209 @@ exit 0 } } +// The ownership-marker contract (skillBackupMarkerFile / skillBackupMarkerBody, +// declared in skill_backup_prune_test.go) is shared by the Go core +// (internal/upgrade/paths.go), install.sh, both PowerShell installers, and +// build/npm/install.js: every stamp root DWS creates carries the marker, and +// pruning only deletes stamp-named directories whose marker verifies. + +// seedSkillBackupOwnershipFixture fills ~/.dws/skill-backups with five +// verified stamps (marker bytes exactly as the Go core writes them, proving +// cross-surface acceptance) plus three foreign stamp-shaped siblings: two +// without a marker and one with a wrong marker body. +func seedSkillBackupOwnershipFixture(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range []string{ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", + } { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "placeholder", "SKILL.md"), []byte(stamp+"\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, stamp, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + for _, stamp := range []string{"20260102-000001", "20260102-000002", "20260101-000000"} { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "user-data.txt"), []byte("foreign:"+stamp+"\n"), 0o644) + } + mustWriteFile(t, filepath.Join(backupRoot, "20260101-000000", skillBackupMarkerFile), []byte("dws skill backup v2\n"), 0o644) +} + +// assertSkillBackupOwnershipResult checks the post-prune contract shared by +// both PowerShell installers: the four oldest verified stamps are gone +// (three by the final prune, one by the backup's internal prune), exactly +// five verified stamps survive, every foreign stamp-shaped sibling is intact, +// and the current-run stamp carries the exact marker bytes next to the moved +// payload. +func assertSkillBackupOwnershipResult(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, pruned := range []string{"20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004"} { + if _, err := os.Lstat(filepath.Join(backupRoot, pruned)); !os.IsNotExist(err) { + t.Fatalf("verified stamp %s must be pruned, err = %v", pruned, err) + } + } + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", backupRoot, err) + } + surviving := map[string]bool{} + runStamps := []string{} + for _, entry := range entries { + surviving[entry.Name()] = true + if entry.Name() >= "20260801-000000" { + runStamps = append(runStamps, entry.Name()) + } + } + for _, want := range []string{ + "20260101-000005", + "20260101-000000", "20260102-000001", "20260102-000002", + "20260103-000001", "20260103-000002", "20260103-000003", + } { + if !surviving[want] { + t.Fatalf("stamp %s must survive pruning, surviving = %v", want, surviving) + } + } + if len(runStamps) != 1 || len(entries) != 8 { + t.Fatalf("expected the five newest verified stamps, three foreign stamps, and one run stamp, got %d entries (run stamps %v)", len(entries), runStamps) + } + runRoot := filepath.Join(backupRoot, runStamps[0]) + marker, err := os.ReadFile(filepath.Join(runRoot, skillBackupMarkerFile)) + if err != nil || string(marker) != skillBackupMarkerBody { + t.Fatalf("run stamp marker = %q, %v; want exactly %q", string(marker), err, skillBackupMarkerBody) + } + if backup := findSkillBackupContent(home, "old copy\n"); backup == "" { + t.Fatalf("backed-up Skill content missing from run stamp %s", runRoot) + } + for _, stamp := range []string{"20260101-000000", "20260102-000001", "20260102-000002"} { + data, err := os.ReadFile(filepath.Join(backupRoot, stamp, "user-data.txt")) + if err != nil || string(data) != "foreign:"+stamp+"\n" { + t.Fatalf("foreign stamp %s must be intact, got %q, %v", stamp, string(data), err) + } + } +} + +func TestInstallPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 10 } +} +# A real backup both proves the writer stamps the exact marker bytes and adds +# a current-run root that pruning must keep. +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +if (!(Backup-SkillDir -Dir $victim)) { exit 11 } +if (Test-Path -LiteralPath $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + +func TestInstallDevappPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + pwsh := "" + if candidate, err := exec.LookPath("pwsh"); err == nil { + pwsh = candidate + } else if runtime.GOOS == "windows" { + if candidate, err := exec.LookPath("powershell"); err == nil { + pwsh = candidate + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldDevSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 10 } +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +Backup-DevSkill $victim +if (Test-PathLexically $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + func TestInstallScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX shell semantics are unavailable") @@ -1007,6 +1210,15 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { if err != nil || string(sibling) != "keep sibling\n" { t.Fatalf("unrelated sibling changed: data=%q err=%v", sibling, err) } + // The replaced standalone Event skill must sit in a marker-stamped backup + // root: that marker is what lets a later prune count and recycle it. The + // event installer archives under the flattened source path, so locate the + // backup by content and the stamp root two levels above it. + if got := findSkillBackupContent(fakeHome, "old standalone event\n"); got == "" { + t.Fatalf("replaced standalone Event skill should be backed up under .dws/skill-backups") + } else { + assertSkillBackupMarker(t, filepath.Dir(filepath.Dir(got))) + } if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-dev")); !os.IsNotExist(err) { t.Fatalf("dingtalk-dev should not be installed by install-event.sh, stat err=%v", err) } @@ -1571,12 +1783,17 @@ func TestInstallScriptSourceModeDefaultMultiInstall(t *testing.T) { } } // Removals must be reversible: the displaced dirs land under - // ~/.dws/skill-backups/ instead of being destroyed. - if findSkillBackup(fixture.fakeHome, "dws", "old mono\n") == "" { + // ~/.dws/skill-backups/ instead of being destroyed, each inside a stamp + // root stamped with the ownership marker. + if got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n"); got == "" { t.Errorf("old mono dws/ should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } - if findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n") == "" { + if got := findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n"); got == "" { t.Errorf("stale dingtalk-* should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil { t.Errorf("non-DWS skill must be preserved: %v", err) @@ -1738,6 +1955,73 @@ func findSkillBackup(fakeHome, name, content string) string { return found } +// assertSkillBackupMarker pins the ownership contract shared with the Go core: +// every stamp root a shell installer creates carries .dws-skill-backup with +// exactly "dws skill backup v1\n", because pruning only ever removes +// stamp-shaped roots with those exact bytes. +func assertSkillBackupMarker(t *testing.T, stampRoot string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(stampRoot, skillBackupMarkerFile)) + if err != nil { + t.Fatalf("backup ownership marker missing in %s: %v", stampRoot, err) + } + if string(data) != skillBackupMarkerBody { + t.Fatalf("backup ownership marker in %s = %q, want %q", stampRoot, data, skillBackupMarkerBody) + } +} + +// TestInstallScriptPrunePreservesUnmarkedStampDirs runs the real install.sh +// (whose main ends with prune_skill_backups) against a HOME whose backup root +// already holds a full marked history plus one unmarked stamp-shaped +// directory. The end-of-run prune must remove the marked excess while the +// unmarked directory — foreign data without the ownership marker — is neither +// deleted nor counted toward the keep limit, and the stamp this run creates +// must carry the exact marker bytes. +func TestInstallScriptPrunePreservesUnmarkedStampDirs(t *testing.T) { + t.Parallel() + + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") + backupRoot := filepath.Join(fixture.fakeHome, ".dws", "skill-backups") + + // Oldest on purpose: without the marker check this directory would be the + // first pruning candidate and would inflate the excess count. + unmarked := filepath.Join(backupRoot, "20200101-000000") + mustWriteFile(t, filepath.Join(unmarked, "user-data.txt"), []byte("foreign\n"), 0o644) + for i := 1; i <= 6; i++ { + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), "skill", "SKILL.md"), []byte("old batch\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + seedAgentHome(t, fixture.fakeHome, "dws", "old mono\n") + + runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode("mono", + "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=0", + )) + + // Six marked batches plus this run's one exceed the keep limit of five, + // so exactly the two oldest marked batches are excess and must be gone — + // proving the prune actually ran. + for _, gone := range []string{oldStampName(1), oldStampName(2)} { + if _, err := os.Stat(filepath.Join(backupRoot, gone)); !os.IsNotExist(err) { + t.Fatalf("marked excess stamp %s should have been pruned, stat err=%v", gone, err) + } + } + if _, err := os.Stat(filepath.Join(backupRoot, oldStampName(3))); err != nil { + t.Fatalf("marked keep-range stamp should survive the prune: %v", err) + } + // The unmarked directory is never counted and never removed, even though + // it sorts before every pruned batch. + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "foreign\n" { + t.Fatalf("unmarked stamp-shaped directory must be preserved untouched: data=%q err=%v", data, err) + } + got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n") + if got == "" { + t.Fatal("this run's dws backup is missing after the prune") + } + assertSkillBackupMarker(t, filepath.Dir(got)) +} + // TestInstallScriptBackupFailureKeepsOriginalDir pins the fail-safe contract: // when the backup destination cannot be created (skill-backups pre-created as // a regular file), the installer must keep every pre-existing skill directory diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go index 6defc75b..2e519490 100644 --- a/test/scripts/skill_backup_prune_test.go +++ b/test/scripts/skill_backup_prune_test.go @@ -34,6 +34,14 @@ var shellPruneScriptTargets = []string{ "install-devapp.sh", } +// The ownership-marker contract shared with the Go core +// (internal/upgrade/paths.go): every stamp root DWS creates carries the +// marker, and prune only counts or removes roots with these exact bytes. +const ( + skillBackupMarkerFile = ".dws-skill-backup" + skillBackupMarkerBody = "dws skill backup v1\n" +) + // extractShellFunction pulls a top-level `name() { ... }` definition out of a // script so the shipped logic — not a copy — is what the test exercises. func extractShellFunction(t *testing.T, scriptPath, name string) string { @@ -55,16 +63,33 @@ func extractShellFunction(t *testing.T, scriptPath, name string) string { return text[start : start+end+3] } +// mkdirStampWithMarker creates a DWS-owned stamp fixture: the stamp directory +// plus the exact ownership marker bytes prune verifies. Without the marker a +// stamp-shaped directory is foreign data that prune must neither count nor +// remove. +func mkdirStampWithMarker(t *testing.T, backupRoot, stamp string) { + t.Helper() + dir := filepath.Join(backupRoot, stamp) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(dir, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) +} + // runShellPruneScenario builds a sandbox HOME with the given pre-existing -// (foreign) stamps, registers the given stamps as created by the current run, -// runs the script's own prune_skill_backups, and returns the surviving stamp -// directories. -func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string) []string { +// (earlier-run) stamps, registers the given stamps as created by the current +// run, optionally also plants stamp-shaped directories without the ownership +// marker, runs the script's own prune_skill_backups, and returns the +// surviving stamp directories. +func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string, unmarkedStamps ...string) []string { t.Helper() home := t.TempDir() backupRoot := filepath.Join(home, ".dws", "skill-backups") for _, stamp := range foreignStamps { - if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { + mkdirStampWithMarker(t, backupRoot, stamp) + } + for _, stamp := range unmarkedStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp, "payload"), 0o755); err != nil { t.Fatal(err) } } @@ -83,9 +108,7 @@ func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runSt driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups")) driver.WriteString("\n") for _, stamp := range runStamps { - if err := os.MkdirAll(filepath.Join(backupRoot, stamp), 0o755); err != nil { - t.Fatal(err) - } + mkdirStampWithMarker(t, backupRoot, stamp) driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n") } driver.WriteString("prune_skill_backups\n") @@ -179,6 +202,24 @@ func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) { oldStampName(3), oldStampName(4), oldStampName(5), oldStampName(6), oldStampName(7)) }) + + // A stamp-shaped directory without the ownership marker is foreign + // data: it must be preserved untouched, never counted toward the + // keep limit, and never treated as prunable excess. Oldest on + // purpose — if the marker were not required, this directory would + // be the first pruning candidate. + t.Run("unmarked stamp-shaped directory is preserved and uncounted", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + unmarked := "20200101-000000" + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil, unmarked) + assertStampsSurvive(t, surviving, + append([]string{unmarked}, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7))...) + }) }) } } From 234253e75f05cb1d702398bf56e225e2ac0c64db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 20:42:04 +0800 Subject: [PATCH 123/267] test(skills): cover linux statx identity without btime --- .../skill_publication_identity_linux.go | 8 ++++- .../skill_publication_identity_linux_test.go | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 internal/upgrade/skill_publication_identity_linux_test.go diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index c2dcc414..372e4716 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -27,9 +27,15 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { // immediately, so a concurrent replacement can look like the staged object. // ctime is omitted: writing a child updates the directory ctime and would // make an owned dest look foreign. +// skillPathStatx is the seam behind skillPathFileIdentityImpl so tests can +// exercise the kernels/filesystems that do not report STATX_BTIME. +var skillPathStatx = func(path string, stx *unix.Statx_t) error { + return unix.Statx(unix.AT_FDCWD, path, unix.AT_SYMLINK_NOFOLLOW, unix.STATX_BASIC_STATS|unix.STATX_BTIME, stx) +} + func skillPathFileIdentityImpl(path string) string { var stx unix.Statx_t - if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_SYMLINK_NOFOLLOW, unix.STATX_BASIC_STATS|unix.STATX_BTIME, &stx); err != nil { + if err := skillPathStatx(path, &stx); err != nil { return "" } if stx.Mask&unix.STATX_BTIME == 0 { diff --git a/internal/upgrade/skill_publication_identity_linux_test.go b/internal/upgrade/skill_publication_identity_linux_test.go new file mode 100644 index 00000000..6c634ef7 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux_test.go @@ -0,0 +1,35 @@ +//go:build linux + +package upgrade + +import ( + "errors" + "testing" + + "golang.org/x/sys/unix" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPublicationLinuxFileIdentityEdges(t *testing.T) { + t.Run("statx without birth time is not an identity", func(t *testing.T) { + // CI filesystems always report STATX_BTIME, so the unprovable-identity + // path is driven through the seam: a kernel or filesystem without + // birth-time support must yield an empty file ID, which callers treat + // as "identity cannot be proven". + testseam.Swap(t, &skillPathStatx, func(_ string, stx *unix.Statx_t) error { + stx.Mask = unix.STATX_BASIC_STATS + return nil + }) + if got := skillPathFileIdentityImpl(t.TempDir()); got != "" { + t.Fatalf("statx without btime = %q, want empty", got) + } + }) + + t.Run("statx failure is not an identity", func(t *testing.T) { + testseam.Swap(t, &skillPathStatx, func(string, *unix.Statx_t) error { return errors.New("statx denied") }) + if got := skillPathFileIdentityImpl(t.TempDir()); got != "" { + t.Fatalf("failed statx = %q, want empty", got) + } + }) +} From 0ae8949d4022f7c0bc0295a5f0e1b67faab77543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Tue, 18 Aug 2026 20:53:02 +0800 Subject: [PATCH 124/267] fix(doc): align skill contracts with runtime --- skills/multi/dingtalk-doc/SKILL.md | 2 +- skills/multi/dingtalk-doc/references/contracts.md | 8 ++++---- skills/multi/dingtalk-doc/references/doc/doc-import.md | 4 ++-- skills/multi/dingtalk-doc/references/doc/doc-update.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index f1af08b0..2855adf2 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -65,7 +65,7 @@ metadata: - 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。 - 仅在结果明确且关键内容回读匹配后报告写入完成。 - 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。 -- `+import` 已含上传、转换和轮询;按终态与 `steps` 判断,不能因有 `taskId` 就说仍异步。截断回读不算验证,改用 `keyword`/局部 scope 核对。 +- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。 - 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。 ## 参数与安全边界 diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md index c70e1f79..e64f5650 100644 --- a/skills/multi/dingtalk-doc/references/contracts.md +++ b/skills/multi/dingtalk-doc/references/contracts.md @@ -13,10 +13,10 @@ | `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 | | `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 | | `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 | -| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 | +| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after_seconds`,最多有界重试一次 | | `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 | -`ok=true` 或进程退出码为零不能替代 `status/verified`。`status` 是当前 Doc 业务契约中的状态,不应与框架外层 `outcome` 混为一谈。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。 +进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。 优先级从高到低为:稳定返回 ID/任务 ID → 写后读回匹配 → 明确服务端终态 → 仅 transport/退出码。只有前三类中与该操作匹配的证据足够时才报告成功;Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。 @@ -26,8 +26,8 @@ - `complete=true`:已证明覆盖请求范围; - `hasMore=true`:仍有后续页,必须保留有效 continuation; -- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止; -- `failures[]`:已返回部分数据但某一页或后处理失败。 +- `truncated=true`:成功返回因 `max_pages` 或 `max_items` 边界停止,并通过 `stopReason` 说明原因; +- 后续页请求失败、cursor 缺失/停滞/循环时返回 typed error;部分结果位于 `details.items`,并保留 `details.status=partial_success`、`complete=false`、`reason/page/nextCursor/count`。不要期待成功回执的空 `failures[]` 承载这类错误,也不要把 timeout 写成 `truncated`。 只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md index ce25ce4a..d03a6031 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-import.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md @@ -8,12 +8,12 @@ dws doc +import --file ./report.docx --folder --format json dws doc +import --file ./notes.md --workspace --name "会议纪要" --format json ``` -`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。返回 `taskId` 只是稳定任务标识;若 shortcut 同时返回终态 `success` 及已完成 `steps`,不需要再调用原子状态查询。只有终态缺失、`unknown`、超时或中断才进入恢复流程。 +`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。转换成功回执包含 `success=true`、`taskId`、`documentUrl`、`documentName` 和 `documentType`,不包含 `status` 或 `steps`;成功返回即表示本次内部轮询已到终态。超时或中断时保留错误中的 `taskId`,只查询原任务。 ## 本地文件边界 - `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。 -- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。 +- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录。通常只提供一个;当前 Runtime 在两者同时出现时会一并传给服务端,未声明客户端优先级,不要依赖 `--folder` 覆盖 `--workspace`。 - CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。 - 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。 - “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace `,不要因容器叫“文档空间”就误报为在线文档。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-update.md b/skills/multi/dingtalk-doc/references/doc/doc-update.md index 42bc6947..59707ac4 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-update.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-update.md @@ -55,7 +55,7 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止 ## Block ID 生命周期与保真 -- `block_replace` / `block_delete` 后,受影响的旧 ID 不再作为后续锚点;需要继续操作该位置时局部 `+fetch` 重新取 ID。 +- `block_replace` 成功后 Runtime 使用同一 `blockId` 回读验证,该 ID 可继续作为锚点;验证失败时先局部 `+fetch` 核对现状。`block_delete` 成功后旧 ID 失效,不得继续复用。 - `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。 - `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。 - 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。 From 2dd724f1e108ab2b8e3821b4b88ed6785737e7f8 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Tue, 18 Aug 2026 21:10:23 +0800 Subject: [PATCH 125/267] feat(oa): add approval list-by-admin with string time contract - Add dws oa approval list-by-admin leaf with simple flags and advanced --request modes backed by get_process_instances_by_admin - Send startTime/endTime as yyyy-MM-dd HH:mm:ss strings per the 2026-08 MCP contract update; ISO-8601 flag inputs auto-convert - Enforce pageSize cap (20) and string time format/order client-side; PreRunE reports flag-group violations in Chinese before Cobra's built-in English validation - Extend coverage tests and document the command in mono/multi OA skill references --- internal/helpers/oa.go | 219 +++++++++++++++++++- internal/helpers/oa_coverage_more_test.go | 73 +++++++ skills/mono/references/products/oa.md | 30 +++ skills/multi/dingtalk-misc/references/oa.md | 30 +++ 4 files changed, 351 insertions(+), 1 deletion(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 8dfe47b5..9073041b 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -8,6 +8,7 @@ import ( "io" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -322,6 +323,79 @@ func newOAAttachmentCommand() *cobra.Command { return attachmentCmd } +// oaAdminQueryMaxPageSize is the pageSize upper bound of +// get_process_instances_by_admin. +const oaAdminQueryMaxPageSize = float64(20) + +// validateOARequestPageSize checks the pageSize field of a decoded +// --request payload (json.Number values from decodeOARequest). +func validateOARequestPageSize(request map[string]any) error { + v, ok := request["pageSize"] + if !ok { + return nil + } + n, ok := v.(json.Number) + if !ok { + return fmt.Errorf("pageSize 必须为数字") + } + f, err := n.Float64() + if err != nil || f < 1 || f > oaAdminQueryMaxPageSize { + return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String()) + } + return nil +} + +// oaAdminTimeLayout is the startTime/endTime wire format of +// get_process_instances_by_admin since the 2026-08 MCP contract update +// (both fields changed from epoch millis to strings). +const oaAdminTimeLayout = "2006-01-02 15:04:05" + +// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in +// error messages. +const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss" + +var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600) + +// formatOAAdminQueryTime renders a millisecond timestamp into the +// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin. +func formatOAAdminQueryTime(ms int64) string { + return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout) +} + +// validateOARequestTimeRange checks startTime/endTime of a decoded +// --request payload: both must be yyyy-MM-dd HH:mm:ss strings, and +// endTime must not precede startTime. +func validateOARequestTimeRange(request map[string]any) error { + startRaw, ok := request["startTime"] + if !ok { + return nil + } + startStr, ok := startRaw.(string) + if !ok { + return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr) + } + endRaw, ok := request["endTime"] + if !ok { + return nil + } + endStr, ok := endRaw.(string) + if !ok { + return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr) + } + if end.Before(start) { + return fmt.Errorf("--request endTime 不能早于 startTime") + } + return nil +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, @@ -329,7 +403,8 @@ func newOAAttachmentCommand() *cobra.Command { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance, get_attachment_download_url, auth_download_file, +// start_process_instance, get_process_instances_by_admin, +// get_attachment_download_url, auth_download_file, // auth_preview_attachment // ────────────────────────────────────────────────────────── @@ -1432,6 +1507,126 @@ func newOaCommand() *cobra.Command { }, }, }) + // 以管理员身份查询审批实例列表 + listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"} + approvalListByAdminCmd := &cobra.Command{ + Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表", + Example: ` dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行, + // 这里先校验同一组约束,保证用户看到的是中文错误。 + request, _ := cmd.Flags().GetString("request") + processCode, _ := cmd.Flags().GetString("process-code") + if request == "" && processCode == "" { + return fmt.Errorf("--request、--process-code 至少指定一个") + } + if request != "" { + for _, name := range listByAdminSimpleFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--request 与 --%s 不能同时指定", name) + } + } + } + if processCode != "" && !cmd.Flags().Changed("start") { + return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if raw, _ := cmd.Flags().GetString("request"); raw != "" { + request, err := decodeOARequest(raw) + if err != nil { + return fmt.Errorf("--request JSON 解析失败: %w", err) + } + if err := validateOARequestPageSize(request); err != nil { + return err + } + if err := validateOARequestTimeRange(request); err != nil { + return err + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + } + if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil { + return err + } + startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + if err != nil { + return err + } + cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64) + if err != nil { + return fmt.Errorf("--cursor 必须为数字: %w", err) + } + pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64) + if err != nil { + return fmt.Errorf("--limit 必须为数字: %w", err) + } + if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize { + return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit")) + } + request := map[string]any{ + "processCode": mustGetFlag(cmd, "process-code"), + "startTime": formatOAAdminQueryTime(startMs), + "cursor": cursor, + "pageSize": pageSize, + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + endMs, err := parseISOTimeToMillis("end", v) + if err != nil { + return err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return err + } + request["endTime"] = formatOAAdminQueryTime(endMs) + } + if v, _ := cmd.Flags().GetString("user-ids"); v != "" { + request["userIds"] = strings.Split(v, ",") + } + if v, _ := cmd.Flags().GetString("statuses"); v != "" { + request["statuses"] = strings.Split(v, ",") + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + }, + } + DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_process_instances_by_admin", + CanonicalPath: "oa.get_process_instances_by_admin", + CLIPath: "oa approval list-by-admin", + PrimaryCLIPath: "oa approval list-by-admin", + }, + Description: "以管理员身份获取审批单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表", + UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"}, + AvoidWhen: []string{ + "只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc", + "无 OA 管理员权限时不要使用,该命令查不到数据", + }, + Examples: []string{ + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20", + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"", + }, + }, + // Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest). + Parameters: []contract.ParamDecl{ + {Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"}, + }, + }, + }) approvalCreateCmd := &cobra.Command{ Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)", Example: "dws oa approval create-instance --process-code --form-values '{\"事由\":\"测试\"}' --yes", @@ -1610,6 +1805,27 @@ func newOaCommand() *cobra.Command { RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}}, }) + approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)") + approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0") + approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20") + approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)") + approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)") + approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)") + approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code") + approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start") + listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags)) + for _, name := range listByAdminSimpleFlags { + approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name) + listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name}) + } + cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: listByAdminMutuallyExclusive, + RequireOneOf: [][]string{{"request", "process-code"}}, + RequireTogether: [][]string{{"process-code", "start"}}, + }) + approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID") approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)") @@ -1656,6 +1872,7 @@ func newOaCommand() *cobra.Command { approvalRevertTaskCmd, approvalFormSchemaCmd, approvalForecastCmd, + approvalListByAdminCmd, approvalCreateCmd, ) approvalCmd.AddCommand(newOAAttachmentCommand()) diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index 70cb18cc..b3c2f1dc 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes } } +func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "list-by-admin", + "--process-code", "PROC", + "--start", "2030-01-01T09:00:00+08:00", + "--end", "2030-01-01T10:00:00+08:00", + "--cursor", "5", + "--limit", "20", + "--user-ids", "user-1,user-2", + "--statuses", "RUNNING,COMPLETED", + ) + if err != nil { + t.Fatalf("list by admin: %v", err) + } + if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" { + t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool) + } + request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any) + if !ok { + t.Fatalf("request payload = %#v", caller.args) + } + if got := request["processCode"]; got != "PROC" { + t.Fatalf("processCode = %#v", got) + } + if got := request["cursor"]; got != float64(5) { + t.Fatalf("cursor = %#v", got) + } + if got := request["pageSize"]; got != float64(20) { + t.Fatalf("pageSize = %#v", got) + } + if got := request["startTime"]; got != "2030-01-01 09:00:00" { + t.Fatalf("startTime = %#v", got) + } + if got := request["endTime"]; got != "2030-01-01 10:00:00" { + t.Fatalf("endTime = %#v", got) + } + if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" { + t.Fatalf("userIds = %#v", got) + } + if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" { + t.Fatalf("statuses = %#v", got) + } +} + func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) { validCases := []struct { name string @@ -157,6 +202,16 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"}, tool: "start_process_instance", }, + { + name: "list-by-admin simple mode", + args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, } for _, tc := range validCases { t.Run(tc.name, func(t *testing.T) { @@ -188,6 +243,24 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"}, + {"approval", "list-by-admin"}, + {"approval", "list-by-admin", "--process-code", "PROC"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"}, + {"approval", "list-by-admin", "--request", "{"}, + {"approval", "list-by-admin", "--request", "null"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index cc82079f..4c9f6d4c 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -634,6 +634,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -743,6 +767,7 @@ Flags: 用户说"我发起的审批单" -> `approval list-submitted` 用户说"我审批/处理过的审批单" -> `approval list-executed` 用户说"抄送我的审批单" -> `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -792,6 +817,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -834,6 +862,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance | | `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners | | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -844,6 +873,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。 - `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 7124e064..fb018ca7 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -638,6 +638,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -757,6 +781,7 @@ Flags: 用户说"抄送我的审批单/抄送我的XX审批/CC我的XX/查抄送我的XX" → `approval list-cc`,将 XX 作为 `--query` 关键字传入(可搜索表单名称或表单详情内容) - 示例:"查抄送我的补卡审批单" → `approval list-cc --query 补卡` - 示例:"抄送我的审批单"(无关键词)→ `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -815,6 +840,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 aisearch person) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -879,6 +907,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | | `ding-info` | `userId` | ding message send 的 --users(多个逗号拼接);**robotCode 优先走 `$DINGTALK_DING_ROBOT_CODE` 环境变量,content 由 agent 根据审批上下文撰写;返回空时报错并停止** | | `revert-activities` | `activityId`, `revertAction`, `activityName` | revert-task 的 --target-activity-id 和 --action;**返回空时必须告知用户"无可回退节点"** | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -900,6 +929,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取 - 已知表单名称关键字时优先用 `search-forms`;需枚举全部表单时用 `list-forms` +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - 催办必须两步串联:`ding-info` 仅返回被催办人 `userId`,不返回 robotCode/content;需再调用 `dws ding message send`,其中 `--robot-code` 优先使用环境变量 `$DINGTALK_DING_ROBOT_CODE`,若无则向用户确认;`--content` 由 agent 根据审批上下文撰写催办文案;**严禁跳过 `ding-info` 直接猜测 userId** - 催办文案建议格式:`"请尽快审批《{表单名}》(提交人:{发起人},提交时间:{时间})"` - `ding-info` 返回空或报错时,必须明确告知用户"无法获取该任务的被催办人信息"并停止 From fab84af434e5c5d7b27cea2989009df2dc906a8a Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Tue, 18 Aug 2026 21:32:00 +0800 Subject: [PATCH 126/267] docs(changelog): add release fragment for oa approval list-by-admin --- .changes/oa-approval-list-by-admin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/oa-approval-list-by-admin.md diff --git a/.changes/oa-approval-list-by-admin.md b/.changes/oa-approval-list-by-admin.md new file mode 100644 index 00000000..da6f2c33 --- /dev/null +++ b/.changes/oa-approval-list-by-admin.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. From 75f08da1973d113f39046dcda3d6130289985bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Tue, 18 Aug 2026 22:10:23 +0800 Subject: [PATCH 127/267] feat(calendar): expand parameter alias normalization --- internal/app/param_alias_e2e_test.go | 68 ++- .../param_alias_payload_equivalence_test.go | 106 ++++ internal/cli/param_aliases_generated.go | 481 ++++++++++++++++++ internal/cli/param_concepts.json | 165 +++++- 4 files changed, 810 insertions(+), 10 deletions(-) diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index fed2527b..cca79f8e 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -51,7 +51,38 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string { switch tool { case "list_calendar_events": - return `{"result":{"events":[]}}` + return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}` + case "get_calendar_detail": + return c.paramAliasCalendarDetailResponse() + case "get_calendar_participants": + return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}` + case "search_calendar": + return `{"success":true,"result":{"calendars":[]}}` + case "search_rooms": + return `{"success":true,"result":{"rooms":[]}}` + case "query_available_meeting_room": + return `{"success":true,"result":{"rooms":[],"hasMore":false}}` + case "list_meeting_room_groups": + return `{"success":true,"result":{"groups":[]}}` + case "query_busy_status": + return `{"success":true,"result":[]}` + case "list_suggested_event_times": + return `{"success":true,"result":{"recommendEventTimes":[]}}` + case "create_calendar_event": + return `{"success":true,"result":{"eventId":"event-1"}}` + case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant": + return `{"success":true}` + case "respond": + status := "accepted" + if call := c.lastParamAliasCall(); call != nil { + if value, ok := call.args["responseStatus"].(string); ok && value != "" { + status = value + } + } + encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}}) + return string(encoded) + case "get_current_user_profile": + return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}` case "query_records": return `{"success":true,"status":"success","error":{},"data":{}}` case "search_mail_users": @@ -127,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string } } +func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall { + if len(c.calls) == 0 { + return nil + } + return &c.calls[len(c.calls)-1] +} + +func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string { + event := map[string]any{ + "eventId": "event-1", + "summary": "Fixture Meeting", + "description": "fixture description", + "startDateTime": "2026-03-10T09:00:00+08:00", + "endDateTime": "2026-03-10T10:00:00+08:00", + } + for _, call := range c.calls { + switch call.tool { + case "create_calendar_event", "update_calendar_event": + for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} { + if value, ok := call.args[key]; ok { + event[key] = value + } + } + case "respond": + if value, ok := call.args["responseStatus"]; ok { + event["responseStatus"] = value + } + case "delete_calendar_event": + event["status"] = "cancelled" + } + } + encoded, _ := json.Marshal(map[string]any{"success": true, "result": event}) + return string(encoded) +} + func (*paramAliasCaptureCaller) Format() string { return "json" } func (*paramAliasCaptureCaller) DryRun() bool { return false } func (*paramAliasCaptureCaller) Fields() string { return "" } diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 6d83f718..cfe5224c 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -46,7 +46,37 @@ var paramAliasCompleteCommands = map[string][]string{ "aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"}, "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, "attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"}, + "calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"}, + "calendar +book-search": {"calendar", "+book-search", "--query", "fixture"}, + "calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"}, + "calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"}, + "calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"}, + "calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"}, + "calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"}, + "calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"}, + "calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"}, + "calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"}, + "calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"}, + "calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"}, + "calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"}, + "calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--timezone", "Asia/Shanghai"}, + "calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"}, + "calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2", "--yes"}, "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted", "--yes"}, + "calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, + "calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai", "--yes"}, + "calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2", "--yes"}, + "calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2", "--yes"}, + "calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"}, "chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"}, "chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, "chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"}, @@ -532,6 +562,82 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias + paramAliasPayloadCaseKey("calendar +agenda", "from"): true, + paramAliasPayloadCaseKey("calendar +agenda", "to"): true, + paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true, + paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +book", "summary"): true, + paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true, + paramAliasPayloadCaseKey("calendar +book-search", "search"): true, + paramAliasPayloadCaseKey("calendar +book-search", "name"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true, + paramAliasPayloadCaseKey("calendar +free", "name"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true, + paramAliasPayloadCaseKey("calendar +my-free", "from"): true, + paramAliasPayloadCaseKey("calendar +my-free", "to"): true, + paramAliasPayloadCaseKey("calendar +invite", "id"): true, + paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "id"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "to"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-search", "query"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true, + paramAliasPayloadCaseKey("calendar busy search", "room-id"): true, + paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true, + paramAliasPayloadCaseKey("calendar event create", "tz"): true, + paramAliasPayloadCaseKey("calendar event create", "room-id"): true, + paramAliasPayloadCaseKey("calendar event respond", "response-status"): true, + paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar event update", "tz"): true, + paramAliasPayloadCaseKey("calendar room add", "room-id"): true, + paramAliasPayloadCaseKey("calendar room delete", "room-id"): true, + paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +create", "summary"): true, + paramAliasPayloadCaseKey("calendar +create", "description"): true, + paramAliasPayloadCaseKey("calendar +create", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-id"): true, + paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +create", "to"): true, + paramAliasPayloadCaseKey("calendar +create", "from"): true, + paramAliasPayloadCaseKey("calendar +get", "event-id"): true, + paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "from"): true, + paramAliasPayloadCaseKey("calendar +room-find", "to"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "query"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, + paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true, + paramAliasPayloadCaseKey("calendar +search-event", "from"): true, + paramAliasPayloadCaseKey("calendar +search-event", "to"): true, + paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true, + paramAliasPayloadCaseKey("calendar +update", "event-id"): true, + paramAliasPayloadCaseKey("calendar +update", "from"): true, + paramAliasPayloadCaseKey("calendar +update", "summary"): true, + paramAliasPayloadCaseKey("calendar +update", "description"): true, + paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true, + paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true, paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index ad82e6db..4b5b93c9 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -1773,6 +1773,439 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, }, + { + CLIPath: "calendar +agenda", + Aliases: map[string]string{ + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "offset", "page", "room-id", "time"}, + }, + { + CLIPath: "calendar +attendee-list", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + }, + Blocked: []string{"acl-id", "room-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +book", + Aliases: map[string]string{ + "attendee-names": "with", + "begin": "start", + "end-time": "end", + "from": "start", + "names": "with", + "participant-names": "with", + "start-time": "start", + "subject": "title", + "summary": "title", + "to": "end", + }, + Blocked: []string{"attendees", "calendar-id", "calendar-name", "date", "end-date", "name", "open-dingtalk-ids", "participants", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +book-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "name": "query", + "q": "query", + "search": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "calendar +cancel-event", + Aliases: map[string]string{ + "calendar-event-id": "event", + "event-id": "event", + "id": "event", + }, + Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "room-id"}, + }, + { + CLIPath: "calendar +conflicts", + Aliases: map[string]string{ + "day-offset": "in-days", + "days-from-today": "in-days", + }, + Blocked: []string{"days", "duration", "end", "from", "start", "to"}, + }, + { + CLIPath: "calendar +create", + Aliases: map[string]string{ + "attendee-ids": "attendees", + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "description": "desc", + "end-time": "end", + "freebusy": "free-busy", + "from": "start", + "room-id": "rooms", + "room-ids": "rooms", + "start-time": "start", + "subject": "title", + "summary": "title", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + "user-ids": "attendees", + "users": "attendees", + }, + Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "event", "event-id", "field-description", "group-id", "id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "rich-text-desc", "room", "room-name", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar +free", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "name": "who", + "person": "who", + "person-name": "who", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"date", "time", "user", "user-id", "user-ids", "users", "with"}, + }, + { + CLIPath: "calendar +free-slots", + Aliases: map[string]string{ + "day-offset": "in-days", + "days-from-today": "in-days", + "end-hour": "to", + "start-hour": "from", + }, + Blocked: []string{"days", "duration", "end", "end-time", "start", "start-time", "time-max", "time-min"}, + }, + { + CLIPath: "calendar +freebusy", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "room-id": "rooms", + "room-ids": "rooms", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "attendee-names", "date", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "staff-id", "time", "uid", "user", "user-id", "userid", "who", "with"}, + }, + { + CLIPath: "calendar +get", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + }, + Blocked: []string{"acl-id", "room-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +invite", + Aliases: map[string]string{ + "attendee-names": "with", + "calendar-event-id": "event", + "event-id": "event", + "id": "event", + "names": "with", + "participant-names": "with", + }, + Blocked: []string{"acl-id", "attendees", "calendar-book-id", "calendar-id", "open-dingtalk-ids", "participants", "room-id", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +my-free", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"date", "time"}, + }, + { + CLIPath: "calendar +reschedule", + Aliases: map[string]string{ + "begin": "start", + "calendar-event-id": "event", + "end-time": "end", + "event-id": "event", + "from": "start", + "id": "event", + "start-time": "start", + "to": "end", + }, + Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "date", "end-date", "room-id", "start-date", "time", "time-max", "time-min"}, + }, + { + CLIPath: "calendar +room-find", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "group": "group-id", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "name": "room-name", + "page-index": "page", + "page-size": "limit", + "per-page": "limit", + "query": "room-name", + "room-group-id": "group-id", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "date", "location", "next-cursor", "page-token", "room", "room-id", "room-ids", "rooms", "time"}, + }, + { + CLIPath: "calendar +room-groups", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "page-index": "page", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "page-token"}, + }, + { + CLIPath: "calendar +room-search", + Aliases: map[string]string{ + "name": "room-name", + "query": "room-name", + }, + Blocked: []string{"group-id", "location", "room", "room-id", "room-ids", "rooms"}, + }, + { + CLIPath: "calendar +rsvp", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + "response": "status", + "response-status": "status", + }, + Blocked: []string{"acl-id", "availability", "done", "free-busy", "room-id", "state"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +search-event", + Aliases: map[string]string{ + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search": "query", + "search-word": "query", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "name", "offset", "page", "page-index", "room-id", "subject", "text", "time", "title"}, + }, + { + CLIPath: "calendar +suggest-time", + Aliases: map[string]string{ + "attendee-names": "with", + "begin": "start", + "duration-minutes": "duration", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "meeting-duration-minutes": "duration", + "min-time": "start", + "names": "with", + "participant-names": "with", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"attendees", "date", "open-dingtalk-ids", "participants", "remind-minutes", "time", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +suggestion", + Aliases: map[string]string{ + "begin": "start", + "duration-minutes": "duration", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "meeting-duration-minutes": "duration", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "attendee-name", "attendee-names", "date", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "room-id", "room-ids", "room-name", "rooms", "staff-id", "time", "uid", "user", "user-id", "userid", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar +update", + Aliases: map[string]string{ + "add-user-ids": "add-attendees", + "add-users": "add-attendees", + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "description": "desc", + "end-time": "end", + "event-id": "event", + "freebusy": "free-busy", + "from": "start", + "remove-user-ids": "remove-attendees", + "remove-users": "remove-attendees", + "start-time": "start", + "subject": "title", + "summary": "title", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + }, + Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "field-description", "group-id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "reminder-minutes", "rich-text-desc", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"}, + Ambiguous: []string{"attendees", "id", "user-ids", "users"}, + }, + { + CLIPath: "calendar acl delete", + Blocked: []string{"calendar-book-id", "calendar-id", "event", "event-id", "room-id", "user-id"}, + }, + { + CLIPath: "calendar attachment add", + Blocked: []string{"attachments", "file", "file-id", "file-ids"}, + }, + { + CLIPath: "calendar attendee add", + Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"}, + }, + { + CLIPath: "calendar attendee delete", + Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"}, + }, + { + CLIPath: "calendar busy search", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"attendee-name", "attendee-names", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "who", "with"}, + }, + { + CLIPath: "calendar event create", + Aliases: map[string]string{ + "reminder-minutes": "remind-minutes", + "reminder-offset-minutes": "remind-minutes", + "room-id": "rooms", + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"at", "attendee-name", "attendee-names", "due", "duration", "group-id", "locale", "offset", "participant-name", "participant-names", "remind-at", "reminder-time", "room", "room-name", "utc-offset", "who", "with"}, + }, { CLIPath: "calendar event list", Aliases: map[string]string{ @@ -1789,6 +2222,54 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"offset", "page", "time"}, }, + { + CLIPath: "calendar event respond", + Aliases: map[string]string{ + "response": "status", + "response-status": "status", + }, + Blocked: []string{"availability", "done", "free-busy", "state"}, + }, + { + CLIPath: "calendar event suggest", + Aliases: map[string]string{ + "duration-minutes": "duration", + "meeting-duration-minutes": "duration", + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"attendee-name", "attendee-names", "from", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-names", "remind-minutes", "to", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar event update", + Aliases: map[string]string{ + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"attendees", "group-id", "locale", "offset", "participants", "remind-minutes", "reminder-minutes", "room-id", "room-ids", "room-name", "rooms", "utc-offset"}, + }, + { + CLIPath: "calendar room add", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"group-id", "location", "room", "room-name"}, + }, + { + CLIPath: "calendar room delete", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"group-id", "location", "room", "room-name"}, + }, + { + CLIPath: "calendar room search", + Aliases: map[string]string{ + "group": "group-id", + "room-group-id": "group-id", + }, + Blocked: []string{"location", "room", "room-id", "room-ids", "rooms"}, + }, { CLIPath: "chat +bot-find", Aliases: map[string]string{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 90570a9e..6aae2d0e 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,16 +10,16 @@ }, "concepts": { - "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed"],"risk":"green"}, + "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event"],"risk":"green"}, "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, - "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list"],"risk":"green"}, + "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event"],"risk":"green"}, "content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"}, - "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"}, - "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"}, + "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"}, + "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"}, "base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow list","aitable workflow run","aitable workflow update"],"risk":"green"}, "table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"}, - "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update"],"risk":"green"}, + "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update"],"risk":"green"}, "workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"}, "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, "dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"}, @@ -31,7 +31,7 @@ "open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"}, "referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"}, "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"}, - "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"}, + "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot", "calendar +freebusy", "calendar +suggestion"], "risk": "yellow"}, "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"}, @@ -54,7 +54,14 @@ "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, "creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"} + "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}, + "calendar_event_id": {"denotes":"single calendar eventId in the target-event role","canonical_hint":"event","members":["event","event-id","calendar-event-id"],"excludes":["id","calendar-id","calendar-book-id","acl-id","room-id"],"commands":["calendar +attendee-list","calendar +cancel-event","calendar +invite","calendar +reschedule","calendar +get","calendar +rsvp","calendar +update"],"risk":"yellow"}, + "calendar_book_id": {"denotes":"single calendar container calendarId, not an eventId","canonical_hint":"calendar-id","members":["calendar-id","calendar","calendar-book-id"],"excludes":["id","event","event-id","acl-id","room-id"],"commands":["calendar +agenda","calendar +attendee-list","calendar +create","calendar +get","calendar +rsvp","calendar +search-event","calendar +update"],"risk":"yellow"}, + "calendar_event_title": {"denotes":"calendar event title text","canonical_hint":"title","members":["title","summary","subject"],"excludes":["name","room-name","calendar-name"],"commands":["calendar +book","calendar +create","calendar +update"],"risk":"green"}, + "calendar_person_name_list": {"denotes":"comma-separated DingTalk person names resolved by the shortcut","canonical_hint":"with","members":["with","names","attendee-names","participant-names"],"excludes":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"commands":["calendar +book","calendar +invite","calendar +suggest-time"],"risk":"yellow"}, + "calendar_room_ids": {"denotes":"calendar meeting roomId list with unchanged element values; one roomId is a one-element list","canonical_hint":"rooms","members":["rooms","room-id","room-ids"],"excludes":["room","room-name","group-id","location"],"commands":["calendar event create","calendar room add","calendar room delete","calendar busy search","calendar +create","calendar +freebusy"],"risk":"yellow"}, + "calendar_duration_minutes": {"denotes":"meeting duration in minutes with unchanged numeric text","canonical_hint":"duration","members":["duration","duration-minutes","meeting-duration-minutes"],"excludes":["remind-minutes","start","end","from","to"],"commands":["calendar event suggest","calendar +suggest-time","calendar +suggestion"],"risk":"green"}, + "calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"} }, "command_overrides": { @@ -82,6 +89,40 @@ "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, + "calendar +agenda": {"block":["id"],"note":"calendarId aliases stay role-specific; bare --id could be an eventId and must not be silently treated as a calendarId. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar +attendee-list": {"ambiguous":["id"],"note":"This command carries both an eventId and an optional calendarId; bare --id cannot safely choose the role."}, + "calendar +book": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id","rooms","room-id","room-ids","date","time","start-date","end-date","time-min","time-max"],"note":"--with carries names resolved by the shortcut; identifier spellings and room booking inputs cannot be supplied by name-only normalization. Only explicit event-time spellings are mapped; range-bound or date-only spellings remain blocked.","scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"scope_strict":true}, + "calendar +book-search": {"scoped_aliases":{"search":"query","name":"query"},"scope_strict":true,"note":"On this exact calendar-book search, --search, --name, and --query carry the same calendar-name keyword."}, + "calendar +cancel-event": {"scoped_aliases":{"id":"event"},"block":["calendar-id","calendar-book-id","acl-id","room-id"],"scope_strict":true,"note":"The only identifier role is the eventId to cancel; other calendar resource IDs are rejected."}, + "calendar +conflicts": {"scoped_aliases":{"day-offset":"in-days","days-from-today":"in-days"},"block":["days","duration","start","end","from","to"],"scope_strict":true,"note":"--in-days is an integer offset from today. Exact day-offset spellings preserve the value; generic duration and absolute/range time names are different roles."}, + "calendar +free": {"block":["user","user-id","user-ids","users","with"],"note":"--who carries one person name for runtime resolution, so exact name/person spellings are command-scoped while stable user identifiers stay blocked. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end","name":"who","person":"who","person-name":"who"},"scope_strict":true}, + "calendar +free-slots": {"scoped_aliases":{"start-hour":"from","end-hour":"to","day-offset":"in-days","days-from-today":"in-days"},"block":["start","end","start-time","end-time","time-min","time-max","days","duration"],"scope_strict":true,"note":"--from/--to are integer hours and --in-days is a relative day offset; exact unit-preserving spellings are command-scoped while ISO timestamp and generic duration names stay blocked."}, + "calendar +freebusy": {"block":["who","with","name","names","attendee-names","participant-names","room-name","group-id"],"note":"This shortcut accepts stable userId/roomId lists; singular --room-id is one list element and normalizes safely, while names and room-search filters require different commands. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar +invite": {"scoped_aliases":{"id":"event"},"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id"],"scope_strict":true,"note":"--with is a name list resolved by the shortcut; stable person identifiers are not renamed into names."}, + "calendar +reschedule": {"scoped_aliases":{"id":"event","from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"block":["calendar-id","calendar-book-id","acl-id","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The only identifier role is the eventId being rescheduled. Only explicit new-event-time spellings are mapped; range-bound or date-only spellings remain blocked."}, + "calendar +room-groups": {"scoped_aliases":{"page-index":"page"},"block":["cursor","page-token"],"scope_strict":true,"note":"This endpoint uses a zero-based pageIndex; cursor pagination cannot be converted to it."}, + "calendar +room-search": {"scoped_aliases":{"query":"room-name","name":"room-name"},"block":["room","room-id","room-ids","rooms","group-id","location"],"scope_strict":true,"note":"This shortcut accepts a room display-name keyword and returns roomId; identifier/location inputs are different roles."}, + "calendar +suggest-time": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"note":"--with carries names resolved by the shortcut; stable identifier lists belong to event suggest/freebusy. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar acl delete": {"block":["calendar-id","calendar-book-id","event","event-id","room-id","user-id"],"note":"--acl-id identifies one access-control grant, not a calendar, event, room, or user."}, + "calendar attachment add": {"block":["file","file-id","file-ids","attachments"],"note":"--files requires : elements; a bare file identifier cannot be wrapped or named by the alias layer."}, + "calendar attendee add": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."}, + "calendar attendee delete": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."}, + "calendar busy search": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","room-name"],"note":"--users/--rooms are stable ID lists; singular --room-id safely denotes one list element, the existing hidden --room-ids compatibility stays native, and names require a resolver or room search."}, + "calendar event create": {"scoped_aliases":{"reminder-minutes":"remind-minutes","reminder-offset-minutes":"remind-minutes"},"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","room-name","group-id","reminder-time","remind-at","at","due","duration"],"scope_strict":true,"note":"Creation accepts stable attendee/room identifiers and minute offsets; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native. Reminder aliases are exact to this command; names, room search criteria, absolute reminder times, and generic duration need separate resolution or value transforms."}, + "calendar event respond": {"scoped_aliases":{"response-status":"status","response":"status"},"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The response/status flag-name aliases are exact to this command. --status still uses this leaf's own attendee-response enum; values are not translated, and busy/free state or boolean completion names stay blocked."}, + "calendar event suggest": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","open-dingtalk-ids"],"note":"--users carries stable userIds; name resolution belongs to +suggest-time."}, + "calendar event update": {"block":["attendees","participants","rooms","room-id","room-ids","room-name","group-id","remind-minutes","reminder-minutes"],"note":"The command's existing hidden --freebusy compatibility flag remains native. Attendee, room, and reminder changes require dedicated commands or are unsupported by update."}, + "calendar room add": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."}, + "calendar room delete": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."}, + "calendar room search": {"scoped_aliases":{"room-group-id":"group-id","group":"group-id"},"block":["room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"On this exact command, room-group-id/group denote the meeting-room group filter; search consumes room name/group/time filters and returns roomId, while existing room identifiers and free-text event location are different roles."}, + "calendar +my-free": {"scoped_aliases":{"to":"end"},"scope_strict":true,"note":"On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end."}, + "calendar +create": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","user-ids":"attendees","users":"attendees","attendee-ids":"attendees"},"block":["id","event","event-id","who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","group-id","rich-text-desc","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The shortcut accepts stable attendee userId and roomId lists plus one calendarId; singular --room-id is one list element and normalizes safely. Names, openDingTalkIds, event identifiers, room-search filters, and HTML rich text are different roles; --to and --freebusy are exact value-preserving spellings on this command."}, + "calendar +get": {"ambiguous":["id"],"note":"The command carries one required eventId and one optional calendarId, so bare --id cannot safely select a role."}, + "calendar +room-find": {"scoped_aliases":{"to":"end","page-index":"page","room-group-id":"group-id","group":"group-id","query":"room-name","name":"room-name"},"block":["cursor","page-token","next-cursor","room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"This shortcut combines ISO time bounds, a zero-based pageIndex, and room-name/group filters. Cursor pagination and existing room identifiers cannot be converted to these filter roles."}, + "calendar +rsvp": {"scoped_aliases":{"response-status":"status","response":"status"},"ambiguous":["id"],"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The command carries eventId and calendarId together; bare --id is ambiguous. Response/status aliases are exact to this leaf, but --status still uses +rsvp's own enum values; values are never translated, and busy/free state names stay blocked."}, + "calendar +search-event": {"scoped_aliases":{"to":"end","search":"query"},"block":["id","event","event-id","page","page-index","offset"],"scope_strict":true,"note":"The query searches title, description, and location within one calendar page. This route uses cursor pagination; event identifiers and page-index/offset models are different roles."}, + "calendar +suggestion": {"scoped_aliases":{"to":"end"},"block":["who","with","name","names","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","room-id","room-ids","rooms"],"scope_strict":true,"note":"This shortcut accepts stable userId values. Person names belong to +suggest-time, openDingTalkIds are a different value domain, and room availability is a different query."}, + "calendar +update": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","add-users":"add-attendees","add-user-ids":"add-attendees","remove-users":"remove-attendees","remove-user-ids":"remove-attendees"},"ambiguous":["id","attendees","users","user-ids"],"block":["who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","rooms","room-id","room-ids","room-name","group-id","rich-text-desc","remind-minutes","reminder-minutes","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"Bare --id cannot choose eventId versus calendarId, and a generic attendee list cannot choose add versus remove. Directional userId aliases are safe; names, openDingTalkIds, room changes, rich text, and reminders require different commands or capabilities."}, "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, "chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."}, "chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."}, @@ -695,7 +736,113 @@ {"command":"aitable workflow history","emitted":"page-index","expect":"page","via":"override:scoped-zero-based-page"}, {"command":"aitable workflow history","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-vs-cursor-model"}, {"command":"aitable workflow history","emitted":"offset","expect":"did-you-mean:blocked","via":"guard:page-vs-offset-model"}, - {"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"} + {"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"}, + {"command":"calendar +agenda","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +agenda","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +agenda","emitted":"max-results","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +agenda","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"calendar +agenda","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +agenda","emitted":"id","expect":"did-you-mean:blocked","via":"guard:calendar-id-vs-event-id"}, + {"command":"calendar +attendee-list","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +attendee-list","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +attendee-list","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +book","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +book","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +book","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +book-search","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"calendar +book-search","emitted":"search","expect":"query","via":"override:scoped_strict"}, + {"command":"calendar +book-search","emitted":"name","expect":"query","via":"override:scoped_strict"}, + {"command":"calendar +cancel-event","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +cancel-event","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +cancel-event","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +free","emitted":"name","expect":"who","via":"override:scoped_strict"}, + {"command":"calendar +free","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +free-slots","emitted":"start-hour","expect":"from","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"end-hour","expect":"to","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"start","expect":"did-you-mean:blocked","via":"guard:hour-vs-iso-time"}, + {"command":"calendar +freebusy","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"calendar +freebusy","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +freebusy","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +freebusy","emitted":"with","expect":"did-you-mean:blocked","via":"guard:id-list-vs-name-list"}, + {"command":"calendar +my-free","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +my-free","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +invite","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +invite","emitted":"participant-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +invite","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +reschedule","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +reschedule","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +reschedule","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +room-groups","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +room-groups","emitted":"page-index","expect":"page","via":"override:scoped_strict"}, + {"command":"calendar +room-groups","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"}, + {"command":"calendar +room-search","emitted":"query","expect":"room-name","via":"override:scoped_strict"}, + {"command":"calendar +room-search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar +suggest-time","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar +suggest-time","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +suggest-time","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +conflicts","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"}, + {"command":"calendar attachment add","emitted":"file-id","expect":"did-you-mean:blocked","via":"guard:structured-file-list"}, + {"command":"calendar attendee add","emitted":"attendee-name","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar busy search","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar busy search","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar event create","emitted":"reminder-minutes","expect":"remind-minutes","via":"override:scoped_strict"}, + {"command":"calendar event create","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar event create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar event create","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar event respond","emitted":"response-status","expect":"status","via":"override:scoped_strict"}, + {"command":"calendar event respond","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"}, + {"command":"calendar event suggest","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar event suggest","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar event update","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar event update","emitted":"rooms","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"}, + {"command":"calendar room add","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar room delete","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar room search","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"}, + {"command":"calendar room search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-filter-vs-room-id"}, + {"command":"calendar acl delete","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:acl-id-vs-calendar-id"}, + {"command":"calendar +create","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +create","emitted":"description","expect":"desc","via":"concept:plain_description"}, + {"command":"calendar +create","emitted":"user-ids","expect":"attendees","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +create","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +create","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +get","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +get","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +get","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +room-find","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +room-find","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +room-find","emitted":"page-index","expect":"page","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"query","expect":"room-name","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-index-vs-cursor"}, + {"command":"calendar +rsvp","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +rsvp","emitted":"response-status","expect":"status","via":"override:scoped_strict"}, + {"command":"calendar +rsvp","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +rsvp","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"}, + {"command":"calendar +search-event","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"calendar +search-event","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +search-event","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +search-event","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"calendar +search-event","emitted":"max-results","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +search-event","emitted":"page","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"}, + {"command":"calendar +suggestion","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"calendar +suggestion","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar +suggestion","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar +suggestion","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +update","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +update","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +update","emitted":"description","expect":"desc","via":"concept:plain_description"}, + {"command":"calendar +update","emitted":"add-user-ids","expect":"add-attendees","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"remove-user-ids","expect":"remove-attendees","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +update","emitted":"attendees","expect":"did-you-mean:ambiguous","via":"guard:add-vs-remove-role"}, + {"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"} ] } } From 57b5845eb30ec3cd03b50b01ac03589731b07257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 18 Aug 2026 22:30:51 +0800 Subject: [PATCH 128/267] fix(skills): verify content fingerprint in shell rollback --- scripts/install-skills.sh | 58 ++++++++++++++++--- scripts/install.sh | 61 +++++++++++++++++--- test/scripts/install_script_test.go | 88 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 14 deletions(-) diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 5ed3f101..820e5880 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -204,6 +204,20 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure so the identity fails instead of parsing a wrong token. +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + verify_release_asset_checksum() { _checksum_asset="$1" _checksum_path="$2" @@ -450,10 +464,12 @@ skill_link_inode() { printf '%s\n' "$_sli_entry" | awk '{print $1}' } -# Directory publication identity is inode plus a stable child-name list. -# Inode reuse on Linux overlayfs would otherwise make a concurrent directory -# look owned. The token has no colon so dest:identity manifest parsing stays -# dest="${entry%:*}" / identity="${entry##*:}". +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". skill_dir_identity() { _sdi_inode="$(skill_link_inode "$1")" || return 1 if [ -L "$1" ] || [ ! -d "$1" ]; then @@ -461,7 +477,34 @@ skill_dir_identity() { return 0 fi _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" - printf '%s,%s\n' "$_sdi_inode" "$_sdi_names" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree, mirroring the Go +# fingerprint: sorted relative paths emit type+permission bits plus either a +# content sha256 or the symlink target, and the whole line stream feeds +# sha256_stdin. Digest failures surface as a non-zero identity instead of a +# wrong-but-parseable token. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin } skill_link_matches() { @@ -505,8 +548,9 @@ cleanup_nested_staged_link() { # staging directory is created under the same umask as the claim, so their # modes match by construction). A failed child move relocates the children # back and removes only the claim. On success the destination's inode is -# recorded as : so rollback only ever deletes this -# transaction's object even when the filesystem recycles the claim inode. +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. publish_skill_dir_no_replace() { _psd_stage="$1" _psd_dest="$2" diff --git a/scripts/install.sh b/scripts/install.sh index 2beb6c93..300d5e95 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -222,6 +222,21 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure (the caller's pipeline fails the whole identity instead of +# producing a wrong-but-parseable token). +sha256_file() { + if need_cmd sha256sum; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd shasum; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd openssl; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + verify_release_asset_checksum() { _checksum_asset="$1" _checksum_path="$2" @@ -470,10 +485,12 @@ skill_link_inode() { printf '%s\n' "$_sli_entry" | awk '{print $1}' } -# Directory publication identity is inode plus a stable child-name list. -# Inode reuse on Linux overlayfs would otherwise make a concurrent directory -# look owned. The token has no colon so dest:identity manifest parsing stays -# dest="${entry%:*}" / identity="${entry##*:}". +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". skill_dir_identity() { _sdi_inode="$(skill_link_inode "$1")" || return 1 if [ -L "$1" ] || [ ! -d "$1" ]; then @@ -481,7 +498,36 @@ skill_dir_identity() { return 0 fi _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" - printf '%s,%s\n' "$_sdi_inode" "$_sdi_names" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree: for each path (LC_ALL=C +# sorted, relative) emit path, type+permission bits from ls -ld, and either +# the sha256 of the file content or the symlink target. Sorted-path lines are +# piped through sha256_stdin, so one opaque token covers renames, mode flips, +# content edits and link retargets — the same in-place-edit guarantee the Go +# fingerprint provides. Digest failures (missing hash tool, unreadable file) +# surface as a non-zero identity instead of a wrong-but-parseable token. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin } skill_link_matches() { @@ -525,8 +571,9 @@ cleanup_nested_staged_link() { # staging directory is created under the same umask as the claim, so their # modes match by construction). A failed child move relocates the children # back and removes only the claim. On success the destination's inode is -# recorded as : so rollback only ever deletes this -# transaction's object even when the filesystem recycles the claim inode. +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. publish_skill_dir_no_replace() { _psd_stage="$1" _psd_dest="$2" diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 300d2c0f..04d967b0 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -2553,6 +2553,94 @@ fi } } +// TestInstallerShellRollbackPreservesInPlaceEditedSkill pins the P1 contract: +// a published Skill directory whose file contents were edited in place (same +// directory inode, same child names) must not be deleted by the rollback of a +// later failed publication. The recorded identity carries a recursive content +// digest, so the edit makes the dest provably not-this-transaction's object. +func TestInstallerShellRollbackPreservesInPlaceEditedSkill(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "references", "guide.md"), []byte("guide\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inside the second publication's child move — after the first + // skill has been published and its identity recorded — edit a file + // inside the first destination in place (append keeps the inode, + // directory inode and child names are untouched), then fail the + // transaction. Rollback must detect the content drift through the + // recorded digest and leave the user's edit alone. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + printf 'user-edit\n' >> "$DWS_TEST_FIRST/references/guide.md" + return 1 + fi + ;; + esac + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s in-place edit harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip in-place-edited rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "references", "guide.md")) + if err != nil || string(got) != "guide\nuser-edit\n" { + t.Fatalf("%s in-place user edit was lost: %q, %v", scriptName, string(got), err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { From 3dbd29ab50516fbf80cf7fe0e0a69b800a2e23d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Tue, 18 Aug 2026 23:59:15 +0800 Subject: [PATCH 129/267] feat(chat): add personal emotion commands --- .changes/chat-personal-emotion.md | 5 + docs/command-index.md | 9 +- internal/app/schema_completeness_test.go | 3 + internal/app/schema_shortcut_contract_test.go | 53 +++- internal/helpers/chat.go | 2 +- internal/helpers/chat_personal_emotion.go | 253 ++++++++++++++++++ .../helpers/chat_personal_emotion_test.go | 220 +++++++++++++++ skills/mono/references/products/chat.md | 49 ++++ skills/multi/dingtalk-chat/SKILL.md | 3 + skills/multi/dingtalk-chat/references/chat.md | 2 + 10 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 .changes/chat-personal-emotion.md create mode 100644 internal/helpers/chat_personal_emotion.go create mode 100644 internal/helpers/chat_personal_emotion_test.go diff --git a/.changes/chat-personal-emotion.md b/.changes/chat-personal-emotion.md new file mode 100644 index 00000000..ed7e9e04 --- /dev/null +++ b/.changes/chat-personal-emotion.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting. diff --git a/docs/command-index.md b/docs/command-index.md index a1cd90d8..a4b1d1e4 100644 --- a/docs/command-index.md +++ b/docs/command-index.md @@ -3,7 +3,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration. - **Products**: 13 -- **Total commands**: 160 +- **Total commands**: 163 - **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime. > Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file. @@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma - [`dws aitable` — AI Tables](#dws-aitable) · 41 commands - [`dws attendance` — Attendance](#dws-attendance) · 4 commands - [`dws calendar` — Calendar](#dws-calendar) · 14 commands -- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands +- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands - [`dws contact` — Contact Directory](#dws-contact) · 6 commands - [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands - [`dws ding` — DING Messages](#dws-ding) · 2 commands @@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._ _Group chats, conversations, messages, and robot/webhook integrations._ -**23 commands** +**26 commands** | Command | Description | When to use | |---|---|---| | `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. | | `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. | +| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. | +| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. | +| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. | | `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. | | `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. | | `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. | diff --git a/internal/app/schema_completeness_test.go b/internal/app/schema_completeness_test.go index 6346b0fd..a412c778 100644 --- a/internal/app/schema_completeness_test.go +++ b/internal/app/schema_completeness_test.go @@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string { "chat clear-messages", "chat clear-red-point", "chat data-auth cross-org", + "chat emotion favorite", + "chat emotion list", + "chat emotion send", "chat group audit-join-validation", "chat group list-all", "chat group list-join-validations", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index a4c5d83e..b4fe5fff 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) product := executeShortcutSchemaQuery(t, "chat") productPayload, _ := product["product"].(map[string]any) - if got, want := int(product["count"].(float64)), 217; got != want { + if got, want := int(product["count"].(float64)), 220; got != want { t.Fatalf("schema chat count = %d, want %d", got, want) } summaries := schemaContractObjectSlice(productPayload["tools"]) @@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) assertChatCatalogCompleteLeafContracts(t) } +func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) { + for _, tc := range []struct { + cliPath string + params map[string]string + }{ + { + cliPath: "chat emotion list", + }, + { + cliPath: "chat emotion send", + params: map[string]string{ + "media-id": "mediaId", + "emotion-id": "emotionId", + "group": "openConversationId", + "open-dingtalk-id": "receiverOpenDingTalkId", + "idempotency-key": "uuid", + }, + }, + { + cliPath: "chat emotion favorite", + params: map[string]string{ + "media-id": "mediaId", + "name": "name", + "source-conversation-id": "sourceConversationId", + "source-message-id": "sourceMessageId", + }, + }, + } { + t.Run(tc.cliPath, func(t *testing.T) { + leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath) + if got := schemaContractString(leaf["interface_mode"]); got != "composite" { + t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got) + } + reason := schemaContractString(leaf["interface_reason"]) + if !strings.Contains(reason, "Reviewed unpinned remote adapter") { + t.Fatalf("%s interface_reason = %q", tc.cliPath, reason) + } + parameters := schemaContractMap(leaf["parameters"]) + for name, want := range tc.params { + parameter := parameters[name] + if parameter == nil { + t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters) + } + if got := schemaContractString(parameter["property"]); got != want { + t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want) + } + } + }) + } +} + func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) { leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap") result, _ := leaf["result"].(map[string]any) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index af6b16b1..a07e6289 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -10528,7 +10528,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd) chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd) - root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand()) + root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand()) // Keep the v1.0.56 command surface recognizable while directing callers to // the supported nested commands. The chat root's "im" alias makes these diff --git a/internal/helpers/chat_personal_emotion.go b/internal/helpers/chat_personal_emotion.go new file mode 100644 index 00000000..eb46fe67 --- /dev/null +++ b/internal/helpers/chat_personal_emotion.go @@ -0,0 +1,253 @@ +package helpers + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" + "github.com/spf13/cobra" +) + +const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." + +func newChatEmotionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "emotion", + Short: "个人收藏表情", + Long: "查询、发送和新增当前用户的个人收藏表情。", + RunE: groupRunE, + } + cmd.AddCommand( + newChatEmotionListCommand(), + newChatEmotionSendCommand(), + newChatEmotionFavoriteCommand(), + ) + return cmd +} + +func newChatEmotionListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "列出个人收藏表情", + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{}) + }, + } + DeclareLeafMetadata(cmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "chat", Name: "list_personal_emotions", + CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list", + }, + Description: "列出当前用户的个人收藏表情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "列出当前用户的个人收藏表情", + UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"}, + AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"}, + Examples: []string{"dws chat emotion list --format json"}, + }, + }, + }) + return cmd +} + +func newChatEmotionSendCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "send", + Short: "发送个人收藏表情", + Long: `发送当前用户的个人收藏表情。 + +⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`, + Example: ` dws chat emotion send --media-id --group + dws chat emotion send --media-id --emotion-id --user + dws chat emotion send --media-id --open-dingtalk-id --uuid `, + RunE: func(cmd *cobra.Command, args []string) error { + mediaID, _ := cmd.Flags().GetString("media-id") + if strings.TrimSpace(mediaID) == "" { + return fmt.Errorf("--media-id is required") + } + target, err := personalEmotionSendTarget(cmd) + if err != nil { + return err + } + payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)} + emotionID, _ := cmd.Flags().GetString("emotion-id") + if strings.TrimSpace(emotionID) != "" { + payload["emotionId"] = strings.TrimSpace(emotionID) + } + for key, value := range target { + payload[key] = value + } + if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" { + payload["uuid"] = uuid + } + return callMCPToolOnServer("im", "send_personal_emotion", payload) + }, + } + cmd.Flags().String("media-id", "", "表情媒体 ID (必填)") + cmd.Flags().String("emotion-id", "", "表情 ID") + cmd.Flags().String("conversation-id", "", "群聊 openConversationId") + cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)") + cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId") + cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId") + cmd.Flags().String("uuid", "", "幂等键") + cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)") + cmd.MarkFlagsMutuallyExclusive("conversation-id", "group") + cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}}, + RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}}, + }) + DeclareLeafMetadata(cmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: personalEmotionSendContract(), + }) + return cmd +} + +func newChatEmotionFavoriteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "favorite", + Short: "新增个人收藏表情", + Example: ` dws chat emotion favorite --media-id --name "赞" + dws chat emotion favorite --media-id --source-conversation-id --source-message-id `, + RunE: func(cmd *cobra.Command, args []string) error { + mediaID, _ := cmd.Flags().GetString("media-id") + if strings.TrimSpace(mediaID) == "" { + return fmt.Errorf("--media-id is required") + } + sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id") + sourceMessageID, _ := cmd.Flags().GetString("source-message-id") + if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil { + return err + } + payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)} + name, _ := cmd.Flags().GetString("name") + if strings.TrimSpace(name) != "" { + payload["name"] = strings.TrimSpace(name) + } + if strings.TrimSpace(sourceConversationID) != "" { + payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID) + payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID) + } + return callMCPToolOnServer("im", "favorite_personal_emotion", payload) + }, + } + cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)") + cmd.Flags().String("name", "", "表情名称") + cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定") + cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定") + DeclareLeafMetadata(cmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: personalEmotionFavoriteContract(), + }) + return cmd +} + +func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) { + groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group")) + userID, _ := cmd.Flags().GetString("user") + openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") + userID = strings.TrimSpace(userID) + openDingTalkID = strings.TrimSpace(openDingTalkID) + specified := 0 + for _, value := range []string{groupID, userID, openDingTalkID} { + if value != "" { + specified++ + } + } + if specified != 1 { + return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one") + } + if groupID != "" { + return map[string]any{"openConversationId": groupID}, nil + } + if openDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { + return nil, err + } + return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil + } + if isOpenDingTalkID(userID) { + return map[string]any{"receiverOpenDingTalkId": userID}, nil + } + resolved, err := resolveOpenDingTalkID(cmd.Context(), userID) + if err != nil { + return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err) + } + return map[string]any{"receiverOpenDingTalkId": resolved}, nil +} + +func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error { + hasConversation := strings.TrimSpace(sourceConversationID) != "" + hasMessage := strings.TrimSpace(sourceMessageID) != "" + if hasConversation != hasMessage { + return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together") + } + return nil +} + +func personalEmotionSendContract() LeafContract { + return LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "chat", Name: "send_personal_emotion", + CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send", + }, + Description: "以当前用户身份向群聊或单聊发送个人收藏表情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "以当前用户身份发送个人收藏表情", + UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"}, + AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"}, + Examples: []string{"dws chat emotion send --media-id --group --uuid "}, + }, + Parameters: []contract.ParamDecl{ + {Name: "media-id", Property: "mediaId", Required: boolPtr(true)}, + {Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, + {Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)}, + {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)}, + {Name: "uuid", Property: "uuid", Required: boolPtr(false)}, + {Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)}, + }, + } +} + +func personalEmotionFavoriteContract() LeafContract { + return LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "chat", Name: "favorite_personal_emotion", + CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite", + }, + Description: "将 mediaId 新增到当前用户的个人收藏表情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "新增当前用户的个人收藏表情", + UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"}, + AvoidWhen: []string{"收藏消息使用 chat message add-favorite"}, + Examples: []string{"dws chat emotion favorite --media-id --name \"赞\""}, + }, + Parameters: []contract.ParamDecl{ + {Name: "media-id", Property: "mediaId", Required: boolPtr(true)}, + {Name: "name", Property: "name", Required: boolPtr(false)}, + {Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"}, + {Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"}, + }, + } +} diff --git a/internal/helpers/chat_personal_emotion_test.go b/internal/helpers/chat_personal_emotion_test.go new file mode 100644 index 00000000..e5b212e1 --- /dev/null +++ b/internal/helpers/chat_personal_emotion_test.go @@ -0,0 +1,220 @@ +package helpers + +import ( + "context" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type personalEmotionCall struct { + server string + tool string + args map[string]any +} + +type personalEmotionCaller struct { + calls []personalEmotionCall +} + +func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + copied := make(map[string]any, len(args)) + for key, value := range args { + copied[key] = value + } + c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied}) + return textToolResult(`{"ok":true}`), nil +} + +func (*personalEmotionCaller) Format() string { return "json" } +func (*personalEmotionCaller) DryRun() bool { return false } +func (*personalEmotionCaller) Fields() string { return "" } +func (*personalEmotionCaller) JQ() string { return "" } + +func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error { + t.Helper() + installHelpersCoreDeps(t, caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + root := newChatCommand() + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + return root.ExecuteContext(context.Background()) +} + +func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) { + t.Helper() + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls) + } + call := caller.calls[0] + if call.server != "im" || call.tool != tool { + t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool) + } + if !reflect.DeepEqual(call.args, want) { + t.Fatalf("args = %#v, want %#v", call.args, want) + } +} + +func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) { + // TC-001: list 无业务参数,当前用户身份由 MCP server 注入。 + caller := &personalEmotionCaller{} + if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil { + t.Fatalf("chat emotion list returned error: %v", err) + } + requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{}) +} + +func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) { + // TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。 + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, + "emotion", "send", + "--media-id", "@media", + "--emotion-id", "emotion123", + "--group", "cid123", + "--idempotency-key", "idem-001", + ) + if err != nil { + t.Fatalf("chat emotion send returned error: %v", err) + } + requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{ + "mediaId": "@media", + "emotionId": "emotion123", + "openConversationId": "cid123", + "uuid": "idem-001", + }) +} + +func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) { + // TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。 + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, + "emotion", "send", + "--media-id", "@media", + "--open-dingtalk-id", helperCurrentDOpenID, + ) + if err != nil { + t.Fatalf("chat emotion send returned error: %v", err) + } + requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{ + "mediaId": "@media", + "receiverOpenDingTalkId": helperCurrentDOpenID, + }) +} + +func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) { + // TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。 + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, + "emotion", "send", + "--media-id", "@media", + "--user", helperCurrentDOpenID, + ) + if err != nil { + t.Fatalf("chat emotion send returned error: %v", err) + } + requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{ + "mediaId": "@media", + "receiverOpenDingTalkId": helperCurrentDOpenID, + }) +} + +func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing target", + args: []string{"emotion", "send", "--media-id", "@media"}, + wantErr: "specify exactly one", + }, + { + name: "multiple targets", + args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID}, + wantErr: "specify exactly one", + }, + { + name: "missing media", + args: []string{"emotion", "send", "--group", "cid"}, + wantErr: "--media-id is required", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tc.wantErr) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid command reached MCP: %+v", caller.calls) + } + }) + } +} + +func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) { + // TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。 + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, + "emotion", "favorite", + "--media-id", "@media", + "--name", "赞", + "--source-conversation-id", "cid123", + "--source-message-id", "msg123", + ) + if err != nil { + t.Fatalf("chat emotion favorite returned error: %v", err) + } + requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{ + "mediaId": "@media", + "name": "赞", + "sourceConversationId": "cid123", + "sourceMessageId": "msg123", + }) +} + +func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing media", + args: []string{"emotion", "favorite", "--name", "赞"}, + wantErr: "--media-id is required", + }, + { + name: "source conversation only", + args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"}, + wantErr: "must be specified together", + }, + { + name: "source message only", + args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"}, + wantErr: "must be specified together", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tc.wantErr) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid command reached MCP: %+v", caller.calls) + } + }) + } +} diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 5fbff337..22157bc8 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -1212,6 +1212,51 @@ Flags: --msg-ids string 消息 ID 列表,逗号分隔 (必填) ``` +### emotion (个人收藏表情) + +#### 列出个人收藏表情 +``` +Usage: + dws chat emotion list [flags] +Example: + dws chat emotion list --format json +``` +当前用户身份由 MCP server 注入,无业务参数。 + +#### 发送个人收藏表情 +``` +Usage: + dws chat emotion send [flags] +Example: + dws chat emotion send --media-id --group + dws chat emotion send --media-id --emotion-id --user + dws chat emotion send --media-id --open-dingtalk-id --uuid +Flags: + --media-id string 表情媒体 ID (必填) + --emotion-id string 表情 ID (可选) + --conversation-id string 群聊 openConversationId(与 --group 等价) + --group string 群聊 openConversationId + --user string 单聊接收人 userId,CLI 会解析为 openDingTalkId + --open-dingtalk-id string 单聊接收人 openDingTalkId + --uuid string 幂等键 + --idempotency-key string 幂等键(--uuid 等价) +``` +目标参数 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一。该命令会真实发送表情,执行前必须确认目标和 mediaId。 + +#### 新增个人收藏表情 +``` +Usage: + dws chat emotion favorite [flags] +Example: + dws chat emotion favorite --media-id --name "赞" + dws chat emotion favorite --media-id --source-conversation-id --source-message-id +Flags: + --media-id string 待收藏 mediaId (必填) + --name string 表情名称 + --source-conversation-id string 来源会话 ID,需与 --source-message-id 成对指定 + --source-message-id string 来源消息 ID,需与 --source-conversation-id 成对指定 +``` + ### list-top-conversations (置顶会话) #### 拉取置顶会话列表 @@ -2129,6 +2174,9 @@ Flags: 用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list` 用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids` 用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies` +用户说"查看个人收藏表情/列出我的收藏表情" → `chat emotion list` +用户说"发送个人收藏表情/发表情包" → `chat emotion send` +用户说"收藏表情/新增个人收藏表情" → `chat emotion favorite` 用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji` 用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji` 用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion` @@ -2200,6 +2248,7 @@ Flags: - `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复 - `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应 - `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用 +- `chat emotion list` / `send` / `favorite` — 当前用户个人收藏表情列表、发送和新增;不同于消息 reaction/文字回应 - `chat category list` — 获取用户自定义会话分组列表 - `chat category list-conversations` — 拉取指定分组下的会话列表 - `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index d1e62ea1..cf63ec02 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -47,6 +47,9 @@ metadata: | 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | | 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | | 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | +| 查看个人收藏表情 | `dws chat emotion list` | 当前用户身份由 MCP server 注入 | +| 发送个人收藏表情 | `dws chat emotion send --media-id --group ` | 目标为 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 会解析为 openDingTalkId | +| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | `--source-conversation-id` 与 `--source-message-id` 必须成对指定 | | 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index cb46df88..3b8e710f 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -57,8 +57,10 @@ | `message add-emoji` / `remove-emoji` | 默认 emoji reaction | | `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 | | `message list-emotion-replies` | 批量 reaction/文字回应 | +| `emotion list` / `send` / `favorite` | 当前用户个人收藏表情列表、发送和新增 | Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。 +个人收藏表情与消息 reaction/文字回应不同;发送收藏表情使用 `chat emotion send`,给已有消息贴表情使用 `chat message add-emoji` 或 `chat message add-text-emotion`。 ## 群与成员底层能力 From 15d289d3f3de602ea9c8dd9c11053dca8a5a1370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 00:15:27 +0800 Subject: [PATCH 130/267] fix(skills): keep sibling backups when marker write fails --- build/npm/install.js | 18 +- internal/upgrade/paths.go | 21 +- internal/upgrade/paths_multi_test.go | 48 +++ scripts/install-devapp.sh | 22 +- scripts/install-event.sh | 22 +- scripts/install-skills.sh | 26 +- scripts/install.sh | 27 +- test/scripts/install_script_test.go | 552 +++++++++++++++++++++++++++ 8 files changed, 716 insertions(+), 20 deletions(-) diff --git a/build/npm/install.js b/build/npm/install.js index ac74bb8d..cb53ea56 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -619,11 +619,11 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { // leave an unmarked (never-prunable) stamp behind. writeSkillBackupMarker(targetRoot); } catch (err) { - // Never leave an empty unowned stamp root behind. The removal is - // deliberately non-recursive so a pre-existing non-empty root (foreign - // data) is never destroyed. + // Never leave an empty unowned stamp root behind. rmdirSync removes an + // empty root but deliberately fails on a non-empty one, so a pre-existing + // root holding foreign data is never destroyed. try { - fs.rmSync(targetRoot, { force: true }); + fs.rmdirSync(targetRoot); } catch { // Non-empty pre-existing root: foreign data stays. } @@ -1041,8 +1041,14 @@ function rollbackPublishedSkillPath(publication, options = {}) { } } catch (err) { if (err && err.code === "ENOENT") { - cleanupRoot(); - return; + // ENOENT from the fingerprint walk means the destination itself is + // gone only when it no longer exists lexically; a concurrently removed + // CHILD produces the same error and must fall through to the refusal + // below instead of skipping rollback. + if (!pathExistsLexicallySync(destination)) { + cleanupRoot(); + return; + } } if (String(err.message || "").startsWith("refusing to delete non-transaction Skill") || String(err.message || "").startsWith("failed to clean Skill rollback quarantine")) { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 88df2324..57233a7f 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -233,11 +233,30 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { } } rememberSkillBackupRoot(backupRoot) + // The stamp only has second precision and the collision loop dedupes on + // the payload target, not on the root, so a second skill dir backed up in + // the same second shares this root. Record whether this call created the + // root: marker-failure cleanup must never delete a root that may already + // hold a sibling's moved payload — its original is gone, so that loss + // would be unrecoverable. + fresh := false + if _, statErr := upgradeLstat(backupRoot); statErr != nil && os.IsNotExist(statErr) { + fresh = true + } if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) } if err := writeSkillBackupMarker(backupRoot); err != nil { - _ = upgradeRemoveAll(backupRoot) + // Clean up only a root this call created (empty by construction) and + // only non-recursively: os.Remove refuses a non-empty root, which is + // the intended guard against a concurrent writer or a sibling payload + // already moved into a shared root. A pre-existing root is foreign + // from this call's perspective and is left untouched. The victim was + // never moved, so it stays in place. + if fresh { + _ = os.Remove(filepath.Join(backupRoot, skillBackupMarkerFile)) + _ = os.Remove(backupRoot) + } return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err) } if err := moveSkillPathRecoverably(dir, target); err != nil { diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 2790bdf3..58f2e660 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -964,6 +964,54 @@ func TestCrossPlatformCoverageSkillBackupMarkerOwnership(t *testing.T) { t.Fatalf("victim must stay in place: %v", statErr) } }) + + t.Run("marker write failure preserves a sibling backup in the same stamp", func(t *testing.T) { + home := t.TempDir() + // Pin one stamp: a second skill dir backed up in the same second + // shares the root with the first (collision dedupe is per payload). + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260818-010101" }) + newVictim := func(name, body string) string { + victim := filepath.Join(home, ".claude", "skills", name) + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return victim + } + victimA := newVictim("dingtalk-a", "payload-a") + victimB := newVictim("dingtalk-b", "payload-b") + backupA, err := backupAndRemoveSkillDir(home, victimA) + if err != nil { + t.Fatal(err) + } + root := filepath.Dir(backupA) + + testseam.Swap(t, &upgradeWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write denied") }) + backupB, err := backupAndRemoveSkillDir(home, victimB) + testseam.Swap(t, &upgradeWriteFile, os.WriteFile) + if err == nil || !strings.Contains(err.Error(), "备份所有权标记") { + t.Fatalf("marker write failure must fail the backup, got %v", err) + } + if backupB != "" { + t.Fatalf("failed backup must not return a path: %q", backupB) + } + // The root existed before B's call, so its contents — including A's + // already-moved payload, the only remaining copy — must be untouched. + if body, readErr := os.ReadFile(filepath.Join(backupA, "SKILL.md")); readErr != nil || string(body) != "payload-a" { + t.Fatalf("sibling backup must survive, body = %q, err = %v", body, readErr) + } + if _, statErr := os.Stat(root); statErr != nil { + t.Fatalf("stamp root must survive: %v", statErr) + } + if body, readErr := os.ReadFile(filepath.Join(root, skillBackupMarkerFile)); readErr != nil || string(body) != skillBackupMarkerBody { + t.Fatalf("ownership marker must survive, body = %q, err = %v", body, readErr) + } + if body, readErr := os.ReadFile(filepath.Join(victimB, "SKILL.md")); readErr != nil || string(body) != "payload-b" { + t.Fatalf("victim B must stay in place, body = %q, err = %v", body, readErr) + } + }) } func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index d5716fd5..4859dcf7 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -72,7 +72,13 @@ copy_tree() { fi for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - mv "$_ct_child" "$stage/" || true + # Bounded failure like install.sh's publish rollback: a child that + # cannot move back leaves dest occupied; stop instead of swallowing + # the error so the restore below reports the failed rollback. + if ! mv "$_ct_child" "$stage/"; then + _ct_failed=1 + break + fi done rmdir "$dest" 2>/dev/null || true fi @@ -148,12 +154,24 @@ backup_skill_dir() { fi done record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 mkdir -p "$backup_root" || return 1 # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a # stamp-shaped name alone is not evidence, so pruning only ever removes # roots carrying this marker — it must exist before the payload moves in. printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { - rm -rf "$backup_root" + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi return 1 } mv "$victim" "$backup" || return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 0665461d..c367edbb 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -137,7 +137,13 @@ copy_tree() { fi for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - mv "$_ct_child" "$stage/" || true + # Bounded failure like install.sh's publish rollback: a child that + # cannot move back leaves dest occupied; stop instead of swallowing + # the error so the restore below reports the failed rollback. + if ! mv "$_ct_child" "$stage/"; then + _ct_failed=1 + break + fi done rmdir "$dest" 2>/dev/null || true fi @@ -217,12 +223,24 @@ backup_skill_dir() { fi done record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 mkdir -p "$backup_root" || return 1 # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a # stamp-shaped name alone is not evidence, so pruning only ever removes # roots carrying this marker — it must exist before the payload moves in. printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { - rm -rf "$backup_root" + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi return 1 } mv "$victim" "$backup" || return 1 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 820e5880..8a95c930 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -69,6 +69,11 @@ backup_and_remove_skill_dir() { done _bed_stamp_root="$(dirname "$_bed_target")" record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 mkdir -p "$_bed_stamp_root" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 @@ -77,7 +82,14 @@ backup_and_remove_skill_dir() { # stamp-shaped name alone is not evidence, so pruning only ever removes # roots carrying this marker — it must exist before any payload moves in. printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { - rm -rf "$_bed_stamp_root" + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir" return 1 } @@ -205,7 +217,10 @@ sha256_stdin() { } # sha256_file : content digest used by skill_dir_content_digest. Never -# emits on failure so the identity fails instead of parsing a wrong token. +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' @@ -484,8 +499,11 @@ skill_dir_identity() { # Recursive content digest over a published tree, mirroring the Go # fingerprint: sorted relative paths emit type+permission bits plus either a # content sha256 or the symlink target, and the whole line stream feeds -# sha256_stdin. Digest failures surface as a non-zero identity instead of a -# wrong-but-parseable token. +# sha256_stdin. Failure detection is asymmetric and best-effort: with no hash +# tool installed sha256_stdin itself returns 1 and the identity genuinely +# fails, but an unreadable file only exits the left-hand subshell — /bin/sh +# has no pipefail, so the pipeline returns sha256_stdin's status and the +# digest is computed over a truncated stream instead of failing. skill_dir_content_digest() { ( cd "$1" 2>/dev/null || exit 1 diff --git a/scripts/install.sh b/scripts/install.sh index 300d5e95..542a1530 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -87,6 +87,11 @@ backup_and_remove_skill_dir() { done _bed_stamp_root="$(dirname "$_bed_target")" record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 mkdir -p "$_bed_stamp_root" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 @@ -95,7 +100,14 @@ backup_and_remove_skill_dir() { # stamp-shaped name alone is not evidence, so pruning only ever removes # roots carrying this marker — it must exist before any payload moves in. printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { - rm -rf "$_bed_stamp_root" + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi say " ⚠️ 无法写入备份所有权标记,保留原目录 $_bed_dir" return 1 } @@ -223,8 +235,10 @@ sha256_stdin() { } # sha256_file : content digest used by skill_dir_content_digest. Never -# emits on failure (the caller's pipeline fails the whole identity instead of -# producing a wrong-but-parseable token). +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. sha256_file() { if need_cmd sha256sum; then sha256sum "$1" 2>/dev/null | awk '{print $1}' @@ -507,8 +521,11 @@ skill_dir_identity() { # the sha256 of the file content or the symlink target. Sorted-path lines are # piped through sha256_stdin, so one opaque token covers renames, mode flips, # content edits and link retargets — the same in-place-edit guarantee the Go -# fingerprint provides. Digest failures (missing hash tool, unreadable file) -# surface as a non-zero identity instead of a wrong-but-parseable token. +# fingerprint provides. Failure detection is asymmetric and best-effort: with +# no hash tool installed sha256_stdin itself returns 1 and the identity +# genuinely fails, but an unreadable file only exits the left-hand subshell — +# /bin/sh has no pipefail, so the pipeline returns sha256_stdin's status and +# the digest is computed over a truncated stream instead of failing. skill_dir_content_digest() { ( cd "$1" 2>/dev/null || exit 1 diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 04d967b0..285694cb 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -294,6 +294,39 @@ func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { t.Errorf("install-devapp.ps1 missing junction-safe removal contract %q", want) } } + + // The backup prune must also be reparse-safe: a stamp tree can hold + // junction/symlink entries at any depth (victims are collected before + // the physical-equality filter), and Windows PowerShell 5.1 follows + // reparse points during Remove-Item -Recurse. + for scriptName, pruneFn := range map[string]string{ + "install.ps1": "Remove-OldSkillBackups", + "install-devapp.ps1": "Remove-OldDevSkillBackups", + } { + scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptName, err) + } + body := extractPowerShellFunction(t, string(scriptData), pruneFn) + if strings.Contains(body, "-Recurse") { + t.Errorf("%s %s must delegate to child-first lexical removal, not Remove-Item -Recurse:\n%s", scriptName, pruneFn, body) + } + } +} + +// extractPowerShellFunction returns a `function Name { ... }` body from a +// PowerShell script, terminated by the next top-level function declaration. +func extractPowerShellFunction(t *testing.T, text, name string) string { + t.Helper() + begin := strings.Index(text, "function "+name) + if begin < 0 { + t.Fatalf("function %s not found", name) + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + end = len(text) - begin - 1 + } + return text[begin : begin+1+end] } func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { @@ -778,6 +811,364 @@ exit 0 assertSkillBackupOwnershipResult(t, home) } +// lookPowerShellForScripts prefers Windows PowerShell 5.1 (the advertised +// irm|iex surface) on Windows and falls back to pwsh everywhere. +func lookPowerShellForScripts(t *testing.T) string { + t.Helper() + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + return candidate + } + } + t.Skip("PowerShell is not available") + return "" +} + +// TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets pins +// the Blocker 2 fix: pruning a stamp root that contains a junction/symlink +// child must delete the link itself while the link target's contents survive. +// Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse, +// so the prune removes children lexically, recursing real directories only. +func TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Junctions need no privilege on Windows but silently no-op on non-Windows + // pwsh builds; symlinks need no privilege on Unix. Exit 4 marks "no link + // flavor testable on this host". + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +# The oldest of six verified stamps is prunable and holds a link child. +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-SkillBackupMarker -Root $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-Path -LiteralPath $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +if (Test-Path -LiteralPath $oldest) { exit 5 } +if (!(Test-Path -LiteralPath (Join-Path $precious 'target-data.txt') -PathType Leaf)) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + surviving = append(surviving, entry.Name()) + } + want := []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} + if len(surviving) != len(want) { + t.Fatalf("after pruning the link-bearing stamp, surviving stamps = %v, want %v", surviving, want) + } + for _, stamp := range want { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets +// is the install-devapp.ps1 twin of the link-aware prune contract above. +func TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-DevSkillBackupMarker $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-PathLexically $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +if (Test-PathLexically $oldest) { exit 5 } +if (!(Test-PathLexically (Join-Path $precious 'target-data.txt'))) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell devapp link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + for _, stamp := range []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot pins the +// collision-loop fix: a stamp root that exists without the verified +// ownership marker is foreign data, so Backup-SkillDir bumps to a -N root +// instead of writing its marker (and later prune eligibility) into it. +// Foreign roots are seeded for the current second and the next nine so the +// stamp Backup-SkillDir picks is always a pre-existing unverified root. +func TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +$now = [DateTime]::UtcNow +$seeded = @() +for ($i = 0; $i -lt 10; $i++) { + $stamp = $now.AddSeconds($i).ToString('yyyyMMdd-HHmmss') + $seeded += $stamp + $foreign = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path $foreign -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $foreign 'user-data.txt'), 'foreign') +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), 'old copy') +$backupPath = '' +if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { exit 2 } +if (!$backupPath) { exit 3 } +foreach ($stamp in $seeded) { + $foreign = Join-Path $backupRoot $stamp + if (Test-Path -LiteralPath (Join-Path $foreign '.dws-skill-backup')) { exit 4 } + $children = @(Get-ChildItem -LiteralPath $foreign -Force | ForEach-Object { $_.Name }) + if ($children.Count -ne 1 -or $children[0] -ne 'user-data.txt') { exit 5 } +} +# Every plain stamp root was foreign, so the payload must sit in a -N root. +if ($backupPath -notmatch '-[0-9]+[\\/]\.zcode-skills-dingtalk-chat$') { exit 6 } +if (!(Test-Path -LiteralPath (Join-Path $backupPath 'SKILL.md') -PathType Leaf)) { exit 7 } +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-collision.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup collision harness failed: %v\n%s", err, string(output)) + } + // The foreign same-second roots stay untouched: no marker, no payload. + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + foreignIntact, bumped := 0, "" + for _, entry := range entries { + children, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups", entry.Name())) + if err != nil { + t.Fatal(err) + } + hasPayload, hasMarker := false, false + for _, child := range children { + if child.Name() == ".dws-skill-backup" { + hasMarker = true + } + if child.Name() == ".zcode-skills-dingtalk-chat" { + hasPayload = true + } + } + if hasPayload { + bumped = entry.Name() + if !hasMarker { + t.Fatalf("backup stamp root %s must carry the ownership marker", bumped) + } + continue + } + if hasMarker { + t.Fatalf("foreign stamp root %s must never be marked DWS-owned", entry.Name()) + } + if len(children) != 1 || children[0].Name() != "user-data.txt" { + t.Fatalf("foreign stamp root %s must stay untouched, children = %v", entry.Name(), children) + } + foreignIntact++ + } + if foreignIntact != 10 || bumped == "" || !strings.Contains(bumped, "-") { + t.Fatalf("want 10 untouched foreign roots plus one -N backup root, got %d intact and backup root %q", foreignIntact, bumped) + } +} + +// TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication pins the +// path-blind retract fix: after a cross-device publish, a post-occupy +// verification failure must retract the destination only through +// Remove-VerifiedDevSkillPublication, so a concurrently replaced destination +// is restored instead of deleted. +func TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +# The first move throws the cross-device error, so the recoverable path +# stages and publishes a copy; right after that publish another process +# replaces the destination, and post-occupy verification fails. +$script:ReplacedPublished = $false +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (!$script:ReplacedPublished -and $Destination -eq $env:DWS_TEST_DESTINATION) { + $script:ReplacedPublished = $true + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Destination (Join-Path $env:DWS_TEST_HOME 'replaced-published') -ErrorAction Stop + New-Item -ItemType Directory -Path $Destination -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $Destination 'concurrent-user-data.txt') -Value 'keep' + } +} +try { + Move-DevSkillPathRecoverably $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_.Exception.Message -notmatch 'state uncertain') { Write-Error $_; exit 3 } +} +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt'))) { exit 4 } +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_SOURCE 'SKILL.md'))) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-retract-verify-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp verified retract must retain the concurrent replacement: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent replacement data = %q, %v; want it retained", got, err) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "new dev skill\n" { + t.Fatalf("source must be retained after the failed cross-device move, got %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(home, "replaced-published")); err != nil { + t.Fatalf("the replaced original copy must stay inspectable, not be deleted: %v", err) + } + for _, pattern := range []string{".dws-dev-rollback-*", ".dingtalk-misc.cross-device-*"} { + if matches, err := filepath.Glob(filepath.Join(root, "external", pattern)); err != nil || len(matches) != 0 { + t.Fatalf("devapp rollback leftovers %s = %v, %v", pattern, matches, err) + } + } +} + func TestInstallScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX shell semantics are unavailable") @@ -2641,6 +3032,167 @@ fi } } +// TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling pins +// the review blocker on the shared stamp root: the backup collision loop +// tests the payload path, so two backups in the same stamp second land in +// ONE stamp root. When the second call fails to write the ownership marker, +// the old `rm -rf` cleanup destroyed the first, already completed sibling +// payload whose original directory had already been moved away. The cleanup +// must be non-recursive and may only drop a stamp root the failing call +// itself created (marker file plus empty root; rmdir refuses non-empty). +func TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + // Both scenarios rely on permission-bit enforcement (unwritable marker + // file, unwritable fresh root), which root bypasses entirely. + if os.Geteuid() == 0 { + t.Skip("running as root ignores permission bits, the marker write would succeed") + } + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + backupFn := "backup_and_remove_skill_dir" + emitsMarkerWarning := true + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + backupFn = "backup_skill_dir" + emitsMarkerWarning = false + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + // Pin the stamp: a stub `date` always prints the same UTC second, + // so consecutive backups deterministically share one stamp root. + const stamp = "20260818-120000" + stubDir := filepath.Join(t.TempDir(), "bin") + mustWriteFile(t, filepath.Join(stubDir, "date"), []byte("#!/bin/sh\nprintf '"+stamp+"\\n'\n"), 0o755) + + t.Run("same-stamp sibling payload survives marker write failure", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victimA := filepath.Join(home, ".agents", "skills", "skill-a") + victimB := filepath.Join(home, ".agents", "skills", "skill-b") + mustWriteFile(t, filepath.Join(victimA, "SKILL.md"), []byte("payload A\n"), 0o644) + mustWriteFile(t, filepath.Join(victimB, "SKILL.md"), []byte("victim B\n"), 0o644) + + // The first backup succeeds and moves victim A into the stamp + // root; the marker is then made unwritable so the second, + // same-second backup fails exactly at the marker write. + harness := `. "$DWS_TEST_LIBRARY" +if ` + backupFn + ` "$DWS_TEST_VICTIM_A"; then :; else exit 10; fi +chmod 000 "$DWS_TEST_ROOT/` + skillBackupMarkerFile + `" +if ` + backupFn + ` "$DWS_TEST_VICTIM_B"; then + exit 11 +fi +[ -d "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM_B/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM_A="+victimA, + "DWS_TEST_VICTIM_B="+victimB, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s sibling marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if emitsMarkerWarning && !strings.Contains(string(output), "无法写入备份所有权标记") { + t.Fatalf("%s did not report the marker write failure:\n%s", scriptName, output) + } + if _, err := os.Lstat(victimA); !os.IsNotExist(err) { + t.Fatalf("%s first victim must have been moved into the stamp root: %v", scriptName, err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("%s stamp root must survive the failed marker write: %v", scriptName, err) + } + var payloadDirs []string + for _, entry := range entries { + if entry.Name() == skillBackupMarkerFile { + continue + } + if !entry.IsDir() { + t.Fatalf("%s stamp root carries an unexpected non-marker entry %s", scriptName, entry.Name()) + } + payloadDirs = append(payloadDirs, entry.Name()) + } + if len(payloadDirs) != 1 { + t.Fatalf("%s stamp root must retain exactly the first payload, got %v", scriptName, payloadDirs) + } + got, err := os.ReadFile(filepath.Join(root, payloadDirs[0], "SKILL.md")) + if err != nil || string(got) != "payload A\n" { + t.Fatalf("%s completed sibling payload was destroyed: %q, %v", scriptName, got, err) + } + if got, err := os.ReadFile(filepath.Join(victimB, "SKILL.md")); err != nil || string(got) != "victim B\n" { + t.Fatalf("%s second victim must stay at its original path: %q, %v", scriptName, got, err) + } + }) + + t.Run("fresh stamp root is dropped non-recursively", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victim := filepath.Join(home, ".agents", "skills", "skill-fresh") + mustWriteFile(t, filepath.Join(victim, "SKILL.md"), []byte("fresh victim\n"), 0o644) + + // The failing call itself created the root, so the cleanup + // must drop it again: mkdir is intercepted to hand back a + // non-writable root, failing the marker write while the root + // stays removable through its writable parent directory. + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + command mkdir "$@" + _mk_rc=$? + if [ "${1:-}" = "-p" ] && [ "${2:-}" = "$DWS_TEST_ROOT" ]; then + chmod 0555 "$DWS_TEST_ROOT" + fi + return "$_mk_rc" +} +if ` + backupFn + ` "$DWS_TEST_VICTIM"; then + exit 11 +fi +[ ! -e "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM="+victim, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s fresh marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("%s fresh stamp root must be removed again: %v", scriptName, err) + } + if got, err := os.ReadFile(filepath.Join(victim, "SKILL.md")); err != nil || string(got) != "fresh victim\n" { + t.Fatalf("%s victim must stay untouched at its original path: %q, %v", scriptName, got, err) + } + }) + }) + } +} + func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { From b354b371c9e59ee8a1a109e81855c47e778e0084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 00:15:31 +0800 Subject: [PATCH 131/267] fix(skills): prune backups without following reparse points --- .changes/996-canonical-agent-skills.md | 2 +- scripts/install-devapp.ps1 | 27 +++++++- scripts/install.ps1 | 27 +++++++- test/scripts/install_js_smoke.mjs | 95 ++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md index c499f5ec..12b0c44e 100644 --- a/.changes/996-canonical-agent-skills.md +++ b/.changes/996-canonical-agent-skills.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Linux Go identity proof now uses a Statx token (inode plus birth/change time) because overlayfs recycles device+inode immediately and `os.SameFile` would otherwise treat a concurrent dest as owned and retract it; Shell copied-set rollback proves dest first with inode plus child names for the same reason. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. A cross-filesystem copy that has already renamed the verified staging path onto the destination records that publication identity immediately; a later mode-restore, copy-verification, or staging-cleanup failure retracts the proven destination so the source stays the only live copy and retries are not blocked by an unrecorded leftover, while a failed retract reports an uncertain state naming both retained locations. The same occupy-then-record-then-retract contract applies to Go no-replace publish confirmation, npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Linux Go identity proof now uses a Statx token (inode plus birth/change time) because overlayfs recycles device+inode immediately and `os.SameFile` would otherwise treat a concurrent dest as owned and retract it; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 84558494..686db8e1 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -260,7 +260,13 @@ function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { } catch { $postErr = $_ try { - if (Test-PathLexically $Destination) { Remove-DevSkillPathLexically $Destination } + # Retract only after proving the destination is still this + # transaction's copy (mirrors install.ps1's + # Remove-PublishedSkillPathSafely retract): a path-blind + # lexical removal would delete a concurrent replacement. + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $Source "" + } } catch { throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained" } @@ -346,6 +352,23 @@ function Test-DevSkillBackupMarker([string]$Dir) { } } +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-DevLinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-DevSkillBackupTreeLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-DevSkillBackupTreeLexically $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + function Remove-OldDevSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } @@ -360,7 +383,7 @@ function Remove-OldDevSkillBackups { foreach ($dir in $dirs) { if ($excess -le 0) { break } if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue } - Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + try { Remove-DevSkillBackupTreeLexically $dir.FullName } catch { } $excess-- } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 23e0e652..93fdf168 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -801,6 +801,24 @@ function Test-SkillBackupMarker { } } +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-LinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-SkillBackupTreeLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-SkillBackupTreeLexically -Path $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + # Remove-OldSkillBackups deletes the oldest stamped backup directories once # more than $SkillBackupKeep remain. Stamps sort lexicographically in # chronological order, so name order is age order. Roots created during this @@ -820,7 +838,7 @@ function Remove-OldSkillBackups { foreach ($dir in $dirs) { if ($excess -le 0) { break } if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue } - Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + try { Remove-SkillBackupTreeLexically -Path $dir.FullName } catch { } $excess-- } } @@ -843,7 +861,12 @@ function Backup-SkillDir { $targetRoot = Join-Path $backupRoot $stamp $target = Join-Path $targetRoot $name $i = 1 - while (Test-SkillPathLexically -Path $target) { + # Bump not only when \ is taken but also when the stamp root + # itself exists without a verified ownership marker: a same-second + # foreign directory must never be stamped DWS-owned and pruned later. A + # marker-verified root from this run's same second stays reusable. + while ((Test-SkillPathLexically -Path $target) -or + ((Test-SkillPathLexically -Path $targetRoot) -and !(Test-SkillBackupMarker -Dir $targetRoot))) { $targetRoot = Join-Path $backupRoot "$stamp-$i" $target = Join-Path $targetRoot $name $i++ diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 220b9320..41fdd3bd 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -56,6 +56,12 @@ * 20. unmarked stamps — stamp-shaped directories without the exact * ownership marker survive pruning and never * consume a keep slot. + * 24. stamp-root cleanup — a failed marker write removes an empty fresh + * root (rmdir path) but never a non-empty + * foreign root. + * 25. rollback ENOENT gate — a vanished child makes the destination + * unverifiable (rollback refused), never + * "destination already gone". * * Requirements: unix host with tar/zip/unzip on PATH (the same tools * install.js itself shells out to). Skips cleanly on win32. @@ -1562,6 +1568,95 @@ for (const failureKind of ["backup", "publish"]) { }); } +// A stamp root whose ownership marker write fails must not linger: an empty +// fresh root is removed (rmdir, never a recursive delete), while a +// pre-existing non-empty root holding foreign data always survives. +scenario("a failed marker write never leaves an empty unowned stamp root", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-stamp-cleanup-")); + try { + const home = path.join(tmp, "home"); + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + // Fresh root: created by this call, then the marker write is poisoned so + // the backup aborts before any payload moves. + const stamp = "20260801-000000"; + const markerPath = path.join(home, ".dws", "skill-backups", stamp, SKILL_BACKUP_MARKER_FILE); + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = (target, data, options) => { + if (target === markerPath) { + throw Object.assign(new Error("injected marker write failure"), { code: "EACCES" }); + } + return originalWriteFileSync(target, data, options); + }; + try { + assert.throws( + () => backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }), + /failed to back up Skill directory/, + ); + } finally { + fs.writeFileSync = originalWriteFileSync; + } + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old codex copy\n", "the source stays in place"); + assert.equal( + fs.existsSync(path.join(home, ".dws", "skill-backups", stamp)), + false, + "an empty unowned stamp root is cleaned up", + ); + + // Foreign non-empty root: the marker path is a directory (the write fails + // with EISDIR), and rmdir must fail on the non-empty root, keeping it. + const foreignStamp = "20260801-000001"; + const foreignRoot = path.join(home, ".dws", "skill-backups", foreignStamp); + writeFile(path.join(foreignRoot, "user-data.txt"), "foreign\n"); + fs.mkdirSync(path.join(foreignRoot, SKILL_BACKUP_MARKER_FILE)); + assert.throws( + () => backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => foreignStamp }), + /failed to back up Skill directory/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old codex copy\n", "the source stays in place"); + assert.equal(fs.readFileSync(path.join(foreignRoot, "user-data.txt"), "utf8"), "foreign\n", "a non-empty foreign stamp root survives"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ENOENT during the pre-quarantine identity walk means "already gone" only +// when the destination itself is missing; a concurrently removed CHILD must +// fail the rollback loudly instead of silently skipping it. +scenario("rollback refuses an unverifiable destination whose child vanished", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-rollback-enoent-")); + try { + const dest = path.join(tmp, "skill"); + writeFile(path.join(dest, "SKILL.md"), "payload\n"); + writeFile(path.join(dest, "child.md"), "child\n"); + const publication = recordSkillPathPublicationSync(dest); + + // Remove the child while the pre-quarantine fingerprint walk sits between + // its readdir and the per-child lstat, so the walk itself fails ENOENT. + const firstChild = path.join(dest, "SKILL.md"); + const vanishedChild = path.join(dest, "child.md"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = (target, options) => { + if (target === firstChild) { + fs.rmSync(vanishedChild); + } + return originalReadFileSync(target, options); + }; + try { + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete unverifiable Skill/, + ); + } finally { + fs.readFileSync = originalReadFileSync; + } + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "payload\n", "the unverifiable destination is preserved"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); runCrossDeviceMoveContract(); process.stdout.write("ok\n"); From c5951a10ff04503b824fb9387de962063270a6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 19 Aug 2026 00:55:27 +0800 Subject: [PATCH 132/267] docs(chat): trim chat skill context budget --- skills/multi/dingtalk-chat/SKILL.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index cf63ec02..a77945a6 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-chat -description: 钉钉群聊与消息。Use when 用户提到 发消息/编辑或撤回消息/单聊/群聊/建群/普通群升级外部群/群昵称/会话分组/群成员管理/@消息/搜索聊天记录/话题回复/收藏消息/机器人群发/Webhook通知/发送或下载消息图片与文件。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。 +description: 钉钉群聊与消息。Use when 用户提到发消息、单聊/群聊、群管理、@消息、搜索记录、话题回复、收藏/置顶、机器人群发、Webhook、收发图片与文件。不做紧急 DING/短信/电话、邮件、班级群。前缀:dws chat。 metadata: cli_version: ">=0.2.14" category: product @@ -35,7 +35,7 @@ metadata: ## Golden Route -按用户任务选择最小充分入口。公开层按意图分流;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用,不把所有能力塞进一个万能命令。 +按任务选择最小充分入口;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用。 | 用户意图 | 唯一推荐入口 | 关键边界 | |---|---|---| @@ -47,10 +47,10 @@ metadata: | 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | | 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | | 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | -| 查看个人收藏表情 | `dws chat emotion list` | 当前用户身份由 MCP server 注入 | -| 发送个人收藏表情 | `dws chat emotion send --media-id --group ` | 目标为 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 会解析为 openDingTalkId | -| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | `--source-conversation-id` 与 `--source-message-id` 必须成对指定 | -| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | +| 查看个人收藏表情 | `dws chat emotion list` | MCP `im/list_personal_emotions`;入参 `{}`,当前用户由 server 注入 | +| 发送个人收藏表情 | `dws chat emotion send --media-id ` | MCP `im/send_personal_emotion`;`--emotion-id` 可选;目标 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 解析为 openDingTalkId;`--uuid/--idempotency-key` -> `uuid` | +| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | MCP `im/favorite_personal_emotion`;`--name` 可选;`--source-conversation-id/--source-message-id` 成对 | +| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | | 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 | @@ -73,22 +73,22 @@ metadata: ### 发送入口边界 -- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。 -- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。 +- `+dm`:姓名目标的简单文本/Markdown。 +- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown。 - Markdown 中的公网图片必须写成 `![图片标题](https://example.com/image.png)` 才会内联展示; 省略开头的 `!` 时只会显示为链接。 -- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 +- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。已知 ID 可直传,也可用 `--user-query` / `--chat-query` 解析;Bot 多群用 `--groups/--groups-file`,返回 `im.batch-write.v1`。 - 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 - Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 -- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`,Runtime 把 create 返回前缀加到 `--content`;禁写占位符;仅 text。 +- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`;禁写占位符;仅 text。 ## 关键结果语义 - `openTaskId` 是发送任务 ID,不是回复或撤回所需的消息 ID;消息 ID 必须来自真实查询结果。 -- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`;`--no-reactions` 可关闭 reaction。 +- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`。 - 查询结果必须检查 `complete`、`hasMore`、`failures` 和资源下载 ledger;partial result 不得表述为完整成功。 - 子消息使用自己的 `messageId`;仅缺会话 ID 时继承父消息的 `conversationId`。 -- 下载只允许工作目录内安全相对路径,默认不覆盖并原子落盘;覆盖必须由用户显式传 `--overwrite`。读取和下载不需要 `--yes`。 +- 下载只允许工作目录内安全相对路径,默认不覆盖;覆盖必须显式传 `--overwrite`。读取和下载不需要 `--yes`。 - Favorite、消息 Pin、消息 Top、会话 Top 是不同对象层级,不能互换。 ## 按需加载 From 65885dd262b06885a4534a1ce251d9fe7a593c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 05:54:20 +0800 Subject: [PATCH 133/267] fix(skills): abort degraded publish on foreign claim entries --- internal/upgrade/skill_path_move_test.go | 8 +- internal/upgrade/skill_publish_rename.go | 166 +++++- internal/upgrade/skill_publish_rename_test.go | 529 +++++++++++++++++- 3 files changed, 654 insertions(+), 49 deletions(-) diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 6a3b4633..9dc34afb 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -626,15 +626,21 @@ func TestCrossPlatformCoverageSkillPathMoveShellRetraction(t *testing.T) { src, dst := makeSkillMoveFixture(t) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) forceFastClaimRenameFailure(t, src, dst) + retracting := false testseam.Swap(t, &skillPathRemove, func(path string) error { if path == src { + retracting = true return os.ErrPermission } return os.Remove(path) }) originalReadDir := skillPathReadDir testseam.Swap(t, &skillPathReadDir, func(dir string) ([]os.DirEntry, error) { - if dir == dst { + // The claim stays readable while the publication itself runs + // (empty-claim gate and live re-read); only the retraction of the + // published claim becomes unreadable, which is the state the + // caller must be warned about with the data location. + if dir == dst && retracting { return nil, os.ErrPermission } return originalReadDir(dir) diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index 9cf02b05..5f441a15 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -61,16 +61,18 @@ func renameSkillPathNoReplaceFallback(source, destination string) error { // renames the source over the claim; on platforms that permit replacing an // empty directory (Linux) that rename only ever touches the claim we own. // Platforms that refuse to rename over any directory (macOS returns EEXIST -// even for an empty target) take the slow path instead: the source children -// are moved into the claim one by one, so the destination stays occupied by -// us the whole time. Removing the claim and retrying the rename — the -// previous design — opened an unlink→rename window in which a foreign -// directory could appear and be silently overwritten, breaking the -// no-replace contract. The destination is therefore never deleted here: a -// concurrent creator either loses the mkdir race (EEXIST) or finds the path -// claimed until publication completes. The slow path is not -// all-or-nothing-visible, which is acceptable on the degraded filesystems -// that need it; every other property of the no-replace contract holds. +// even for an empty target) take the slow path instead — but only after +// proving the claim is still empty: a rename that failed because a +// concurrent writer landed inside the claim must abort with the destination +// retained, never fall through to child moves whose per-entry primitives +// would collide with (and rollback would delete) the foreign data. The slow +// path moves the source children into the claim one by one, each through an +// atomic no-clobber primitive (mkdir claim for directories, os.Link for +// files, os.Symlink for links), so a same-name concurrent entry aborts the +// move and a foreign entry of any name — detected by re-reading the claim — +// retains the destination instead of letting the rollback delete it. The +// destination claim is removed only while still empty, so a foreign entry +// always survives a failed publication. func renameSkillDirNoReplaceFallback(source, destination string) error { sourceInfo, err := skillPathLstat(source) if err != nil { @@ -85,18 +87,112 @@ func renameSkillDirNoReplaceFallback(source, destination string) error { if err := skillPathRename(source, destination); err == nil { return nil } + claimEntries, claimErr := skillPathReadDir(destination) + if claimErr != nil { + return fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr) + } + if len(claimEntries) > 0 { + return fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist) + } if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil { return fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err) } return nil } +// moveSkillDirChildNoReplace moves one child of a staged directory into the +// claimed destination through an atomic no-clobber primitive: directories +// recursively claim with mkdir, regular files publish via os.Link, and +// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST +// when the destination entry was taken concurrently, so no step can replace +// a foreign object. +func moveSkillDirChildNoReplace(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + if err := renameSkillDirNoReplaceFallback(source, destination); err != nil { + return err + } + // The nested slow path leaves an emptied shell at the source (the + // fast-path rename consumed it). os.Remove only deletes an empty + // directory, so clearing the shell restores the single-rename + // outcome the parent's rollback and shell signal expect; a + // concurrent writer inside our own staging shell fails the removal + // and surfaces through the parent's uncertain-state path instead of + // being silently discarded. + if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理已迁移 Skill 子目录失败 %s: %w", source, err) + } + return nil + case info.Mode().IsRegular(): + return renameSkillFileNoReplaceFallback(source, destination) + case info.Mode()&os.ModeSymlink != 0: + return renameSkillSymlinkNoReplaceFallback(source, destination) + default: + return &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillSymlinkNoReplaceFallback recreates the link at the destination +// with os.Symlink — atomic, EEXIST when taken — and then removes the source. +// A failed source removal retracts the recreated link only while it still +// carries the same target, so a concurrent replacement survives and the +// object itself always remains reachable. +func renameSkillSymlinkNoReplaceFallback(source, destination string) error { + target, err := skillPathReadlink(source) + if err != nil { + return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err) + } + if err := skillPathSymlink(target, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err) + } + if err := skillPathRemove(source); err != nil { + if current, readErr := skillPathReadlink(destination); readErr == nil && current == target { + _ = skillPathRemove(destination) + } + return fmt.Errorf("移动 Skill 链接失败(源 %s 与目标 %s 至少一者保留): %w", source, destination, err) + } + return nil +} + +// removeClaimIfEmpty deletes the claimed destination only while it still +// holds no entries. A foreign entry that appeared after the claim must never +// be removed: the rollback has no ownership proof for it. +func removeClaimIfEmpty(destination string) error { + entries, err := skillPathReadDir(destination) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err) + } + if len(entries) > 0 { + return nil + } + if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err) + } + return nil +} + // moveSkillDirChildrenIntoClaim moves every top-level child of source into -// the claimed destination and restores the source mode on the claim. Any -// failure moves the already relocated children back and removes only the -// claim, leaving the source intact. Each child rename targets a path that -// does not exist inside the empty claim, so no step can replace a foreign -// object. The emptied source shell is intentionally left behind: callers of +// the claimed destination — each through moveSkillDirChildNoReplace, so a +// concurrently created same-name entry fails atomically — and restores the +// source mode on the claim. The claim is re-read after the moves: an entry +// count above the source's means a different-named foreign object landed +// mid-move, and the publication aborts with the destination retained. Any +// failure moves the already relocated children back and removes the claim +// only while it is empty, leaving the source intact and foreign entries in +// place. The emptied source shell is intentionally left behind: callers of // renameSkillPathNoReplace use its presence to tell that the publication was // a child move into a fresh claim (new identity) rather than a rename that // consumed the source (identity preserved), and moveSkillPathRecoverably @@ -104,27 +200,47 @@ func renameSkillDirNoReplaceFallback(source, destination string) error { func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error { entries, err := skillPathReadDir(source) if err != nil { - _ = skillPathRemove(destination) - return fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err) + return errors.Join( + fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err), + removeClaimIfEmpty(destination), + ) } moved := make([]string, 0, len(entries)) - rollback := func() { + rollback := func() error { + var rollbackErr error for i := len(moved) - 1; i >= 0; i-- { - _ = skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])) + if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } } - _ = skillPathRemove(destination) + rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination)) + return rollbackErr } for _, entry := range entries { name := entry.Name() - if err := skillPathRename(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { - rollback() - return fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err) + if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback()) } moved = append(moved, name) } + live, liveErr := skillPathReadDir(destination) + if liveErr != nil { + return errors.Join( + fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr), + rollback(), + ) + } + if len(live) > len(entries) { + return errors.Join( + fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist), + rollback(), + ) + } if err := skillPathChmod(destination, sourceMode); err != nil { - rollback() - return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err) + return errors.Join( + fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err), + rollback(), + ) } return nil } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 6a4f66fb..396aaac9 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -288,30 +288,105 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { assertUpgradeSkillContent(t, source, "payload") }) - t.Run("concurrent creation between mkdir and rename is detected", func(t *testing.T) { + t.Run("foreign different-name entry in the claim aborts before child moves", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process") - testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr }) - var removeCalled bool - testseam.Swap(t, &skillPathRemove, func(path string) error { - if path == destination { - removeCalled = true + // A concurrent writer lands inside the claim between the mkdir claim + // and the fast rename: the rename fails, and the claim re-read must + // see the foreign entry and abort with the destination retained + // instead of falling through to per-child moves whose rollback would + // delete it. + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if src == source && dst == destination { + if err := os.WriteFile(filepath.Join(destination, "concurrent-user-data.txt"), []byte("foreign\n"), 0o644); err != nil { + return err + } + return os.ErrExist } - return os.Remove(path) + return originalRename(src, dst) }) err := renameSkillPathNoReplace(source, destination) - if !errors.Is(err, renameErr) { - t.Fatalf("rename failure must surface, got %v", err) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("foreign claim entry must abort with ErrExist, got %v", err) } - if !removeCalled { - t.Fatal("empty destination must be cleaned up after rename failure") + got, readErr := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")) + if readErr != nil || string(got) != "foreign\n" { + t.Fatalf("foreign entry must survive at the destination: %q, %v", got, readErr) } - if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { - t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr) + if _, statErr := os.Lstat(filepath.Join(destination, "SKILL.md")); !os.IsNotExist(statErr) { + t.Fatalf("no source child may be moved after a foreign entry is seen: %v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("foreign same-name file in the claim is never replaced", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // The claim is empty when read, so the child moves start; the + // concurrent file uses the same name as a source child and the + // per-child link primitive must fail atomically instead of replacing + // it. + originalLink := skillPathLink + testseam.Swap(t, &skillPathLink, func(oldname, newname string) error { + if filepath.Base(newname) == "SKILL.md" { + if err := os.WriteFile(filepath.Join(destination, "SKILL.md"), []byte("concurrent\n"), 0o644); err != nil { + return err + } + } + return originalLink(oldname, newname) + }) + forceFastClaimRenameFailure(t, source, destination) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("same-name concurrent file must abort with ErrExist, got %v", err) + } + got, readErr := os.ReadFile(filepath.Join(destination, "SKILL.md")) + if readErr != nil || string(got) != "concurrent\n" { + t.Fatalf("concurrent file must stay byte-identical: %q, %v", got, readErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("foreign different-name entry mid child-move aborts and is retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + // The live re-read after the child moves observes an entry count above + // the source's: a different-named foreign object landed mid-move. The + // publication aborts, the moved children return to the source, and the + // claim is only removed while empty — so the foreign entry survives. + claimReads := 0 + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + entries, err := originalReadDir(path) + if err == nil && path == destination { + claimReads++ + if claimReads == 2 { + if err := os.WriteFile(filepath.Join(destination, "concurrent-mid.txt"), []byte("mid\n"), 0o644); err != nil { + return nil, err + } + return originalReadDir(path) + } + } + return entries, err + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "并发目录项") { + t.Fatalf("mid-move foreign entry must abort, got %v", err) + } + got, readErr := os.ReadFile(filepath.Join(destination, "concurrent-mid.txt")) + if readErr != nil || string(got) != "mid\n" { + t.Fatalf("mid-move foreign entry must survive: %q, %v", got, readErr) } assertUpgradeSkillContent(t, source, "payload") }) @@ -402,7 +477,8 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) + forceFastClaimRenameFailure(t, source, destination) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) err := renameSkillPathNoReplace(source, destination) if err == nil || !strings.Contains(err.Error(), "降级发布 Skill 目录失败") { t.Fatalf("slow-path failure must surface, got %v", err) @@ -447,7 +523,12 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) forceFastClaimRenameFailure(t, source, destination) - testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == source { + return nil, os.ErrPermission + } + return os.ReadDir(path) + }) err := renameSkillPathNoReplace(source, destination) if err == nil || !strings.Contains(err.Error(), "读取源 Skill 目录失败") { t.Fatalf("readdir failure must surface, got %v", err) @@ -467,15 +548,13 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatal(err) } testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(src, dst string) error { - if src == source { - return os.ErrExist - } - if filepath.Base(dst) == "zz-second.md" { + forceFastClaimRenameFailure(t, source, destination) + originalLink := skillPathLink + testseam.Swap(t, &skillPathLink, func(oldname, newname string) error { + if filepath.Base(newname) == "zz-second.md" { return os.ErrPermission } - return originalRename(src, dst) + return originalLink(oldname, newname) }) err := renameSkillPathNoReplace(source, destination) if err == nil || !strings.Contains(err.Error(), "迁移 Skill 目录项失败") { @@ -636,3 +715,407 @@ func forceFastClaimRenameFailure(t *testing.T, source, destination string) { return originalRename(src, dst) }) } + +// TestCrossPlatformCoverageDegradedChildPrimitives pins every branch of the +// per-child atomic no-clobber primitives the degraded slow path dispatches +// to: symlink children (success, EEXIST, creation failure, source-removal +// retraction, readlink failure), nested directory shells, the child stat +// gate, the claim re-read gate, and the empty-only claim cleanup. +func TestCrossPlatformCoverageDegradedChildPrimitives(t *testing.T) { + forceSlowPath := func(t *testing.T, source, destination string) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + } + + t.Run("symlink child publishes through the slow path", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("SKILL.md", filepath.Join(source, "self-link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + forceSlowPath(t, source, destination) + if err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("slow path must publish a symlink child, got %v", err) + } + if target, err := os.Readlink(filepath.Join(destination, "self-link")); err != nil || target != "SKILL.md" { + t.Fatalf("published link = %q, %v; want target SKILL.md", target, err) + } + assertSourceConsumed(t, source) + }) + + t.Run("symlink child refuses a concurrent same-name link", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("foreign-target", filepath.Join(source, "self-link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + forceSlowPath(t, source, destination) + originalSymlink := skillPathSymlink + testseam.Swap(t, &skillPathSymlink, func(target, linkname string) error { + if err := os.Symlink("concurrent-target", linkname); err != nil { + return err + } + return originalSymlink(target, linkname) + }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("same-name concurrent link must abort with ErrExist, got %v", err) + } + if target, readErr := os.Readlink(filepath.Join(destination, "self-link")); readErr != nil || target != "concurrent-target" { + t.Fatalf("concurrent link must be preserved, target=%q, %v", target, readErr) + } + if target, readErr := os.Readlink(filepath.Join(source, "self-link")); readErr != nil || target != "foreign-target" { + t.Fatalf("source link must be preserved, target=%q, %v", target, readErr) + } + }) + + t.Run("symlink creation failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(source, "self-link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + forceSlowPath(t, source, destination) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "发布 Skill 链接失败") { + t.Fatalf("link creation failure must surface, got %v", err) + } + }) + + t.Run("symlink source readlink failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(source, "self-link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + forceSlowPath(t, source, destination) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "读取源 Skill 链接失败") { + t.Fatalf("readlink failure must surface, got %v", err) + } + }) + + t.Run("symlink source removal failure retracts the published link", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(source, "self-link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + forceSlowPath(t, source, destination) + linkSource := filepath.Join(source, "self-link") + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == linkSource { + return os.ErrPermission + } + return os.Remove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "移动 Skill 链接失败") { + t.Fatalf("link source removal failure must surface, got %v", err) + } + if _, statErr := os.Lstat(filepath.Join(destination, "self-link")); !os.IsNotExist(statErr) { + t.Fatalf("unconfirmed published link must be retracted, stat err=%v", statErr) + } + if target, readErr := os.Readlink(linkSource); readErr != nil || target != "target" { + t.Fatalf("source link must be preserved, target=%q, %v", target, readErr) + } + }) + + t.Run("nested directory shell removal failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.MkdirAll(filepath.Join(source, "references"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "references", "guide.md"), []byte("guide"), 0o644); err != nil { + t.Fatal(err) + } + forceSlowPath(t, source, destination) + // Force the slow path at every depth: a rename whose destination is + // inside the claim must never succeed, so the nested directory also + // takes the child-move route on platforms that would otherwise allow + // replacing an empty directory claim. + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if dst == destination || strings.HasPrefix(dst, destination+string(filepath.Separator)) { + return os.ErrExist + } + return originalRename(src, dst) + }) + nested := filepath.Join(source, "references") + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == nested { + return os.ErrPermission + } + return os.Remove(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "清理已迁移 Skill 子目录失败") { + t.Fatalf("nested shell removal failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("child stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceSlowPath(t, source, destination) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if strings.HasPrefix(path, source+string(filepath.Separator)) { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "读取源 Skill 目录项失败") { + t.Fatalf("child stat failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("claim re-read failure aborts without deleting the destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceSlowPath(t, source, destination) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, os.ErrPermission + } + return originalReadDir(path) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "降级发布 Skill 目录失败") { + t.Fatalf("claim re-read failure must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("unreadable claim must be retained, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("missing claim cleanup after a failed child move is a no-op", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := moveSkillDirChildrenIntoClaim(source, destination, 0o755); err == nil { + t.Fatal("a child move into a missing claim must fail") + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("cleanup of an unreadable claim reports the failure", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.Mkdir(destination, 0o755); err != nil { + t.Fatal(err) + } + claimReads := 0 + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + entries, err := originalReadDir(path) + if err == nil && path == destination { + claimReads++ + // The direct call's only claim read is removeClaimIfEmpty's, + // after the injected child-move failure. + if claimReads == 1 { + return nil, os.ErrPermission + } + } + return entries, err + }) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + err := moveSkillDirChildrenIntoClaim(source, destination, 0o755) + if err == nil || !strings.Contains(err.Error(), "检查降级发布目标失败") { + t.Fatalf("unreadable claim cleanup must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) +} + +func TestCrossPlatformCoverageDegradedChildPrimitiveErrors(t *testing.T) { + forceSlowPath := func(t *testing.T, source, destination string) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + } + + t.Run("nested directory publish failure propagates", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.MkdirAll(filepath.Join(source, "references"), 0o755); err != nil { + t.Fatal(err) + } + forceSlowPath(t, source, destination) + // The nested directory's own claim is occupied: a concurrent + // same-name directory already sits at /references, so the + // nested mkdir returns EEXIST and the child error must propagate. + originalMkdir := skillPathMkdir + testseam.Swap(t, &skillPathMkdir, func(path string, mode os.FileMode) error { + if path == filepath.Join(destination, "references") { + return os.ErrExist + } + return originalMkdir(path, mode) + }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("nested claim occupation must propagate ErrExist, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("special file child fails safely", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.WriteFile(filepath.Join(source, "fifo"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + forceSlowPath(t, source, destination) + special := filepath.Join(source, "fifo") + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == special { + return mockSpecialInfo{}, nil + } + return originalLstat(path) + }) + err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("special file child must fail safely, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) +} + +func TestCrossPlatformCoverageDegradedRollbackEdges(t *testing.T) { + forceSlowPath := func(t *testing.T, source, destination string) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + forceFastClaimRenameFailure(t, source, destination) + } + + t.Run("failed cleanup of an empty claim surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.Mkdir(destination, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + return os.ErrPermission + } + return os.Remove(path) + }) + err := moveSkillDirChildrenIntoClaim(source, destination, 0o755) + if err == nil || !strings.Contains(err.Error(), "清理降级发布目标失败") { + t.Fatalf("empty-claim cleanup failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("rollback child rename failure is reported alongside the move failure", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "first", false) + if err := os.WriteFile(filepath.Join(source, "zz-second.md"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + forceSlowPath(t, source, destination) + originalLink := skillPathLink + testseam.Swap(t, &skillPathLink, func(oldname, newname string) error { + if filepath.Base(newname) == "zz-second.md" { + return os.ErrPermission + } + return originalLink(oldname, newname) + }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + if src == source && dst == destination { + return os.ErrExist + } + if strings.HasPrefix(src, destination+string(filepath.Separator)) { + return os.ErrPermission + } + return originalRename(src, dst) + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "迁移 Skill 目录项失败") || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("rollback rename failure must be reported with the move failure, got %v", err) + } + if got, readErr := os.ReadFile(filepath.Join(destination, "SKILL.md")); readErr != nil || string(got) != "first" { + t.Fatalf("stranded moved child must stay at the claim with its content: %q, %v", got, readErr) + } + }) + + t.Run("live claim re-read failure rolls the moved children back", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceSlowPath(t, source, destination) + claimReads := 0 + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + entries, err := originalReadDir(path) + if err == nil && path == destination { + claimReads++ + if claimReads == 2 { + return nil, os.ErrPermission + } + } + return entries, err + }) + err := renameSkillPathNoReplace(source, destination) + if err == nil || !strings.Contains(err.Error(), "确认降级发布目标失败") { + t.Fatalf("live claim re-read failure must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("emptied claim must be cleaned after rollback, stat err=%v", statErr) + } + }) +} From 510b120630c24311fb8942148ff9ccce14ad3503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 19 Aug 2026 09:31:26 +0800 Subject: [PATCH 134/267] fix(recruit): require job creator identity --- internal/helpers/recruit.go | 13 ++-- internal/helpers/recruit_test.go | 63 ++++++++++++++++++- skills/mono/references/products/recruit.md | 9 +-- .../multi/dingtalk-misc/references/recruit.md | 13 ++-- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 8152d7a7..aec76b1a 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -379,11 +379,11 @@ func newRecruitJobCreateCommand() *cobra.Command { Interface: recruitMCPInterface(recruitCreateJobTool), Selection: contract.SelectionSpec{ AgentSummary: "使用结构化 JSON 创建招聘职位", - UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData;jobNature 固定为 FULL-TIME;创建人和负责人分别使用真实 creatorUserId 与 ownerUserIds"}, + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"}, AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, }, - Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为创建人 userId,ownerUserIds 为负责人 userId 字符串数组"}}, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}}, }, }) } @@ -479,7 +479,7 @@ func loadRecruitJobFile(path string) (any, error) { } func validateRecruitJob(job map[string]any) error { - for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData"} { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} { value, ok := job[name] if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) @@ -552,11 +552,8 @@ func optionalRecruitNumber(values map[string]any, name string) (float64, bool, e } func validateRecruitIdentityFields(job map[string]any) error { - if value, exists := job["creatorUserId"]; exists && value != nil { - creatorUserID, ok := value.(string) - if !ok || strings.TrimSpace(creatorUserID) == "" { - return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串") - } + if _, ok := job["creatorUserId"].(string); !ok { + return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串") } if value, exists := job["ownerUserIds"]; exists && value != nil { ownerUserIDs, ok := value.([]any) diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index e7632c28..8c3fd948 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -286,6 +286,64 @@ func TestRecruitCreateValidatesJobFile(t *testing.T) { } } +func TestRecruitCreateRejectsInvalidCreatorBeforeRemoteCall(t *testing.T) { + for _, test := range []struct { + name string + creator string + }{ + {name: "missing", creator: ""}, + {name: "null", creator: `,"creatorUserId":null`}, + {name: "blank", creator: `,"creatorUserId":" "`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{}` + test.creator + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "creatorUserId") { + t.Fatalf("creator validation error = %v, want creatorUserId error", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid creator = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestRecruitCreateAllowsOptionalOwners(t *testing.T) { + for _, test := range []struct { + name string + owner string + }{ + {name: "missing"}, + {name: "null", owner: `,"ownerUserIds":null`}, + {name: "empty array", owner: `,"ownerUserIds":[]`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{},"creatorUserId":"creator-user-id"` + test.owner + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("optional owner create error = %v, want nil", err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls with optional owner = %d, want 1", len(caller.calls)) + } + }) + } +} + func TestRecruitValidationBranches(t *testing.T) { statuses, err := transformRecruitStatuses(" open, ,open,closed ") if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) { @@ -333,13 +391,16 @@ func TestRecruitValidationBranches(t *testing.T) { {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, {name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"}, + {name: "missing creator", mutate: func(job map[string]any) { delete(job, "creatorUserId") }, want: "缺少必填字段 creatorUserId"}, + {name: "null creator", mutate: func(job map[string]any) { job["creatorUserId"] = nil }, want: "缺少必填字段 creatorUserId"}, {name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"}, {name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"}, {name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"}, {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, {name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"}, {name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"}, - {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "creatorUserId 必须是非空字符串"}, + {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "缺少必填字段 creatorUserId"}, + {name: "creator non-string", mutate: func(job map[string]any) { job["creatorUserId"] = float64(123) }, want: "creatorUserId 必须是非空字符串"}, {name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"}, {name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"}, {name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"}, diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md index 2e035201..93502d4c 100644 --- a/skills/mono/references/products/recruit.md +++ b/skills/mono/references/products/recruit.md @@ -24,14 +24,15 @@ dws recruit job create --from ./job.json --dry-run --format json dws recruit job create --from ./job.json --format json ``` -JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`。 +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`。 `jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、 5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时 提供时最低薪资不得高于最高薪资。 -职位对象应显式包含真实的 `creatorUserId` 和 `ownerUserIds`;前者是当前创建人 -userId,后者是负责人 userId 的 JSON 字符串数组。所有 userId 必须来自同一 profile -下的真实通讯录结果,禁止猜测。`extData.headCount` 范围 1–999,薪资月份范围 +`creatorUserId` 是当前创建人 userId,必须来自同一 profile 下的真实通讯录结果。 +`ownerUserIds` 可选;提供时是负责人 userId 的 JSON 字符串数组,也必须使用真实 +通讯录结果。禁止猜测任何 userId。`extData.headCount` 范围 1–999,薪资月份范围 12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。 文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode` diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md index 36827e56..2ed44b96 100644 --- a/skills/multi/dingtalk-misc/references/recruit.md +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -37,14 +37,14 @@ dws recruit job get --job-id JOB_ID --format json ## 创建职位 创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 -`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资、创建人和负责人,取得 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资、创建人和负责人(如有),取得 明确确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 -创建人和负责人必须来自同一 profile 下的真实通讯录结果,不得猜测或复用其他组织的 -userId。`creatorUserId` 使用当前操作者的 userId;`ownerUserIds` 是负责人 userId 的 -JSON 字符串数组。用户说“负责人是我”时先运行 +`creatorUserId` 是必填字段,必须使用同一 profile 下当前操作者的真实 userId,不得 +猜测或复用其他组织的 userId。`ownerUserIds` 是可选的负责人 userId JSON 字符串数组; +提供负责人时同样必须使用真实通讯录结果。用户说“负责人是我”时先运行 `dws contact user get-self --format json` 取得当前 userId;用户指定其他负责人时按 -通讯录 Skill 解析唯一 userId。未指定负责人时先向用户确认,不要静默省略或默认选人。 +通讯录 Skill 解析唯一 userId。未指定负责人时允许省略 `ownerUserIds`,不要默认选人。 ```bash dws recruit job create --from ./job.json --dry-run --format json @@ -72,7 +72,8 @@ dws recruit job create --from ./job.json --format json } ``` -必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`。 +必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`;`ownerUserIds` 可选。 `jobNature` 当前固定为 `FULL-TIME`;`requiredEdu` 为 1–9 的整数(1小学、2初中、 3高中、4中专、5大专、6本科、7硕士、8博士、9其他)。`minSalary` 与 `maxSalary` 可选;两者同时提供时最低薪资不得高于最高薪资。 From 358e1ab06554f4ccedc9191914d831a3ba550350 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Wed, 19 Aug 2026 10:03:29 +0800 Subject: [PATCH 135/267] fix(oa): require startTime in --request and validate endTime independently - Reject --request payloads missing startTime (documented required) so endTime can no longer bypass validation when startTime is absent - Align --request time ordering with simple mode: endTime must be strictly after startTime - Cover all five previously uncovered branches (pageSize absent, startTime absent, malformed endTime, valid time pair, empty --start flag) to reach 100% changed-code coverage --- internal/helpers/oa.go | 11 ++++++----- internal/helpers/oa_coverage_more_test.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 9073041b..f2ddefb5 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -363,12 +363,13 @@ func formatOAAdminQueryTime(ms int64) string { } // validateOARequestTimeRange checks startTime/endTime of a decoded -// --request payload: both must be yyyy-MM-dd HH:mm:ss strings, and -// endTime must not precede startTime. +// --request payload: startTime is required, both must be +// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after +// startTime (matching simple mode's ValidateTimeRange). func validateOARequestTimeRange(request map[string]any) error { startRaw, ok := request["startTime"] if !ok { - return nil + return fmt.Errorf("--request 缺少必填字段 startTime") } startStr, ok := startRaw.(string) if !ok { @@ -390,8 +391,8 @@ func validateOARequestTimeRange(request map[string]any) error { if err != nil { return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr) } - if end.Before(start) { - return fmt.Errorf("--request endTime 不能早于 startTime") + if !end.After(start) { + return fmt.Errorf("--request endTime 必须晚于 startTime") } return nil } diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index b3c2f1dc..60ae7edb 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -212,6 +212,16 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, tool: "get_process_instances_by_admin", }, + { + name: "list-by-admin request mode without pageSize", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode with endTime", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, } for _, tc := range validCases { t.Run(tc.name, func(t *testing.T) { @@ -252,6 +262,7 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"}, {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"}, {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", ""}, {"approval", "list-by-admin", "--request", "{"}, {"approval", "list-by-admin", "--request", "null"}, {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"}, @@ -261,6 +272,9 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`}, {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} From d70e6b85b6ec54e5cca02aadc1ab7efe96cdf2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 10:09:37 +0800 Subject: [PATCH 136/267] test(calendar): isolate exhaustive alias payload coverage --- .../param_alias_payload_equivalence_test.go | 266 +++++++++++------- 1 file changed, 168 insertions(+), 98 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index cfe5224c..d4272e40 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -5,6 +5,8 @@ package app import ( "errors" + "os" + "os/exec" "reflect" "strings" "testing" @@ -17,6 +19,8 @@ import ( const ( appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE" appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE" + + paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD" ) // paramAliasCompleteCommands is deliberately keyed by the exact reviewed @@ -562,82 +566,6 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias - paramAliasPayloadCaseKey("calendar +agenda", "from"): true, - paramAliasPayloadCaseKey("calendar +agenda", "to"): true, - paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true, - paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true, - paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true, - paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true, - paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true, - paramAliasPayloadCaseKey("calendar +book", "summary"): true, - paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true, - paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true, - paramAliasPayloadCaseKey("calendar +book-search", "search"): true, - paramAliasPayloadCaseKey("calendar +book-search", "name"): true, - paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, - paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true, - paramAliasPayloadCaseKey("calendar +free", "name"): true, - paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true, - paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true, - paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true, - paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true, - paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true, - paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true, - paramAliasPayloadCaseKey("calendar +my-free", "from"): true, - paramAliasPayloadCaseKey("calendar +my-free", "to"): true, - paramAliasPayloadCaseKey("calendar +invite", "id"): true, - paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true, - paramAliasPayloadCaseKey("calendar +reschedule", "id"): true, - paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, - paramAliasPayloadCaseKey("calendar +reschedule", "to"): true, - paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true, - paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true, - paramAliasPayloadCaseKey("calendar +room-search", "query"): true, - paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true, - paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true, - paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true, - paramAliasPayloadCaseKey("calendar busy search", "room-id"): true, - paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true, - paramAliasPayloadCaseKey("calendar event create", "tz"): true, - paramAliasPayloadCaseKey("calendar event create", "room-id"): true, - paramAliasPayloadCaseKey("calendar event respond", "response-status"): true, - paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true, - paramAliasPayloadCaseKey("calendar event update", "tz"): true, - paramAliasPayloadCaseKey("calendar room add", "room-id"): true, - paramAliasPayloadCaseKey("calendar room delete", "room-id"): true, - paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true, - paramAliasPayloadCaseKey("calendar +create", "summary"): true, - paramAliasPayloadCaseKey("calendar +create", "description"): true, - paramAliasPayloadCaseKey("calendar +create", "user-ids"): true, - paramAliasPayloadCaseKey("calendar +create", "room-ids"): true, - paramAliasPayloadCaseKey("calendar +create", "room-id"): true, - paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true, - paramAliasPayloadCaseKey("calendar +create", "to"): true, - paramAliasPayloadCaseKey("calendar +create", "from"): true, - paramAliasPayloadCaseKey("calendar +get", "event-id"): true, - paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true, - paramAliasPayloadCaseKey("calendar +room-find", "from"): true, - paramAliasPayloadCaseKey("calendar +room-find", "to"): true, - paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true, - paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true, - paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true, - paramAliasPayloadCaseKey("calendar +room-find", "query"): true, - paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true, - paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, - paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true, - paramAliasPayloadCaseKey("calendar +search-event", "from"): true, - paramAliasPayloadCaseKey("calendar +search-event", "to"): true, - paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true, - paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true, - paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true, - paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true, - paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true, - paramAliasPayloadCaseKey("calendar +update", "event-id"): true, - paramAliasPayloadCaseKey("calendar +update", "from"): true, - paramAliasPayloadCaseKey("calendar +update", "summary"): true, - paramAliasPayloadCaseKey("calendar +update", "description"): true, - paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true, - paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true, paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override @@ -672,6 +600,91 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias } +// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion +// separate from the long-lived app-c race process. Each case still executes +// both canonical and alias argv through the real PreParse/Cobra path and +// compares the final captured transport calls; the owning top-level test runs +// these allocations in a short-lived race-instrumented subprocess so all Root +// registrations are released together when that process exits. +var paramAliasCalendarPayloadCases = map[string]bool{ + paramAliasPayloadCaseKey("calendar +agenda", "from"): true, + paramAliasPayloadCaseKey("calendar +agenda", "to"): true, + paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true, + paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +book", "summary"): true, + paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true, + paramAliasPayloadCaseKey("calendar +book-search", "search"): true, + paramAliasPayloadCaseKey("calendar +book-search", "name"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true, + paramAliasPayloadCaseKey("calendar +free", "name"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true, + paramAliasPayloadCaseKey("calendar +my-free", "from"): true, + paramAliasPayloadCaseKey("calendar +my-free", "to"): true, + paramAliasPayloadCaseKey("calendar +invite", "id"): true, + paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "id"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "to"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-search", "query"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true, + paramAliasPayloadCaseKey("calendar busy search", "room-id"): true, + paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true, + paramAliasPayloadCaseKey("calendar event create", "tz"): true, + paramAliasPayloadCaseKey("calendar event create", "room-id"): true, + paramAliasPayloadCaseKey("calendar event respond", "response-status"): true, + paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar event update", "tz"): true, + paramAliasPayloadCaseKey("calendar room add", "room-id"): true, + paramAliasPayloadCaseKey("calendar room delete", "room-id"): true, + paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +create", "summary"): true, + paramAliasPayloadCaseKey("calendar +create", "description"): true, + paramAliasPayloadCaseKey("calendar +create", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-id"): true, + paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +create", "to"): true, + paramAliasPayloadCaseKey("calendar +create", "from"): true, + paramAliasPayloadCaseKey("calendar +get", "event-id"): true, + paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "from"): true, + paramAliasPayloadCaseKey("calendar +room-find", "to"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "query"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, + paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true, + paramAliasPayloadCaseKey("calendar +search-event", "from"): true, + paramAliasPayloadCaseKey("calendar +search-event", "to"): true, + paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true, + paramAliasPayloadCaseKey("calendar +update", "event-id"): true, + paramAliasPayloadCaseKey("calendar +update", "from"): true, + paramAliasPayloadCaseKey("calendar +update", "summary"): true, + paramAliasPayloadCaseKey("calendar +update", "description"): true, + paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true, + paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true, +} + func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { concepts, err := cli.LoadParamConcepts() if err != nil { @@ -705,28 +718,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres } executedRepresentatives[caseKey] = true t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { - - canonicalCaller := ¶mAliasCaptureCaller{} - _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) - if canonicalErr != nil { - t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) - } - if len(canonicalCaller.calls) == 0 { - t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) - } - - aliasCaller := ¶mAliasCaptureCaller{} - ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) - if aliasErr != nil { - t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) - } - if ctx == nil { - t.Fatal("complete alias command skipped PreParse") - } - normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller) - if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { - t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) - } + assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs) }) } @@ -756,6 +748,84 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres } } +func TestReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { + if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" { + command := exec.Command( + os.Args[0], + "-test.run=^TestReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$", + "-test.count=1", + "-test.timeout=5m", + ) + command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output))) + } + return + } + + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + + executed := make(map[string]bool) + for _, fixture := range concepts.Fixture { + caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) + if !paramAliasCalendarPayloadCases[caseKey] { + continue + } + executed[caseKey] = true + fixture := fixture + t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) + if !ok { + t.Fatal("reviewed Calendar alias has no complete-command E2E template") + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted) + if replacements != 1 { + t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs) + } + assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs) + }) + } + + for caseKey := range paramAliasCalendarPayloadCases { + if !executed[caseKey] { + t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey) + } + } + if len(executed) != len(paramAliasCalendarPayloadCases) { + t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases)) + } +} + +func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) { + t.Helper() + canonicalCaller := ¶mAliasCaptureCaller{} + _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) + if canonicalErr != nil { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + aliasCaller := ¶mAliasCaptureCaller{} + ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if aliasErr != nil { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller) + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) + } +} + func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { activeAliases := 0 for _, test := range paramAliasNewIMCases { From 6c895c23ed63b667c5e21b846624a41464c0cfb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 19 Aug 2026 10:17:18 +0800 Subject: [PATCH 137/267] fix(recruit): validate pagination cursor responses --- internal/helpers/recruit.go | 45 ++++++++++++++++++++------------ internal/helpers/recruit_test.go | 42 ++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index aec76b1a..b8f68115 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -285,23 +285,17 @@ func recruitListResultData(data any) (any, *output.Meta, error) { return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") } nextToken := "" - if raw, exists := object["nextCursor"]; exists && raw != nil { - switch value := raw.(type) { - case string: - nextToken = strings.TrimSpace(value) - case json.Number: - nextToken = string(value) - case float64: - nextToken = strconv.FormatFloat(value, 'f', -1, 64) - default: - return nil, nil, fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + if hasMore { + if raw, exists := object["nextCursor"]; exists && raw != nil { + var err error + nextToken, err = normalizeRecruitNextCursor(raw) + if err != nil { + return nil, nil, err + } + } + if nextToken == "" { + return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") } - } - if hasMore && nextToken == "" { - return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") - } - if !hasMore { - nextToken = "" } clean := make(map[string]any, len(object)-2) for key, value := range object { @@ -324,6 +318,25 @@ func recruitListResultData(data any) (any, *output.Meta, error) { return clean, meta, nil } +func normalizeRecruitNextCursor(raw any) (string, error) { + var value string + switch cursor := raw.(type) { + case string: + value = strings.TrimSpace(cursor) + case json.Number: + value = string(cursor) + case float64: + value = strconv.FormatFloat(cursor, 'f', -1, 64) + default: + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标") + } + return strconv.FormatInt(parsed, 10), nil +} + func newRecruitJobGetCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 8c3fd948..83b17f15 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -497,6 +497,9 @@ func TestRecruitListResultData(t *testing.T) { {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"}, {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"}, {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"}, + {name: "exponent cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1e3")}, want: "非负十进制 int64"}, + {name: "decimal cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1.5")}, want: "非负十进制 int64"}, + {name: "overflow cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("9223372036854775808")}, want: "非负十进制 int64"}, } { t.Run(test.name, func(t *testing.T) { if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) { @@ -520,6 +523,13 @@ func TestRecruitListResultData(t *testing.T) { t.Fatalf("clean data = %#v", clean) } + _, normalizedCursor, err := recruitListResultData(map[string]any{ + "jobs": []any{}, "hasMore": true, "nextCursor": "00012", + }) + if err != nil || normalizedCursor.Pagination.NextToken != "12" { + t.Fatalf("normalized cursor meta = %#v, err = %v", normalizedCursor, err) + } + _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"}) if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { t.Fatalf("terminal meta = %#v, err = %v", terminal, err) @@ -789,15 +799,29 @@ func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) { } func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { - caller := withRecruitCaller(t) - caller.text = `{"jobs":[],"hasMore":true}` - cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) - result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) - if err != nil { - t.Fatal(err) - } - if result.Outcome() != output.OutcomeFailure { - t.Fatalf("outcome = %s, want failure", result.Outcome()) + for _, response := range []string{ + `{"jobs":[],"hasMore":true}`, + `{"jobs":[],"hasMore":true,"nextCursor":1e3}`, + `{"jobs":[],"hasMore":true,"nextCursor":1.5}`, + `{"jobs":[],"hasMore":true,"nextCursor":9223372036854775808}`, + } { + t.Run(response, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = response + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" { + t.Fatalf("envelope = %#v, want invalid_response failure", envelope) + } + }) } } From 502317db68fbd81b678766b24c9ea43641c8ea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 10:47:50 +0800 Subject: [PATCH 138/267] test(calendar): cover suggestion time aliases --- internal/app/param_alias_payload_equivalence_test.go | 4 +++- internal/cli/param_concepts.json | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index d4272e40..c212ccb3 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -70,7 +70,7 @@ var paramAliasCompleteCommands = map[string][]string{ "calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"}, "calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, "calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, - "calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--timezone", "Asia/Shanghai"}, + "calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, "calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"}, "calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, "calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2", "--yes"}, @@ -676,6 +676,8 @@ var paramAliasCalendarPayloadCases = map[string]bool{ paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true, paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true, paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "from"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "to"): true, paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true, paramAliasPayloadCaseKey("calendar +update", "event-id"): true, paramAliasPayloadCaseKey("calendar +update", "from"): true, diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 6aae2d0e..3a2f9a8b 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -832,6 +832,8 @@ {"command":"calendar +search-event","emitted":"page","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"}, {"command":"calendar +suggestion","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, {"command":"calendar +suggestion","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar +suggestion","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +suggestion","emitted":"to","expect":"end","via":"override:scoped_strict"}, {"command":"calendar +suggestion","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, {"command":"calendar +suggestion","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, {"command":"calendar +update","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, From 58ff0248b627ad9a181525fdbb1d8ce7d3ee67b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 19 Aug 2026 11:05:29 +0800 Subject: [PATCH 139/267] fix(recruit): handle minimal terminal pages --- internal/helpers/recruit.go | 2 +- internal/helpers/recruit_test.go | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index b8f68115..62e271ff 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -297,7 +297,7 @@ func recruitListResultData(data any) (any, *output.Meta, error) { return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") } } - clean := make(map[string]any, len(object)-2) + clean := make(map[string]any, len(object)) for key, value := range object { if key != "hasMore" && key != "nextCursor" { clean[key] = value diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 83b17f15..da320b23 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -564,6 +564,40 @@ func TestRecruitListResultData(t *testing.T) { } } +func TestRecruitListResultDataAcceptsMinimalTerminalPage(t *testing.T) { + data, meta, err := recruitListResultData(map[string]any{"hasMore": false}) + if err != nil { + t.Fatal(err) + } + clean, ok := data.(map[string]any) + if !ok || len(clean) != 0 { + t.Fatalf("data = %#v, want empty object", data) + } + if meta == nil || meta.Pagination == nil || !meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 0 { + t.Fatalf("meta = %#v, want exhausted terminal page", meta) + } + if meta.Count != nil { + t.Fatalf("count = %#v, want nil when jobs is absent", meta.Count) + } + + caller := withRecruitCaller(t) + caller.text = `{"success":true,"result":{"hasMore":false}}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || + envelope.Meta.Pagination == nil || !envelope.Meta.Pagination.EndpointExhausted { + t.Fatalf("envelope = %#v, want successful exhausted terminal page", envelope) + } +} + func TestRecruitResultCallPropagatesMCPError(t *testing.T) { caller := withRecruitCaller(t) caller.err = stderrors.New("transport failed") From 2ffddbd5a0ae511b4f4521aa2362eecc108eec9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=82=B3=E6=98=B1?= Date: Mon, 10 Aug 2026 21:28:20 +0800 Subject: [PATCH 140/267] feat(event): support OA approval CC events --- README.md | 5 ++-- README_zh.md | 5 ++-- internal/app/event_command.go | 2 +- internal/app/event_personal_attempts_test.go | 4 +-- internal/app/event_personal_oa_test.go | 9 ++++++ internal/event/personal/client_test.go | 1 + internal/event/personal/output.go | 29 +++++++++++++++++++ internal/event/personal/output_test.go | 18 ++++++++++++ internal/event/personal/registry.go | 12 ++++++++ internal/event/personal/registry_test.go | 12 ++++++++ skills/mono/SKILL.md | 2 +- skills/mono/references/products/event.md | 13 ++++++--- skills/multi/dingtalk-event/SKILL.md | 10 +++---- .../dingtalk-event/references/event-oa.md | 22 +++++++++----- test/skill_tests.md | 4 +++ test/unit/skill_docs_policy_test.go | 5 ++-- 16 files changed, 126 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ec3e7416..0c6af0bb 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
Personal Event Subscription — real-time DingTalk messages for event-driven agents -`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events. +`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events. The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`. @@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group --flatten dws event +listen-im --kind sender --user \ --events message,read,recall -f ndjson -# Listen for all six public OA approval events in one process +# Listen for all seven public OA approval events in one process dws event consume \ user_oa_approval_task_created \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten -f ndjson diff --git a/README_zh.md b/README_zh.md index e47544a4..b2a5763f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
个人事件订阅 — 实时接收钉钉消息,驱动事件触发的 Agent -`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。 +`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。 默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。 @@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group --flatten dws event +listen-im --kind sender --user \ --events message,read,recall -f ndjson -# 一个进程监听全部六个公开 OA 审批事件 +# 一个进程监听全部七个公开 OA 审批事件 dws event consume \ user_oa_approval_task_created \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten -f ndjson diff --git a/internal/app/event_command.go b/internal/app/event_command.go index f0cc1e24..ef727259 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 Selection: contract.SelectionSpec{ AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON", UseWhen: []string{ - "需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件", + "需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件", "需要监听指定群的标题变更、成员进退群或群解散事件", "用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制", }, diff --git a/internal/app/event_personal_attempts_test.go b/internal/app/event_personal_attempts_test.go index 1acdea27..913086bb 100644 --- a/internal/app/event_personal_attempts_test.go +++ b/internal/app/event_personal_attempts_test.go @@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent } } - if publicCount != 22 { - t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount) + if publicCount != 23 { + t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount) } for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} { if !ruleTypes[ruleType] { diff --git a/internal/app/event_personal_oa_test.go b/internal/app/event_personal_oa_test.go index 11f8dbe4..9defa152 100644 --- a/internal/app/event_personal_oa_test.go +++ b/internal/app/event_personal_oa_test.go @@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) { "process_code", "title", "status", "create_time", "event_time", }, }, + { + eventKey: personal.EventOAApprovalInstanceCC, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, { eventKey: personal.EventOAApprovalInstanceTerminated, properties: []string{ @@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) { personal.EventOAApprovalTaskFinished, personal.EventOAApprovalTaskRedirected, personal.EventOAApprovalInstanceStarted, + personal.EventOAApprovalInstanceCC, personal.EventOAApprovalInstanceTerminated, personal.EventOAApprovalInstanceFinished, } @@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t * personal.EventOAApprovalTaskFinished, personal.EventOAApprovalTaskRedirected, personal.EventOAApprovalInstanceStarted, + personal.EventOAApprovalInstanceCC, personal.EventOAApprovalInstanceTerminated, personal.EventOAApprovalInstanceFinished, } diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go index e7ffe3af..9dfc12bd 100644 --- a/internal/event/personal/client_test.go +++ b/internal/event/personal/client_test.go @@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}}, {"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}}, + {"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}}, {"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}}, diff --git a/internal/event/personal/output.go b/internal/event/personal/output.go index 00d81a01..e42d1b45 100644 --- a/internal/event/personal/output.go +++ b/internal/event/personal/output.go @@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct { EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"` } +type OAApprovalInstanceCCOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批实例到达抄送节点时的状态"` + CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"` +} + type OAApprovalInstanceTerminatedOutput struct { Type string `json:"type" description:"事件类型,固定为当前 event_key"` EventID string `json:"event_id" description:"事件 ID,可用于去重"` @@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R CreateTime: payload.Body.CreateTime, EventTime: payload.EventTime, }, nil + case EventOAApprovalInstanceCC: + return OAApprovalInstanceCCOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + Title: payload.Body.Title, + Status: payload.Body.Status, + CreateTime: payload.Body.CreateTime, + EventTime: payload.EventTime, + }, nil case EventOAApprovalInstanceTerminated: return OAApprovalInstanceTerminatedOutput{ Type: base.Type, @@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type { return reflect.TypeOf(OAApprovalTaskRedirectedOutput{}) case eventKey == EventOAApprovalInstanceStarted: return reflect.TypeOf(OAApprovalInstanceStartedOutput{}) + case eventKey == EventOAApprovalInstanceCC: + return reflect.TypeOf(OAApprovalInstanceCCOutput{}) case eventKey == EventOAApprovalInstanceTerminated: return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{}) case eventKey == EventOAApprovalInstanceFinished: @@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool { eventKey == EventOAApprovalTaskFinished || eventKey == EventOAApprovalTaskRedirected || eventKey == EventOAApprovalInstanceStarted || + eventKey == EventOAApprovalInstanceCC || eventKey == EventOAApprovalInstanceTerminated || eventKey == EventOAApprovalInstanceFinished } diff --git a/internal/event/personal/output_test.go b/internal/event/personal/output_test.go index 326caf67..e9de2e00 100644 --- a/internal/event/personal/output_test.go +++ b/internal/event/personal/output_test.go @@ -173,6 +173,8 @@ func personalOAData(eventKey string) string { body["finishTime"] = int64(1785229199000) case EventOAApprovalInstanceStarted: body["status"] = "RUNNING" + case EventOAApprovalInstanceCC: + body["status"] = "RUNNING" case EventOAApprovalInstanceTerminated: body["status"] = "TERMINATED" body["finishTime"] = int64(1785229199000) @@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) { EventTime: 1785229199000, }, }, + { + eventKey: EventOAApprovalInstanceCC, + want: OAApprovalInstanceCCOutput{ + Type: EventOAApprovalInstanceCC, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + Title: "测试审批", + Status: "RUNNING", + CreateTime: 1785229100000, + EventTime: 1785229199000, + }, + }, { eventKey: EventOAApprovalInstanceTerminated, want: OAApprovalInstanceTerminatedOutput{ @@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go index bffb7918..50e3ce03 100644 --- a/internal/event/personal/registry.go +++ b/internal/event/personal/registry.go @@ -43,6 +43,7 @@ const ( EventOAApprovalTaskFinished = "user_oa_approval_task_finished" EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected" EventOAApprovalInstanceStarted = "user_oa_approval_instance_started" + EventOAApprovalInstanceCC = "user_oa_approval_instance_cc" EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated" EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished" ) @@ -323,6 +324,17 @@ var definitions = []Definition{ Auth: map[string]any{"identity": "user"}, Public: true, }, + { + EventKey: EventOAApprovalInstanceCC, + DisplayName: "审批单抄送", + Description: "审批实例到达抄送节点,发送给被抄送人", + Category: "oa", + RuleType: "all", + Status: StatusEnabled, + RequiredParams: nil, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, { EventKey: EventOAApprovalInstanceTerminated, DisplayName: "审批单终止", diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go index fb01a3b3..1b80fa82 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } @@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } @@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { @@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) { "process_code", "title", "status", "create_time", "event_time", }, }, + { + eventKey: EventOAApprovalInstanceCC, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, { eventKey: EventOAApprovalInstanceTerminated, properties: []string{ @@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { @@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, "unknown_event", diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 189ffbad..2fb8cb3d 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -1,6 +1,6 @@ --- name: dws -description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。 +description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/抄送/终止/完成,以及审批任务创建/完成/转交时使用。 cli_version: ">=1.0.15" --- diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index f9b82c95..15178398 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -47,10 +47,11 @@ | `user_oa_approval_task_finished` | 审批任务已完成 | 无 | | `user_oa_approval_task_redirected` | 审批任务已转交 | 无 | | `user_oa_approval_instance_started` | 审批实例已发起 | 无 | +| `user_oa_approval_instance_cc` | 审批实例到达抄送节点,发送给被抄送人 | 无 | | `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 | | `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。 +只承认上表 23 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。七个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。 ## Intent mapping @@ -76,9 +77,10 @@ | "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` | | "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` | | "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` | +| "有审批抄送给我时通知我" | `event consume`,事件码 `user_oa_approval_instance_cc`,参数 `--flatten -f ndjson` | | "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` | | "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` | -| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 | +| "同时监听全部已公开 OA 事件" | 一个 consume 放入七个 OA event key,不加目标或消息过滤参数 | | "查看个人事件 schema" | `dws event schema --flatten` | | "看个人事件订阅状态" | `dws event status --event ` | | "停止这个个人事件订阅" | `dws event stop --dry-run`,确认后改用 `--yes` | @@ -130,6 +132,7 @@ dws event schema user_oa_approval_task_created --flatten dws event schema user_oa_approval_task_finished --flatten dws event schema user_oa_approval_task_redirected --flatten dws event schema user_oa_approval_instance_started --flatten +dws event schema user_oa_approval_instance_cc --flatten dws event schema user_oa_approval_instance_terminated --flatten dws event schema user_oa_approval_instance_finished --flatten ``` @@ -157,6 +160,7 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson dws event consume user_oa_approval_task_finished --flatten -f ndjson dws event consume user_oa_approval_task_redirected --flatten -f ndjson dws event consume user_oa_approval_instance_started --flatten -f ndjson +dws event consume user_oa_approval_instance_cc --flatten -f ndjson dws event consume user_oa_approval_instance_terminated --flatten -f ndjson dws event consume user_oa_approval_instance_finished --flatten -f ndjson ``` @@ -185,13 +189,14 @@ dws event consume \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten \ -f ndjson ``` -用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。 +用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。七个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。 上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user ` 替换为 `--open-dingtalk-id `,但两个参数不能同时使用。 @@ -216,7 +221,7 @@ dws event stop --all --yes ## 订阅创建失败与重试预算 -以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。 +以下约束适用于上表全部 23 个公开个人事件(16 个 IM + 7 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。 - `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。 - 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。 diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index 4fdd75a9..719c3d64 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-event -description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。 +description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/抄送/终止/完成。命令前缀:dws event。 metadata: cli_version: ">=0.2.14" category: product @@ -45,7 +45,7 @@ metadata: - `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。 - `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。 -OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。 +OA 事件不进入 `+listen-im`。七个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。七项可放入同一个 consume,每项建立独立订阅并共享 bus。 自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。 @@ -65,7 +65,7 @@ user_im_group_member_added user_im_group_member_exited user_im_group_disbanded ``` -六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。 +七个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。 用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。 @@ -98,7 +98,7 @@ kind + events + target - `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。 - 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。 -- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。 +- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 23 个公开个人 EventKey(16 个 IM + 7 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。 - 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。 - 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。 @@ -125,4 +125,4 @@ kind + events + target | ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer | | 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 | | Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 | -| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 | +| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择七个 OA EventKey、组合消费或解析审批字段 | diff --git a/skills/multi/dingtalk-event/references/event-oa.md b/skills/multi/dingtalk-event/references/event-oa.md index 6d7ec4e2..e8af0108 100644 --- a/skills/multi/dingtalk-event/references/event-oa.md +++ b/skills/multi/dingtalk-event/references/event-oa.md @@ -1,6 +1,6 @@ # OA 个人审批事件 -先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。 +先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的七个 OA 个人事件:审批实例发起、抄送、终止和完成,以及审批任务创建、完成和转交。 实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。 @@ -22,10 +22,11 @@ dws auth login | `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 | | `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 | | `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 | +| `user_oa_approval_instance_cc` | `all` | 审批实例到达抄送节点,发送给被抄送人 | 无 | | `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 | | `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。 +只承认上表 7 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。 ## Intent mapping @@ -35,13 +36,14 @@ dws auth login | “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` | | “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` | | “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` | +| “有审批抄送给我时通知我” | `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` | | “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` | | “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` | -| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 | +| “同时监听全部已公开 OA 事件” | 一个 consume 放入七个 OA event key,不加目标或过滤参数 | | “查看 OA 事件目录” | `dws event list --category oa` | | “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema --flatten` | -三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。 +三个审批任务事件分别表达任务已创建、已完成和已转交;四个审批实例事件分别表达实例已发起、到达抄送节点、已终止和已完成。`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。 ## Commands @@ -52,6 +54,7 @@ dws event schema user_oa_approval_task_created --flatten dws event schema user_oa_approval_task_finished --flatten dws event schema user_oa_approval_task_redirected --flatten dws event schema user_oa_approval_instance_started --flatten +dws event schema user_oa_approval_instance_cc --flatten dws event schema user_oa_approval_instance_terminated --flatten dws event schema user_oa_approval_instance_finished --flatten ``` @@ -63,11 +66,12 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson dws event consume user_oa_approval_task_finished --flatten -f ndjson dws event consume user_oa_approval_task_redirected --flatten -f ndjson dws event consume user_oa_approval_instance_started --flatten -f ndjson +dws event consume user_oa_approval_instance_cc --flatten -f ndjson dws event consume user_oa_approval_instance_terminated --flatten -f ndjson dws event consume user_oa_approval_instance_finished --flatten -f ndjson ``` -同时监听六种事件: +同时监听七种事件: ```bash dws event consume \ @@ -75,13 +79,14 @@ dws event consume \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten \ -f ndjson ``` -多事件 consume 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。 +多事件 consume 会为七个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。 ## Output contract @@ -105,7 +110,7 @@ dws event consume \ - `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。 - `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。 - `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。 -- 六类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。 +- 七类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。 | 事件 | 额外顶层字段 | |---|---| @@ -113,6 +118,7 @@ dws event consume \ | `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` | | `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` | | `user_oa_approval_instance_started` | 无 | +| `user_oa_approval_instance_cc` | 无 | | `user_oa_approval_instance_terminated` | `finish_time` | | `user_oa_approval_instance_finished` | `result`、`finish_time` | @@ -144,6 +150,6 @@ dws event consume \ ## Lifecycle - 单事件等待 `[event] ready event_key= bus_pid= subscribe_id=`。 -- 六事件先保存六条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count=6 bus_pid=`。 +- 七事件先保存七条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count=7 bus_pid=`。 - 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。 - 外部停止已有订阅时先运行 `dws event stop --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。 diff --git a/test/skill_tests.md b/test/skill_tests.md index 8c1b6c4e..2325e43d 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -1190,6 +1190,10 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 - Prompt: 有和我相关的审批实例发起时实时通知我 - Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson` +**event_event_consume_oa_instance_cc_001** +- Prompt: 有审批实例抄送给我时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` + **event_event_consume_oa_instance_terminated_001** - Prompt: 和我相关的审批实例终止时实时通知我 - Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 45877f26..d5c36cf1 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -280,7 +280,7 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { "群成员加入", "群成员退出", "审批任务创建/完成/转交", - "审批实例发起/终止/完成", + "审批实例发起/抄送/终止/完成", } { if !strings.Contains(frontmatter, required) { t.Errorf("%s frontmatter missing event discovery trigger %q", path, required) @@ -306,7 +306,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) { "", "", "16 个 EventKey", - "22 个公开个人 EventKey", + "23 个公开个人 EventKey", } { if !strings.Contains(string(skillContent), required) { t.Errorf("%s missing standalone event contract %q", skillPath, required) @@ -360,6 +360,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) { "user_oa_approval_task_finished", "user_oa_approval_task_redirected", "user_oa_approval_instance_started", + "user_oa_approval_instance_cc", "user_oa_approval_instance_terminated", "user_oa_approval_instance_finished", } From ec59cf80659a243025a45ef581cdb5de3deb7acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 12:14:05 +0800 Subject: [PATCH 141/267] test(calendar): run alias payloads in platform gate --- internal/app/param_alias_payload_equivalence_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index c212ccb3..60a46a00 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -750,11 +750,11 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres } } -func TestReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { +func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" { command := exec.Command( os.Args[0], - "-test.run=^TestReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$", + "-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$", "-test.count=1", "-test.timeout=5m", ) From 4c6db326f5a1248d4e443b180f4c310a1508c4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 13:30:40 +0800 Subject: [PATCH 142/267] fix(chat): preserve and validate user mention tokens --- .../shortcut/chat/coverage_completion_test.go | 59 +++++++++++- internal/shortcut/chat/lark_alignment_test.go | 10 +- internal/shortcut/chat/unified_send.go | 93 ++++++++++++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 0bdbe1ab..490e48ab 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T }{ { name: "user group mentions", - args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, + args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true}, }, { @@ -505,6 +505,63 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T } } +func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) { + tests := []struct { + name string + body string + atIDs string + atAll bool + wantErr string + }{ + { + name: "declared member missing from body", + body: "请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, + { + name: "body member missing from declaration", + body: "<@" + fixtureCurrentDOpenID + "> 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + { + name: "body at-all missing from declaration", + body: "<@all> 请查收", + wantErr: "必须同时指定 --at-all", + }, + { + name: "bare body member missing from declaration", + body: "@" + fixtureCurrentDOpenID + " 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--group", "cid", "--markdown", tt.body, "--yes", + } + if tt.atIDs != "" { + args = append(args, "--at-open-dingtalk-ids", tt.atIDs) + } + if tt.atAll { + args = append(args, "--at-all") + } + root.SetArgs(args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls) + } + }) + } +} + func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) { flags := append([]shortcut.Flag(nil), MessagesSend.Flags...) for i := range flags { diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index cea41825..18ba4296 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te } } if tt.bodyKey == "content" { + rawContent := call.args[tt.bodyKey].(string) + if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) { + t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent) + } + if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") { + t.Errorf("content = %q; missing literal current-user mention token", rawContent) + } var content map[string]string - if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil { + if err := json.Unmarshal([]byte(rawContent), &content); err != nil { t.Fatal(err) } if content["text"] != tt.body { diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 1977d077..bd3c66d9 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -11,6 +11,7 @@ import ( "os" "strings" "time" + "unicode" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -18,6 +19,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" @@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数") } + if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") { + if err := validateCurrentUserMentionConsistency( + messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil { + return err + } + } case "bot": if chatQuery != "" || userQuery != "" { return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID") @@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { return nil } +func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error { + declared := make(map[string]struct{}, len(atOpenIDs)) + for _, id := range atOpenIDs { + declared[id] = struct{}{} + } + + missingPlaceholders := make([]string, 0) + for _, id := range atOpenIDs { + if !containsCurrentUserMentionToken(body, id) { + missingPlaceholders = append(missingPlaceholders, id) + } + } + if len(missingPlaceholders) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s", + strings.Join(missingPlaceholders, ","), + )) + } + + undeclared := make([]string, 0) + for _, id := range currentUserMentionBodyIDs(body) { + if _, ok := declared[id]; !ok { + undeclared = append(undeclared, id) + } + } + if len(undeclared) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s", + strings.Join(undeclared, ","), + )) + } + if !atAll && containsCurrentUserMentionToken(body, "all") { + return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all") + } + return nil +} + +func containsCurrentUserMentionToken(body, id string) bool { + placeholder := "@" + id + for searchFrom := 0; ; { + offset := strings.Index(body[searchFrom:], placeholder) + if offset < 0 { + return false + } + end := searchFrom + offset + len(placeholder) + if end == len(body) { + return true + } + next, _ := utf8.DecodeRuneInString(body[end:]) + if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' { + return true + } + searchFrom = end + } +} + +func currentUserMentionBodyIDs(body string) []string { + ids := make([]string, 0) + for searchFrom := 0; ; { + offset := strings.IndexByte(body[searchFrom:], '@') + if offset < 0 { + break + } + start := searchFrom + offset + 1 + end := start + for end < len(body) { + current := body[end] + if (current < 'a' || current > 'z') && + (current < 'A' || current > 'Z') && + (current < '0' || current > '9') { + break + } + end++ + } + id := body[start:end] + if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) { + ids = appendUniqueShortcutString(ids, id) + } + searchFrom = end + } + return ids +} + func executeMessagesSend(rt *shortcut.RuntimeContext) error { identity := messagesSendIdentity(rt) body := messagesSendBody(rt) @@ -499,7 +590,7 @@ func resolvedUserMarkdownParams( if target.GroupID != "" { body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true) } - content, _ := json.Marshal(map[string]string{"title": title, "text": body}) + content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body}) params := rt.AddAIMessageTag(map[string]any{ "msgType": "markdown", "content": string(content), From 00ca448aa24f9b6cde51f8730ddcc4c00a35cf4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 13:34:45 +0800 Subject: [PATCH 143/267] docs(release): add chat mention fix fragment --- .changes/chat-user-mention-tokens.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/chat-user-mention-tokens.md diff --git a/.changes/chat-user-mention-tokens.md b/.changes/chat-user-mention-tokens.md new file mode 100644 index 00000000..3f070b7b --- /dev/null +++ b/.changes/chat-user-mention-tokens.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. From 98588441580a0722843cef5cd6fbe79a37b9118f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 13:37:11 +0800 Subject: [PATCH 144/267] fix(skills): no-clobber child moves in npm publish --- build/npm/install.js | 255 ++++++++++++++++++++++-------- test/scripts/install_js_smoke.mjs | 111 ++++++++++++- 2 files changed, 294 insertions(+), 72 deletions(-) diff --git a/build/npm/install.js b/build/npm/install.js index cb53ea56..345f54c2 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -370,56 +370,192 @@ function verifyPathCopySync(src, dest) { } } -function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) { +// removeClaimIfEmptySync deletes a claimed destination only while it holds no +// entries. A foreign entry that appeared after the claim must never be +// removed: the rollback has no ownership proof for it. +function removeClaimIfEmptySync(destination) { + try { + fs.rmdirSync(destination); + } catch (err) { + if (err && (err.code === "ENOTEMPTY" || err.code === "EEXIST" || err.code === "EPERM")) { + return; + } + throw err; + } +} + +function destinationExistsError(destination) { + return Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); +} + +// moveChildIntoClaimNoClobberSync moves one staged child into the claimed +// destination through an atomic no-clobber primitive: directories recursively +// claim with mkdir, regular files publish via fs.linkSync, and symlinks are +// recreated with fs.symlinkSync. Every primitive fails with EEXIST when the +// destination entry was taken concurrently, so no step can replace a foreign +// object the way a plain rename would. +function moveChildIntoClaimNoClobberSync(source, destination, fns) { const sourceStat = fs.lstatSync(source); if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { try { - fs.mkdirSync(destination, { mode: 0o700 }); + fns.mkdirFn(destination); } catch (err) { - if (err && err.code === "EEXIST") { - throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + // The recursive move leaves an emptied shell at the source. rmdirSync + // only deletes an empty directory, so clearing the shell keeps the + // single-move outcome the parent's rollback expects; a shell that gained + // a concurrent entry fails the removal and retracts the publication + // instead of silently discarding it. + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + removeClaimIfEmptySync(destination); + throw new Error( + `Skill move state uncertain; source shell ${source} removal failed: ${err.message}` + + (rollbackErr ? `; rollback failed: ${rollbackErr.message}` : ""), + ); + } + return; + } + if (sourceStat.isSymbolicLink()) { + const target = fs.readlinkSync(source); + try { + fns.symlinkFn(target, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + let current = null; + try { + current = fs.readlinkSync(destination); + } catch (_) { + // Dest no longer carries our link; leave whatever replaced it. + } + if (current === target) fs.unlinkSync(destination); + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fns.linkFn(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination, { bigint: true }); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (_) { + // Best effort; the error below names both retained paths. } throw err; } - const moved = []; + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +// moveChildrenBackOutOfClaimSync returns the already relocated children to +// the source with plain renames — every one of them is an object this +// transaction published, so a replacing rename is safe — and removes the +// claim only while it is empty. +function moveChildrenBackOutOfClaimSync(source, destination, moved, fns) { + let firstErr = null; + for (let i = moved.length - 1; i >= 0; i -= 1) { try { - for (const name of fs.readdirSync(source)) { - renameFn(path.join(source, name), path.join(destination, name)); - moved.push(name); - } - fs.chmodSync(destination, sourceStat.mode & 0o777); - try { - fs.rmdirSync(source); - } catch (err) { - // Dest already holds the children. A leftover source shell would make - // callers think the move succeeded while the original path still - // exists (empty) — retract dest so source remains the only live copy. - for (let i = moved.length - 1; i >= 0; i -= 1) { - try { - renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); - } catch (_) { - // Best effort: claim cleanup below reports whatever is left. - } - } - try { - fs.rmdirSync(destination); - } catch (cleanupErr) { - throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${cleanupErr.message}`); - } - throw err; - } + fns.renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); } catch (err) { - for (let i = moved.length - 1; i >= 0; i -= 1) { - try { - renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); - } catch (_) { - // Best effort: claim cleanup below reports whatever is left. - } + firstErr = firstErr || err; + } + } + try { + removeClaimIfEmptySync(destination); + } catch (err) { + firstErr = firstErr || err; + } + return firstErr; +} + +// moveChildrenIntoClaimSync populates an already-claimed destination from the +// staged source. The claim must still be empty when it runs (a concurrent +// writer landing between the claim and here aborts the publication with the +// destination retained), every child moves through an atomic no-clobber +// primitive, and a live re-read catches a different-named foreign entry that +// landed mid-move. Any failure returns the moved children to the source and +// removes the claim only while it is empty, so foreign data always survives. +function moveChildrenIntoClaimSync(source, destination, sourceMode, fns) { + const claimEntries = fs.readdirSync(destination); + if (claimEntries.length > 0) { + throw destinationExistsError(destination); + } + const entries = fs.readdirSync(source); + const moved = []; + for (const name of entries) { + try { + moveChildIntoClaimNoClobberSync(path.join(source, name), path.join(destination, name), fns); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${err.message}; rollback failed: ${rollbackErr.message}`); } - try { - fs.rmdirSync(destination); - } catch (cleanupErr) { - throw new Error(`${err.message}; failed to remove the claimed Skill destination ${destination}: ${cleanupErr.message}`); + throw err; + } + moved.push(name); + } + const live = fs.readdirSync(destination); + if (live.length > entries.length) { + const abortErr = destinationExistsError(destination); + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${abortErr.message}; rollback failed: ${rollbackErr.message}`); + } + throw abortErr; + } + fns.chmodFn(destination, sourceMode); +} + +function defaultClaimFns(renameFn) { + return { + mkdirFn: (target) => fs.mkdirSync(target, { mode: 0o700 }), + renameFn: renameFn || fs.renameSync, + linkFn: fs.linkSync, + symlinkFn: fs.symlinkSync, + chmodFn: fs.chmodSync, + }; +} + +function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + const fns = defaultClaimFns(renameFn); + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + if (rollbackErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${rollbackErr.message}`); } throw err; } @@ -430,7 +566,7 @@ function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) fs.symlinkSync(fs.readlinkSync(source), destination); } catch (err) { if (err && err.code === "EEXIST") { - throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + throw destinationExistsError(destination); } throw err; } @@ -451,7 +587,7 @@ function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) fs.linkSync(source, destination); } catch (err) { if (err && err.code === "EEXIST") { - throw Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); + throw destinationExistsError(destination); } throw err; } @@ -1104,12 +1240,16 @@ function rollbackPublishedSkillPath(publication, options = {}) { // only the (now empty) claim is removed, so the destination returns to the // foreign object or to nothing — never to a partial publication. function publishSkillDirNoReplace(staged, destination, options = {}) { - const mkdirFn = options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })); - const renameFn = options.publishRenameFn || fs.renameSync; - const chmodFn = options.publishChmodFn || fs.chmodSync; + const fns = { + mkdirFn: options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })), + renameFn: options.publishRenameFn || fs.renameSync, + linkFn: options.publishLinkFn || fs.linkSync, + symlinkFn: options.publishSymlinkFn || fs.symlinkSync, + chmodFn: options.publishChmodFn || fs.chmodSync, + }; const sourceStat = fs.lstatSync(staged); try { - mkdirFn(destination); + fns.mkdirFn(destination); } catch (err) { if (err && err.code === "EEXIST") { throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); @@ -1123,28 +1263,7 @@ function publishSkillDirNoReplace(staged, destination, options = {}) { inode: claimStat.ino.toString(), fingerprint: skillPathFingerprint(staged), }; - const moved = []; - try { - for (const name of fs.readdirSync(staged)) { - renameFn(path.join(staged, name), path.join(destination, name)); - moved.push(name); - } - chmodFn(destination, sourceStat.mode & 0o7777); - } catch (err) { - for (let i = moved.length - 1; i >= 0; i -= 1) { - try { - renameFn(path.join(destination, moved[i]), path.join(staged, moved[i])); - } catch (_) { - // Best effort: the claim cleanup below reports whatever is left. - } - } - try { - fs.rmdirSync(destination); - } catch (cleanupErr) { - throw new Error(`${err.message}; failed to remove the claimed Skill destination ${destination}: ${cleanupErr.message}`); - } - throw err; - } + moveChildrenIntoClaimSync(staged, destination, sourceStat.mode & 0o7777, fns); return publication; } diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 41fdd3bd..6d0db715 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -1452,11 +1452,13 @@ scenario("multi set publish failure restores the complete previous set", () => { } originalRename(src, dest); }, - publishRenameFn(src, dest) { + // Child files publish through the link primitive now, so the + // injected failure rides the link seam. + publishLinkFn(src, dest) { if (src.includes(".dws-multi-set.tmp-") && src.includes(`${path.sep}dingtalk-second${path.sep}`)) { throw new Error("injected second publish failure"); } - originalRename(src, dest); + return fs.linkSync(src, dest); }, }, ), @@ -1546,11 +1548,11 @@ for (const failureKind of ["backup", "publish"]) { } originalRename(src, target); }, - publishRenameFn(src, target) { + publishLinkFn(src, target) { if (failureKind === "publish" && src.includes(".dws-mono-set.tmp-")) { throw new Error("injected mono publish failure"); } - originalRename(src, target); + return fs.linkSync(src, target); }, }), /injected|failed to back up/, @@ -1657,6 +1659,107 @@ scenario("rollback refuses an unverifiable destination whose child vanished", () } }); +scenario("per-child no-clobber refuses same-name concurrent entries after the claim", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-child-race-")); + try { + const buildFixture = (label) => { + const home = path.join(tmp, label); + const source = path.join(tmp, `${label}-src`); + fs.mkdirSync(path.join(source, "dingtalk-chat", "references"), { recursive: true }); + writeFile(path.join(source, "dingtalk-chat", "SKILL.md"), "new chat\n"); + writeFile(path.join(source, "dingtalk-chat", "notes.md"), "notes\n"); + writeFile(path.join(source, "dingtalk-chat", "references", "guide.md"), "guide\n"); + fs.symlinkSync("SKILL.md", path.join(source, "dingtalk-chat", "rel-link")); + return { home, source, base: path.join(home, ".zcode", "skills") }; + }; + + { + // Same-name concurrent FILE: the foreign file lands at + // /SKILL.md between the claim and the per-child link primitive. + const { home, source, base } = buildFixture("file-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "SKILL.md"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishLinkFn(src, target) { + if (target === planted && !fs.existsSync(planted)) { + writeFile(planted, "concurrent-user-edit\n"); + } + return fs.linkSync(src, target); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readFileSync(planted, "utf8"), "concurrent-user-edit\n", "the concurrent file is never replaced"); + assert.deepEqual( + fs.readdirSync(dest).sort(), + ["SKILL.md"], + "no other child may move once the concurrent entry is seen", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent DIRECTORY at /references. + const { home, source, base } = buildFixture("dir-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "references"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishMkdirFn(target) { + if (target === planted && !fs.existsSync(planted)) { + fs.mkdirSync(path.join(planted, "user-data"), { recursive: true }); + writeFile(path.join(planted, "user-data", "keep.txt"), "keep\n"); + } + fs.mkdirSync(target, { mode: 0o700 }); + }, + }), + /Skill move destination already exists/, + ); + assert.equal( + fs.readFileSync(path.join(planted, "user-data", "keep.txt"), "utf8"), + "keep\n", + "the concurrent directory survives byte-identical", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent SYMLINK at /rel-link. + const { home, source, base } = buildFixture("link-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "rel-link"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkname) { + if (linkname === planted && !fs.existsSync(planted)) { + fs.symlinkSync("foreign-target", planted); + } + return fs.symlinkSync(target, linkname); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readlinkSync(planted), "foreign-target", "the concurrent link is never replaced"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); runCrossDeviceMoveContract(); process.stdout.write("ok\n"); From f3b0fcdc4c86b1da4ac83256daaf82d2d781b93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 13:53:59 +0800 Subject: [PATCH 145/267] test(calendar): verify aliases preserve confirmation --- .../param_alias_payload_equivalence_test.go | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 60a46a00..542c9b8a 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -73,13 +73,13 @@ var paramAliasCompleteCommands = map[string][]string{ "calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, "calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"}, "calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, - "calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2", "--yes"}, + "calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"}, "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, - "calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted", "--yes"}, + "calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"}, "calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, - "calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai", "--yes"}, - "calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2", "--yes"}, - "calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2", "--yes"}, + "calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"}, + "calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"}, + "calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"}, "calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"}, "chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"}, "chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, @@ -687,6 +687,21 @@ var paramAliasCalendarPayloadCases = map[string]bool{ paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true, } +// paramAliasCalendarConfirmationCases selects one newly reviewed alias for +// every Calendar Shortcut whose runtime contract requires user confirmation. +// The complete Calendar matrix proves confirmed canonical/alias payload +// equality; these representatives additionally prove semantic normalization +// cannot cross the confirmation boundary before the first transport call. +var paramAliasCalendarConfirmationCases = map[string]bool{ + paramAliasPayloadCaseKey("calendar +book", "summary"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, + paramAliasPayloadCaseKey("calendar +create", "summary"): true, + paramAliasPayloadCaseKey("calendar +invite", "id"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, + paramAliasPayloadCaseKey("calendar +update", "event-id"): true, +} + func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { concepts, err := cli.LoadParamConcepts() if err != nil { @@ -772,6 +787,7 @@ func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivale } executed := make(map[string]bool) + executedConfirmation := make(map[string]bool) for _, fixture := range concepts.Fixture { caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) if !paramAliasCalendarPayloadCases[caseKey] { @@ -790,6 +806,10 @@ func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivale t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs) } assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs) + if paramAliasCalendarConfirmationCases[caseKey] { + executedConfirmation[caseKey] = true + assertParamAliasCannotBypassConfirmation(t, aliasArgs) + } }) } @@ -801,6 +821,35 @@ func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivale if len(executed) != len(paramAliasCalendarPayloadCases) { t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases)) } + for caseKey := range paramAliasCalendarConfirmationCases { + if !executedConfirmation[caseKey] { + t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey) + } + } + if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) { + t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases)) + } +} + +func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) { + t.Helper() + unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes") + if removals != 1 { + t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs) + } + + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...) + if ctx == nil { + t.Fatal("unconfirmed Calendar alias command skipped PreParse") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls) + } } func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) { From 63854705fdd7d6628215617fb76715f94b831f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 19 Aug 2026 14:22:02 +0800 Subject: [PATCH 146/267] fix(chat): route direct media upload targets --- .changes/chat-user-mention-tokens.md | 1 + .../shortcut/chat/coverage_completion_test.go | 6 + internal/shortcut/chat/gap_fill_test.go | 113 ++++++++++++++++++ internal/shortcut/chat/unified_send.go | 19 ++- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/.changes/chat-user-mention-tokens.md b/.changes/chat-user-mention-tokens.md index 3f070b7b..166f6708 100644 --- a/.changes/chat-user-mention-tokens.md +++ b/.changes/chat-user-mention-tokens.md @@ -3,3 +3,4 @@ category: Fixed --- - **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 490e48ab..2ea6714a 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -519,6 +519,12 @@ func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t atIDs: fixtureCurrentDOpenID, wantErr: "必须在正文中使用对应", }, + { + name: "declared member only appears as a longer token prefix", + body: "@" + fixtureCurrentDOpenID + "_suffix 请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, { name: "body member missing from declaration", body: "<@" + fixtureCurrentDOpenID + "> 请查收", diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 2526af79..0946632c 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) } } +func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil { + t.Fatal(err) + } + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("upload method = %s", r.Method) + } + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(uploadServer.Close) + + tests := []struct { + name string + targetArgs []string + requestedType string + wantOpenID string + wantUserResolve bool + }{ + { + name: "explicit open id file", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID}, + requestedType: "file", + wantOpenID: fixtureCurrentDOpenID, + }, + { + name: "resolved user audio", + targetArgs: []string{"--user", "user-id"}, + requestedType: "audio", + wantOpenID: "D-resolved", + wantUserResolve: true, + }, + { + name: "explicit open id video", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2}, + requestedType: "video", + wantOpenID: fixtureCurrentDOpenID2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`, + "im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`, + "chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--msg-type", tt.requestedType, + "--file", "./direct.bin", + "--idempotency-key", "direct-" + tt.requestedType, + "--yes", + } + args = append(args, tt.targetArgs...) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + callOffset := 0 + if tt.wantUserResolve { + if len(fake.calls) == 0 || fake.calls[0].product != "contact" || + fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("resolution call = %#v", fake.calls) + } + callOffset = 1 + } + if len(fake.calls) != callOffset+3 { + t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls) + } + initCall := fake.calls[callOffset] + commitCall := fake.calls[callOffset+1] + sendCall := fake.calls[callOffset+2] + for _, call := range []larkAlignmentCall{initCall, commitCall} { + if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID { + t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID) + } + if _, exists := call.args["receiverOpenDingTalkId"]; exists { + t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args) + } + } + if initCall.tool != "init_conversation_file_upload" || + commitCall.tool != "commit_conversation_file_upload" { + t.Fatalf("upload calls = %#v, %#v", initCall, commitCall) + } + if sendCall.product != "chat" || sendCall.tool != "send_personal_message" || + sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID { + t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID) + } + if _, exists := sendCall.args["openDingTalkId"]; exists { + t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args) + } + + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["requestedMessageType"] != tt.requestedType || + payload["effectiveMessageType"] != "file" { + t.Fatalf("output = %#v", payload) + } + }) + } +} + func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) { t.Chdir(t.TempDir()) if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil { diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index bd3c66d9..670ae6e8 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -664,8 +664,10 @@ func executeMessagesSendUserFile( if err != nil { return err } - targetArgs := map[string]any{} - addMessagesSendUserTarget(targetArgs, group, openID) + uploadTargetArgs := map[string]any{} + addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID) + sendTargetArgs := map[string]any{} + addMessagesSendUserTarget(sendTargetArgs, group, openID) idempotencyKey := messagesSendIdempotencyKey(rt) if rt.DryRun() { return rt.Output(map[string]any{ @@ -678,6 +680,7 @@ func executeMessagesSendUserFile( { "identity": "user", "tool": "init/commit_conversation_file_upload", + "target": uploadTargetArgs, "file": map[string]any{ "path": rawPath, "name": meta.FileName, @@ -689,7 +692,7 @@ func executeMessagesSendUserFile( "tool": "send_personal_message", "requestedMessageType": requestedType, "effectiveMessageType": "file", - "target": targetArgs, + "target": sendTargetArgs, }, }, }) @@ -698,7 +701,7 @@ func executeMessagesSendUserFile( rt.Command().Context(), messagesSendFileUploadTimeout) defer cancelUpload() commitText, err := helpers.UploadConversationLocalFile( - uploadContext, targetArgs, meta, idempotencyKey) + uploadContext, uploadTargetArgs, meta, idempotencyKey) if err != nil { return err } @@ -744,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) { params["receiverOpenDingTalkId"] = openID } +func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) { + if group != "" { + params["openConversationId"] = group + return + } + params["openDingTalkId"] = openID +} + func nonEmptyStringCount(values ...string) int { count := 0 for _, value := range values { From 8b4453adf972bbf6abd4262f860415ae7bf01a0a Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 12:49:16 +0800 Subject: [PATCH 147/267] feat(attendance): harden shortcut contracts and live evidence --- .../attendance.md | 179 ++++++++++ docs/shortcut-public-catalog.json | 149 +++++--- internal/app/schema_shortcut_contract_test.go | 4 +- internal/shortcut/attendance/attendance.go | 327 ++++++++++-------- ...nce_strict_cross_platform_coverage_test.go | 125 +++++++ internal/shortcut/attendance/common.go | 206 +++++++++++ .../attendance/rule_class_projection_test.go | 22 +- .../attendance_semantic_catalog_test.go | 85 +++++ internal/shortcut/public_catalog_generated.go | 4 +- .../shortcut/responsecheck/responsecheck.go | 181 ++++++++++ .../responsecheck/responsecheck_test.go | 84 +++++ internal/shortcut/semantic_catalog.go | 4 + .../shortcut/semantic_catalog_attendance.json | 44 +++ internal/shortcut/smart/attendance_strict.go | 31 ++ ...nce_strict_cross_platform_coverage_test.go | 28 ++ internal/shortcut/smart/my_attendance.go | 13 +- .../shortcut/smart/this_month_attendance.go | 13 +- scripts/gen_shortcut_public_catalog.py | 1 + 18 files changed, 1279 insertions(+), 221 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/attendance.md create mode 100644 internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/attendance/common.go create mode 100644 internal/shortcut/builtin/attendance_semantic_catalog_test.go create mode 100644 internal/shortcut/responsecheck/responsecheck.go create mode 100644 internal/shortcut/responsecheck/responsecheck_test.go create mode 100644 internal/shortcut/semantic_catalog_attendance.json create mode 100644 internal/shortcut/smart/attendance_strict.go create mode 100644 internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md new file mode 100644 index 00000000..2ff7bf2d --- /dev/null +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -0,0 +1,179 @@ +# Attendance Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` +> 对比基线:Lark CLI 1.0.87 +> 范围:Attendance Shortcut only;不修改 DWS 产品 Skill 或 multi Skill。 + +## 1. 执行摘要 + +- Attendance 共审核 35 个源码 Shortcut;17 个具备公开条件,18 个保持 hidden/unavailable。 +- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 +- 已确认 4 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少确定终止证据、缺少安全可回收的管理员/写操作 fixture。 +- 班次详情曾因未公开而缺失,现已通过“搜索非空结果 → 同一稳定 ID 详情”闭环恢复;严格响应校验、统一 Result 和空结果判定均属于上游修复,不要求下游改动。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | +| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | +| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | +| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;分页终止为保守推断 | contract insufficient | 返回 `complete`/`nextOffset`,不声明框架 cursor 分页 | +| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | covered | 无 | 公开,严格身份闭环 | +| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | +| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均保持 hidden/unavailable | +| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | +| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture,全部 hidden | +| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | +| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | + +## 3. 下游需求明细 + +### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 + +#### A. 用户任务与现状 + +- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 +- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 +- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 +- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 +- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 +- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 +- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 +- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 +- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 +- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 +- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 + +#### C. 验收标准 + +1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 +2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 +3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 +4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 + +#### D. 临时处置 + +`+get-adjustment-rule` 保持 hidden/unavailable 并从 Agent Schema 排除;`+search-adjustment-rule` 不再承诺详情入口可用。 + +### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 + +#### A. 用户任务与现状 + +- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 +- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 +- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 +- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 +- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 +- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 +- 安全证据句柄:`ATT-REPORT-NULL-01`。 + +#### B. 需要下游提供的合同 + +- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 +- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 +- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 +- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 +- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 +- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 + +#### C. 验收标准 + +1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 +2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 +3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 +4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 +5. 分页/partial 分支和远端零残留通过。 + +#### D. 临时处置 + +三个报表 Shortcut 均保持 hidden/unavailable;不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 + +### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 + +#### A. 用户任务与现状 + +- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 +- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `complete=false` 和建议 nextOffset,不能声明全量完成。 +- 安全证据句柄:`ATT-CHECK-PAGE-01`。 + +#### B. 需要下游提供的合同 + +- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 +- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 +- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 +- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 + +#### C. 验收标准与临时处置 + +- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 +- 下游完成前,DWS 保留数据层 `complete/nextOffset`,不伪造 cursor PaginationSpec,也不把满页当完整结果。 + +### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 + +#### A. 用户任务与现状 + +- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 +- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 +- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 +- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 +- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 + +#### B. 需要的测试基础设施与合同 + +- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 +- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 +- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 +- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 +- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 + +#### C. 验收标准与临时处置 + +1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 +2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 +3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 +4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 +5. 测试结束远端和本地均零残留。 + +在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---|---| +| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | + +无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---|---| +| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | +| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | +| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 所有公开 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法 | 单元负向矩阵;真实非空与零命中 E2E | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求稳定 ID、公开分页完成证据 | 非空搜索、零命中、详情闭环和坏 item 回归 | +| `+get-class` | 能力存在但未公开、无 Result/严格读回 | 补 Contract/Safety/Result,搜索 ID 精确读回并恢复公开 | exact live E2E | +| `+my-attendance`, `+this-month` | 直接透传 raw data,缺严格空结果边界 | 统一结果 envelope 并复用严格打卡流水校验 | exact live E2E 与 contract gate | + +## 7. 安全与脱敏声明 + +- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 +- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 +- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 +- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 30bfa1d6..ee9f8452 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T17:38:50.904696", - "count": 436, + "generated_at": "2026-08-18T12:08:16.875520", + "count": 433, "results": [ { "suite": "semantic", @@ -933,137 +933,174 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,缺字段、错型或坏记录均失败。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-result", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对齐 Lark attendance user_tasks query 的打卡结果任务;严格验证 success 与显式 result 数组,并公开 offset/limit 的完整性和 nextOffset 证据。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-adjustment-rule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-approve-template", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-checkin-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.list 签到数组,区分外勤签到与考勤打卡流水。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "attendance", + "command": "+get-class", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用搜索得到的班次 ID 读取详情,要求显式成功和非空 result;已用同一真实班次完成搜索到详情闭环。", + "availability": "available" + }, + { + "suite": "semantic", "service": "attendance", "command": "+get-leave-records", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按用户、假期编码和日期读取余额变更记录;显式空数组合法,缺失结果或坏记录失败。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-overtime-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result;真实搜索到详情闭环已通过。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-schedule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "规范日期范围并严格验证排班数组;缺失或畸形响应不再成为空排班。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-self-setting", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按用户和设置场景读取个人考勤开关,要求显式业务成功和非空 result。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-summary", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按周或月读取个人考勤摘要,要求显式业务成功和非空 result。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+list-approve", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把审批类型语义映射为业务枚举并严格验证审批数组;真实非空与空结果均已执行。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+list-leave-types", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证显式假期类型数组,为假期编码的后续读取提供可验证入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+my-attendance", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动解析当前用户并查询今天的打卡流水,最终仍按严格 success/result 数组契约输出。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+query-report-data", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-adjustment-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-class", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.items、展开 shiftVO、要求稳定班次 ID,并公开 page/totalCount 完整性证据。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+search-group", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-overtime-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+this-month", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动解析当前用户并查询本月打卡流水,最终仍按严格 success/result 数组契约输出。", + "availability": "available" }, { "suite": "semantic", @@ -4132,4 +4169,4 @@ "availability": "available" } ] -} \ No newline at end of file +} diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index a4c5d83e..c072b15f 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 435 + publicShortcutCount = 432 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. schemaPublishedShortcutCount = 438 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 435 + publiclyDeliveredShortcutCount = 432 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index 067f922c..b0018c7d 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -34,6 +34,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) const ( @@ -205,6 +206,13 @@ var CheckResult = shortcut.Shortcut{ }, Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { + limit, offset := rt.Int("limit"), rt.Int("offset") + if limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if offset < 0 { + return fmt.Errorf("--offset 不能小于 0") + } from, err := dayDateTime(rt.Str("start")) if err != nil { return err @@ -213,15 +221,29 @@ var CheckResult = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_result", map[string]any{ + params := map[string]any{ "QueryCheckResultRequest": map[string]any{ "userIds": rt.StrSlice("users"), "workDateFrom": from, "workDateTo": to, - "offset": rt.Int("offset"), - "limit": rt.Int("limit"), + "offset": offset, + "limit": limit, }, - }) + } + data, err := rt.CallMCPData(serverWukong, "query_check_result", params) + if err != nil { + return err + } + records, err := responsecheck.RequireObjectCollection(data, serverWukong+"/query_check_result", "result") + if err != nil { + return err + } + complete := len(records) < limit + extra := map[string]any{"limit": limit} + if !complete { + extra["nextOffset"] = offset + len(records) + } + return rt.Output(attendanceCollectionPayload("records", records, complete, extra)) }, } @@ -273,13 +295,13 @@ var CheckRecord = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_record", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_check_record", map[string]any{ "QueryCheckRecordRequest": map[string]any{ "userIds": rt.StrSlice("users"), "checkDateFrom": from, "checkDateTo": to, }, - }) + }, "records", true, nil, "result") }, } @@ -346,14 +368,14 @@ var ListApprove = shortcut.Shortcut{ } bizTypes = append(bizTypes, bizType) } - return rt.CallMCP("query_user_approve", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_user_approve", map[string]any{ "QueryUserApproveRequest": map[string]any{ "userIds": rt.StrSlice("users"), "bizTypes": bizTypes, "fromDate": from, "toDate": to, }, - }) + }, "approvals", true, nil, "result") }, } @@ -405,9 +427,9 @@ var GetApproveTemplate = shortcut.Shortcut{ default: return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input) } - return rt.CallMCP("query_at_approve_template", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, - }) + }, "templates", true, nil, "result") }, } @@ -464,13 +486,13 @@ var GetSchedule = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("getScheduleByRange", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "getScheduleByRange", map[string]any{ "GetScheduleByRangeRequest": map[string]any{ "userIdList": rt.StrSlice("users"), "workDateBegin": begin, "workDateEnd": end, }, - }) + }, "schedules", true, nil, "result") }, } @@ -565,13 +587,11 @@ var SearchClass = shortcut.Shortcut{ }, Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, Execute: func(rt *shortcut.RuntimeContext) error { - pageQuery := map[string]any{} - if v := rt.Int("page"); v > 0 { - pageQuery["pageIndex"] = v - } - if v := rt.Int("limit"); v > 0 { - pageQuery["pageSize"] = v + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } + pageQuery := map[string]any{"pageIndex": page, "pageSize": limit} shiftParam := map[string]any{} if v := rt.Str("query"); v != "" { shiftParam["searchName"] = v @@ -590,69 +610,56 @@ var SearchClass = shortcut.Shortcut{ if err != nil { return err } - classes := searchClassProject(data) - return rt.Output(map[string]any{"count": len(classes), "classes": classes}) + classes, err := searchClassProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("classes", classes, complete, extra)) }, } // searchClassProject reshapes the raw get_class_list response into a clean class // list ({classId, name, ownerName}) — clean output projection. Both // 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 searchClassProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "classList", "shiftList", "list", "items") +// result.items is the reviewed wire path. Missing/wrong collections and bad +// rows fail closed instead of becoming an empty list. +func searchClassProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_class_list", "result.items") + 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 - } + for index, item := range raw { + m := item // get_class_list wraps each shift's identity/label under shiftVO // (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every // row projects empty and the whole command silently returns nothing. - if vo, ok := m["shiftVO"].(map[string]any); ok { + if value, present := m["shiftVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "malformed_item", fmt.Sprintf("result.items[%d].shiftVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { - row["classId"] = v + v, ok := attendanceFirst(m, "id", "classId", "class_id") + if !ok || v == nil { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index)) } + row["classId"] = v if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok { row["ownerName"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// attendanceResolveList locates the list payload inside a response, tolerating a -// bare top-level array under a common container key or nesting one level deeper -// under result/data. extraKeys are probed ahead of the generic containers. -func attendanceResolveList(data map[string]any, extraKeys ...string) []any { - keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items") - for _, key := range keys { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // attendanceFirst returns the first present candidate key's value. @@ -673,12 +680,37 @@ var GetClass = shortcut.Shortcut{ Description: "根据班次 ID 查询班次详情", Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "attendance", + Name: "shortcut_get_class", + CanonicalPath: "attendance.shortcut_get_class", + CLIPath: "attendance +get-class", + PrimaryCLIPath: "attendance +get-class", + }, + Description: "根据班次 ID 查询班次详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns strict response validation and output projection for this attendance detail operation.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据班次 ID 查询班次详情", + UseWhen: []string{"当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。"}, + AvoidWhen: []string{"不知道班次 ID 时先用 +search-class;不要用本读取命令代替创建或更新班次。"}, + Examples: []string{"dws attendance +get-class --class-id 12345"}, + }, + }, Flags: []shortcut.Flag{ {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, }, Tips: []string{`dws attendance +get-class --class-id 1170996821`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_class_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_class_detail", map[string]any{ "classId": int64(rt.Int("class-id")), }) }, @@ -785,8 +817,8 @@ var GetAdjustmentRule = shortcut.Shortcut{ Description: "根据补卡规则主键 ID 查询补卡规则详情", 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.", + Availability: "unavailable", + Reason: "The detail service returns success=true with result=null for IDs obtained from the live adjustment-rule search; the command remains hidden until the downstream ID/result contract is repaired.", }, Selection: contract.SelectionSpec{ AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情", @@ -800,7 +832,7 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_adjustment_rule_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_adjustment_rule_detail", map[string]any{ "adjustmentId": int64(rt.Int("adjustment-id")), }) }, @@ -812,7 +844,7 @@ var SearchAdjustmentRule = shortcut.Shortcut{ Command: "+search-adjustment-rule", Product: serverWukong, Description: "查询当前用户可管理的补卡规则列表", - Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。", + Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -834,8 +866,8 @@ var SearchAdjustmentRule = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的补卡规则列表", - UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。"}, + AvoidWhen: []string{"需要完整补卡规则详情时应报告当前下游详情能力 unavailable,不要把搜索摘要或空 result 当作详情成功"}, Examples: []string{"dws attendance +search-adjustment-rule --query \"标准\" --page 1 --limit 50"}, }, }, @@ -850,13 +882,9 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -866,44 +894,54 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_adjustment_rule", "result.adjustmentList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("rules", rules, complete, extra)) }, } // searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule -// response into a clean rule list ({ruleId, name}) — 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 searchRuleProject(data map[string]any) []map[string]any { +// response into a clean rule list ({ruleId, name}). Each caller supplies the +// single reviewed collection path; missing/wrong collections and malformed +// rows fail closed instead of becoming an empty list. +func searchRuleProject(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { // get_overtime_rule nests the list under result.atRuleList and // get_adjustment_rule under result.adjustmentList; both wrap each rule's // identity/label under entityVO. Probe those container keys AND unwrap // entityVO, otherwise +search-overtime-rule / +search-adjustment-rule // silently return empty despite the backend returning rules. - raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items") + raw, err := responsecheck.RequireObjectCollection(data, operation, paths...) + 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 - } - if vo, ok := m["entityVO"].(map[string]any); ok { + for index, item := range raw { + m := item + if value, present := m["entityVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("规则结果第 %d 项 entityVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { - row["ruleId"] = v + v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId") + if !ok || v == nil { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index)) } + row["ruleId"] = v if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { row["name"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + return out, nil } // ── overtime rule ─────────────────────────────────────────── @@ -946,7 +984,7 @@ var GetOvertimeRule = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_overtime_rule_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_overtime_rule_detail", map[string]any{ "overtimeId": int64(rt.Int("overtime-id")), }) }, @@ -996,13 +1034,9 @@ var SearchOvertimeRule = shortcut.Shortcut{ if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -1012,8 +1046,15 @@ var SearchOvertimeRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_overtime_rule", "result.atRuleList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("rules", rules, complete, extra)) }, } @@ -1042,8 +1083,8 @@ var SearchGroup = shortcut.Shortcut{ 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.", + Availability: "unavailable", + Reason: "Strict projection is implemented, but the current safe tenant has no known non-empty attendance-group fixture; live empty-only evidence is insufficient for publication.", }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的考勤组列表", @@ -1079,13 +1120,9 @@ var SearchGroup = shortcut.Shortcut{ searchParam["queryPositionAndWifiNames"] = false searchParam["queryBleDeviceList"] = false } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{ "param": searchParam, @@ -1097,39 +1134,44 @@ var SearchGroup = shortcut.Shortcut{ if err != nil { return err } - groups := searchGroupProject(data) - return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + groups, err := searchGroupProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("groups", groups, complete, extra)) }, } // searchGroupProject reshapes the raw get_simple_groups response into a clean // attendance-group list ({groupId, name, type}) — 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 searchGroupProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "groupList", "groups", "list", "items") +// The exact result.items collection and each row identity are required, so an +// unknown shape cannot masquerade as a legitimate empty search result. +func searchGroupProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_simple_groups", "result.items") + 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 - } + for index, m := range raw { row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok { - row["groupId"] = v + v, ok := attendanceFirst(m, "id", "groupId", "group_id") + if !ok || v == nil { + return nil, responsecheck.Error(serverWukong+"/get_simple_groups", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少考勤组 ID", index)) } + row["groupId"] = v if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok { row["type"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + return out, nil } // GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。 @@ -1379,7 +1421,7 @@ var GetSummary = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("get_user_attendance_summary", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_user_attendance_summary", map[string]any{ "userId": rt.Str("user"), "queryDate": queryDate, "statsType": rt.Str("stats-type"), @@ -1431,7 +1473,7 @@ var GetSelfSetting = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_self_setting", map[string]any{ + return attendanceCallObject(rt, serverWukong, "query_self_setting", map[string]any{ "RuleMcpQuerySelfSettingRequest": map[string]any{ "settingScene": rt.Str("setting-scene"), "userId": rt.Str("user"), @@ -1503,8 +1545,8 @@ var QueryReportData = shortcut.Shortcut{ 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.", + Availability: "unavailable", + Reason: "The downstream report-column discovery returns null, so no verified column ID exists for an exact non-empty report-data E2E; the command remains hidden until that contract is repaired.", }, Selection: contract.SelectionSpec{ AgentSummary: "根据字段查询考勤报表数据(仅管理员)", @@ -1541,7 +1583,7 @@ var QueryReportData = shortcut.Shortcut{ } columnIds = append(columnIds, id) } - return rt.CallMCP("get_report_columns_value", map[string]any{ + return attendanceCallValue(rt, serverWukong, "get_report_columns_value", map[string]any{ "McpQueryParam": map[string]any{ "targetUserIds": rt.StrSlice("users"), "columnIds": columnIds, @@ -1624,9 +1666,9 @@ var ListLeaveTypes = shortcut.Shortcut{ }, Tips: []string{`dws attendance +list-leave-types`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_leave_types", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{ "McpLeaveTypeRequest": map[string]any{}, - }) + }, "leaveTypes", true, nil, "result") }, } @@ -1713,9 +1755,9 @@ var GetLeaveRecords = shortcut.Shortcut{ if v := rt.Str("leave-code"); v != "" { req["leaveCode"] = v } - return rt.CallMCP("get_leave_balance_records_v2", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{ "McpLeaveRecordRequest": req, - }) + }, "records", true, nil, "result") }, } @@ -1894,7 +1936,7 @@ var GetCheckinRecord = shortcut.Shortcut{ if endT.Before(startT) { return fmt.Errorf("--end 不能早于 --start") } - return rt.CallMCP("get_checkin_record", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_checkin_record", map[string]any{ "QueryMcpUserRecordRequest": map[string]any{ "operatorCorpId": rt.Str("operator-corp-id"), "operatorStaffId": rt.Str("operator-staff-id"), @@ -1902,7 +1944,7 @@ var GetCheckinRecord = shortcut.Shortcut{ "startTime": startStr, "endTime": endStr, }, - }) + }, "records", true, nil, "result.list") }, } @@ -1954,6 +1996,7 @@ var BossCheck = shortcut.Shortcut{ } func init() { + hardenPublicAttendanceContracts() shortcut.Register( CheckResult, CheckRecord, diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..bbe160a9 --- /dev/null +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) { + shortcuts := []struct { + name string + rollout output.RolloutState + hasResult bool + resultSchema string + }{ + {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema)}, + {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema)}, + {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema)}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema)}, + {GetSchedule.Command, GetSchedule.OutputRollout, GetSchedule.Contract.Result != nil, string(GetSchedule.Contract.Result.DataSchema)}, + {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema)}, + {GetClass.Command, GetClass.OutputRollout, GetClass.Contract.Result != nil, string(GetClass.Contract.Result.DataSchema)}, + {GetAdjustmentRule.Command, GetAdjustmentRule.OutputRollout, GetAdjustmentRule.Contract.Result != nil, string(GetAdjustmentRule.Contract.Result.DataSchema)}, + {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema)}, + {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema)}, + {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema)}, + {SearchGroup.Command, SearchGroup.OutputRollout, SearchGroup.Contract.Result != nil, string(SearchGroup.Contract.Result.DataSchema)}, + {GetSummary.Command, GetSummary.OutputRollout, GetSummary.Contract.Result != nil, string(GetSummary.Contract.Result.DataSchema)}, + {GetSelfSetting.Command, GetSelfSetting.OutputRollout, GetSelfSetting.Contract.Result != nil, string(GetSelfSetting.Contract.Result.DataSchema)}, + {QueryReportData.Command, QueryReportData.OutputRollout, QueryReportData.Contract.Result != nil, string(QueryReportData.Contract.Result.DataSchema)}, + {ListLeaveTypes.Command, ListLeaveTypes.OutputRollout, ListLeaveTypes.Contract.Result != nil, string(ListLeaveTypes.Contract.Result.DataSchema)}, + {GetLeaveRecords.Command, GetLeaveRecords.OutputRollout, GetLeaveRecords.Contract.Result != nil, string(GetLeaveRecords.Contract.Result.DataSchema)}, + {GetCheckinRecord.Command, GetCheckinRecord.OutputRollout, GetCheckinRecord.Contract.Result != nil, string(GetCheckinRecord.Contract.Result.DataSchema)}, + } + for _, item := range shortcuts { + if item.rollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout) + } + if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" { + t.Errorf("%s must publish a non-empty Result", item.name) + } + } +} + +func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, + "adjustmentList": []any{}, + "atRuleList": []any{}, + }, + } + if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err) + } + if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err) + } + if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err) + } + + broken := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"items": "not-an-array"}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}}, + } + for index, data := range broken { + if got, err := searchClassProject(data); err == nil { + t.Errorf("broken class response %d returned success: %v", index, got) + } + if got, err := searchGroupProject(data); err == nil { + t.Errorf("broken group response %d returned success: %v", index, got) + } + } + + ruleBroken := map[string]any{ + "success": true, + "result": map[string]any{ + "adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}}, + }, + } + if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil { + t.Fatalf("rule row without stable ID returned success: %v", got) + } +} + +func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) { + withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}} + if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20); err == nil { + t.Fatal("missing totalCount/totalPage must not be reported as a complete page") + } + firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}} + complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20) + if err != nil { + t.Fatalf("valid page evidence: %v", err) + } + if complete || extra["nextPage"] != 2 { + t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra) + } + lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}} + complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20) + if err != nil || !complete { + t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err) + } + conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20); err == nil { + t.Fatal("conflicting totalPage/totalCount evidence must fail closed") + } + mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}} + if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20); err == nil { + t.Fatal("mismatched currentPage must fail closed") + } + negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}} + if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20); err == nil { + t.Fatal("negative pagination evidence must fail closed") + } +} diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go new file mode 100644 index 00000000..a1ba2fb4 --- /dev/null +++ b/internal/shortcut/attendance/common.go @@ -0,0 +1,206 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "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/internal/shortcut/responsecheck" +) + +func attendanceObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"description":"严格校验后的考勤业务结果"}},"required":["value"],"additionalProperties":false}`, + description, + )), + } +} + +func attendanceCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"考勤业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"现有服务端证据是否证明结果已经完整"},"page":{"type":"integer","description":"当前页码"},"limit":{"type":"integer","description":"请求的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"totalPage":{"type":"integer","description":"服务端报告的总页数"},"nextPage":{"type":"integer","description":"结果未完整时建议请求的下一页"},"nextOffset":{"type":"integer","description":"结果未完整时建议请求的下一偏移量"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + } +} + +func hardenPublicAttendanceContracts() { + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + }{ + {&CheckResult, "records", "严格校验的员工打卡结果"}, + {&CheckRecord, "records", "严格校验的员工打卡流水"}, + {&ListApprove, "approvals", "严格校验的考勤审批记录"}, + {&GetApproveTemplate, "templates", "严格校验的考勤审批模板"}, + {&GetSchedule, "schedules", "严格校验的员工排班记录"}, + {&SearchClass, "classes", "严格校验的班次搜索结果"}, + {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果"}, + {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果"}, + {&SearchGroup, "groups", "严格校验的考勤组搜索结果"}, + {&ListLeaveTypes, "leaveTypes", "严格校验的假期类型列表"}, + {&GetLeaveRecords, "records", "严格校验的假期余额变更记录"}, + {&GetCheckinRecord, "records", "严格校验的签到记录"}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description) + } + objects := []struct { + declaration *shortcut.Shortcut + description string + }{ + {&GetClass, "严格校验的班次详情"}, + {&GetAdjustmentRule, "严格校验的补卡规则详情"}, + {&GetOvertimeRule, "严格校验的加班规则详情"}, + {&GetSummary, "严格校验的个人考勤统计摘要"}, + {&GetSelfSetting, "严格校验的个人考勤设置"}, + {&QueryReportData, "严格校验的考勤报表数据"}, + } + for _, item := range objects { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = attendanceObjectResult(item.description) + } +} + +func attendanceCallCollection( + rt *shortcut.RuntimeContext, + product, tool string, + params map[string]any, + collection string, + complete bool, + extra map[string]any, + paths ...string, +) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...) + if err != nil { + return err + } + payload := map[string]any{ + "count": len(items), + collection: items, + "complete": complete, + } + for key, value := range extra { + payload[key] = value + } + return rt.Output(payload) +} + +func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any { + payload := map[string]any{ + "count": len(items), + collection: items, + "complete": complete, + } + for key, value := range extra { + payload[key] = value + } + return payload +} + +func attendancePageEvidence(data map[string]any, operation string, page, limit int) (bool, map[string]any, error) { + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return false, nil, err + } + extra := map[string]any{"page": page, "limit": limit} + if currentPage, ok := attendanceInt(result["currentPage"]); ok && currentPage != page { + return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + } + var evidence []bool + if totalPage, ok := attendanceInt(result["totalPage"]); ok { + if totalPage < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数") + } + extra["totalPage"] = totalPage + evidence = append(evidence, page >= totalPage) + } + if totalCount, ok := attendanceInt(result["totalCount"]); ok { + if totalCount < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数") + } + extra["totalCount"] = totalCount + evidence = append(evidence, page*limit >= totalCount) + } + if len(evidence) == 0 { + return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整") + } + complete := evidence[0] + for _, candidate := range evidence[1:] { + if candidate != complete { + return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据") + } + } + if !complete { + extra["nextPage"] = page + 1 + } + return complete, extra, nil +} + +func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) { + page, limit := rt.Int("page"), rt.Int("limit") + if page < 1 { + return 0, 0, fmt.Errorf("--page 必须大于等于 1") + } + if limit < 1 || limit > 200 { + return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间") + } + return page, limit, nil +} + +func attendanceInt(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, true + case int32: + return int(number), true + case int64: + return int(number), true + case float64: + if number != float64(int(number)) { + return 0, false + } + return int(number), true + default: + return 0, false + } +} diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go index 1d6e0165..149ecb22 100644 --- a/internal/shortcut/attendance/rule_class_projection_test.go +++ b/internal/shortcut/attendance/rule_class_projection_test.go @@ -18,12 +18,12 @@ import ( "testing" ) -// TestSearchClassProjectShiftVOShape guards against projection-data-loss: +// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss: // get_class_list nests the list under result.items and wraps each shift's // identity under shiftVO. The projection must unwrap shiftVO or +search-class // silently returns empty despite the backend returning shifts. -func TestSearchClassProjectShiftVOShape(t *testing.T) { - const raw = `{"result":{"items":[ +func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) { + const raw = `{"success":true,"result":{"items":[ {"shiftVO":{"id":957395083,"name":"default shift"}}, {"shiftVO":{"id":957395084,"name":"morning shift"}} ]}}` @@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := searchClassProject(data) + got, err := searchClassProject(data) + if err != nil { + t.Fatalf("project class response: %v", err) + } if len(got) != 2 { t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got) } @@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { } } -// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) +// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) // and get_adjustment_rule (result.adjustmentList), both wrapping the rule under // entityVO. The resolver must probe those keys and unwrap entityVO or // +search-overtime-rule / +search-adjustment-rule silently return empty. -func TestSearchRuleProjectEntityVOShape(t *testing.T) { +func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) { for _, tc := range []struct { name, key string }{ {"overtime", "atRuleList"}, {"adjustment", "adjustmentList"}, } { - raw := `{"result":{"` + tc.key + `":[ + raw := `{"success":true,"result":{"` + tc.key + `":[ {"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}}, {"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}} ]}}` @@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err) } - got := searchRuleProject(data) + got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key) + if err != nil { + t.Fatalf("[%s] project rule response: %v", tc.name, err) + } if len(got) != 2 { t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got)) } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go new file mode 100644 index 00000000..081ffcd6 --- /dev/null +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_attendance.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "attendance" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "attendance" { + continue + } + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 35 || len(source.Shortcuts) != 35 { + t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts)) + } + public := 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } else if availability == shortcut.AvailabilityAvailable || !item.Hidden { + t.Errorf("%s: unavailable shortcut must stay hidden", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 17 { + t.Fatalf("public attendance shortcuts = %d, want 17", public) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 0aba731a..2d2c6ca8 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -101,9 +101,9 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+workflow-list": {}, "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, - "attendance\u0000+get-adjustment-rule": {}, "attendance\u0000+get-approve-template": {}, "attendance\u0000+get-checkin-record": {}, + "attendance\u0000+get-class": {}, "attendance\u0000+get-leave-records": {}, "attendance\u0000+get-overtime-rule": {}, "attendance\u0000+get-schedule": {}, @@ -112,10 +112,8 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "attendance\u0000+list-approve": {}, "attendance\u0000+list-leave-types": {}, "attendance\u0000+my-attendance": {}, - "attendance\u0000+query-report-data": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, - "attendance\u0000+search-group": {}, "attendance\u0000+search-overtime-rule": {}, "attendance\u0000+this-month": {}, "calendar\u0000+agenda": {}, diff --git a/internal/shortcut/responsecheck/responsecheck.go b/internal/shortcut/responsecheck/responsecheck.go new file mode 100644 index 00000000..1b175933 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck.go @@ -0,0 +1,181 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package responsecheck contains fail-closed response validators shared by +// built-in shortcuts. It deliberately does not guess that a missing collection +// means an empty result: callers must name the exact reviewed wire paths. +package responsecheck + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Error returns a non-retryable response-validation error. The remote request +// has completed, but the response is not sufficient evidence of success. +func Error(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), + ) +} + +// RequireSuccess validates the common service envelope and returns it. A +// missing success field is rejected because the products using this helper +// have been observed to publish an explicit boolean success field. +func RequireSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + envelope := data + if content, ok := data["content"].(map[string]any); ok { + envelope = content + } + raw, present := envelope["success"] + if !present { + return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功") + } + success, ok := raw.(bool) + if !ok { + return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstString(envelope, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, Error(operation, "remote_failure", message) + } + return envelope, nil +} + +// RequireResult returns the explicit non-null result value from a successful +// response. Empty arrays and empty objects remain valid values; absence/null do +// not prove an empty business result. +func RequireResult(data map[string]any, operation string) (any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + result, present := envelope["result"] + if !present || result == nil { + return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段") + } + return result, nil +} + +// RequireObjectResult returns an explicit object result. +func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + object, ok := result.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result)) + } + return object, nil +} + +// RequireSingleObjectResult returns one non-empty business object. It accepts +// the two reviewed detail shapes used by product services: result={...} and +// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values +// cannot prove that the requested unique resource was read. +func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + var object map[string]any + switch typed := result.(type) { + case map[string]any: + object = typed + case []any: + if len(typed) != 1 { + return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed))) + } + var ok bool + object, ok = typed[0].(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0])) + } + default: + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result)) + } + if len(object) == 0 { + return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功") + } + return object, nil +} + +// RequireObjectCollection resolves the first present reviewed path, requires +// an array at that exact path, and rejects malformed elements instead of +// silently dropping them. A present empty array is a valid empty result. +func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, path := range paths { + value, present := lookup(envelope, path) + if !present { + continue + } + raw, ok := value.([]any) + if !ok { + return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil + } + return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果") +} + +// LookupObject returns the object at a reviewed dotted path. +func LookupObject(data map[string]any, operation, path string) (map[string]any, error) { + value, present := lookup(data, path) + if !present { + return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value)) + } + return object, nil +} + +func lookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func firstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go new file mode 100644 index 00000000..6d1a64df --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package responsecheck + +import "testing" + +func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantLen int + wantErr bool + }{ + {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1}, + {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1}, + {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0}, + {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true}, + {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true}, + {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true}, + {name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true}, + {name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true}, + {name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true}, + {name: "empty response", data: map[string]any{}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RequireObjectCollection(tc.data, "test/read", "result") + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && len(got) != tc.wantLen { + t.Fatalf("len = %d, want %d", len(got), tc.wantLen) + } + }) + } +} + +func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) { + data := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"id": "one"}}, + }, + } + items, err := RequireObjectCollection(data, "test/nested", "result.items") + if err != nil { + t.Fatalf("RequireObjectCollection: %v", err) + } + if len(items) != 1 { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) { + if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil { + t.Fatal("expected null result to fail closed") + } + if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil { + t.Fatal("expected array result to fail object validation") + } +} + +func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"id": "one"}}, + {"success": true, "result": []any{map[string]any{"id": "one"}}}, + } { + object, err := RequireSingleObjectResult(data, "test/detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err) + } + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{}}, + {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}}, + {"success": true, "result": []any{"bad"}}, + } { + if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil { + t.Fatalf("ambiguous/malformed detail returned success: %v", object) + } + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index fbb5b312..f08b297d 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -34,6 +34,9 @@ var calendarSemanticCatalogJSON []byte //go:embed semantic_catalog_todo.json var todoSemanticCatalogJSON []byte +//go:embed semantic_catalog_attendance.json +var attendanceSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -60,6 +63,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( wikiSemanticCatalogJSON, calendarSemanticCatalogJSON, todoSemanticCatalogJSON, + attendanceSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json new file mode 100644 index 00000000..7c8716fe --- /dev/null +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "service": "attendance", + "default_availability": "unavailable", + "shortcuts": { + "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query 的打卡结果任务;严格验证 success 与显式 result 数组,并公开 offset/limit 的完整性和 nextOffset 证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,缺字段、错型或坏记录均失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举并严格验证审批数组;真实非空与空结果均已执行。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"规范日期范围并严格验证排班数组;缺失或畸形响应不再成为空排班。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求稳定班次 ID,并公开 page/totalCount 完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的班次 ID 读取详情,要求显式成功和非空 result;已用同一真实班次完成搜索到详情闭环。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result;真实搜索到详情闭环已通过。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"按周或月读取个人考勤摘要,要求显式业务成功和非空 result。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"按用户和设置场景读取个人考勤开关,要求显式业务成功和非空 result。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"严格验证显式假期类型数组,为假期编码的后续读取提供可验证入口。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"按用户、假期编码和日期读取余额变更记录;显式空数组合法,缺失结果或坏记录失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.list 签到数组,区分外勤签到与考勤打卡流水。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"自动解析当前用户并查询今天的打卡流水,最终仍按严格 success/result 数组契约输出。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"自动解析当前用户并查询本月打卡流水,最终仍按严格 success/result 数组契约输出。","risk":"read","availability":"available","public":true,"reviewed":true}, + + "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + + "+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go new file mode 100644 index 00000000..40ecf569 --- /dev/null +++ b/internal/shortcut/smart/attendance_strict.go @@ -0,0 +1,31 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +func attendanceRecordsResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`), + } +} + +func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error { + records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result") + if err != nil { + return err + } + return rt.Output(map[string]any{ + "count": len(records), + "records": records, + "complete": true, + }) +} diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..12b62f4d --- /dev/null +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +func TestCrossPlatformCoverageSmartAttendanceContractsAreUnifiedAndTyped(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + hasResult bool + }{ + {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil}, + {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil}, + } { + if declaration.rollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout = %q, want unified_active", declaration.name, declaration.rollout) + } + if !declaration.hasResult { + t.Errorf("%s must publish a Result declaration", declaration.name) + } + } +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 1c445d14..54d1bf76 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -20,6 +20,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +42,11 @@ import ( // // dws attendance +my-attendance var MyAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+my-attendance", - Product: "attendance", - Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutUnifiedActive, + Service: "attendance", + Command: "+my-attendance", + Product: "attendance", + Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," + "最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。", @@ -73,6 +75,7 @@ var MyAttendance = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws attendance +my-attendance"}, }, + Result: attendanceRecordsResult(), }, Flags: []shortcut.Flag{}, Tips: []string{ @@ -110,7 +113,7 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index 3363e6f8..461873d8 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -20,6 +20,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +42,11 @@ import ( // // dws attendance +this-month var ThisMonthAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+this-month", - Product: "attendance", - Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutUnifiedActive, + Service: "attendance", + Command: "+this-month", + Product: "attendance", + Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," + "最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。", @@ -73,6 +75,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws attendance +this-month"}, }, + Result: attendanceRecordsResult(), }, Flags: []shortcut.Flag{}, Tips: []string{ @@ -110,7 +113,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 0b37827d..e0542f45 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -27,6 +27,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json", ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ] From 725e60f07cdcea6dccc8d27227a7958c41785fd8 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 14:08:34 +0800 Subject: [PATCH 148/267] feat(mail): harden and align shortcut workflows --- docs/shortcut-downstream-requirements/mail.md | 171 +++++++ docs/shortcut-public-catalog.json | 154 +++++- internal/app/schema_shortcut_contract_test.go | 6 +- .../builtin/mail_semantic_catalog_test.go | 79 +++ internal/shortcut/mail/common.go | 301 ++++++++++++ internal/shortcut/mail/mail.go | 449 +++--------------- ...nfirmation_cross_platform_coverage_test.go | 62 +++ ...ail_strict_cross_platform_coverage_test.go | 93 ++++ internal/shortcut/mail/reads.go | 143 ++++++ .../mail/thread_list_projection_test.go | 11 +- internal/shortcut/mail/writes.go | 287 +++++++++++ internal/shortcut/public_catalog_generated.go | 8 + internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_mail.json | 25 + internal/shortcut/smart/find_mail_user.go | 46 +- .../smart/mail_space_todo_projection_test.go | 34 +- internal/shortcut/smart/mail_strict.go | 236 +++++++++ ...ail_strict_cross_platform_coverage_test.go | 73 +++ internal/shortcut/smart/recent_mail.go | 50 +- internal/shortcut/smart/search_mail.go | 134 ++---- internal/shortcut/smart/triage_mail.go | 96 ++++ internal/shortcut/smart/unread_mail.go | 27 +- scripts/gen_shortcut_public_catalog.py | 1 + 23 files changed, 1894 insertions(+), 596 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/mail.md create mode 100644 internal/shortcut/builtin/mail_semantic_catalog_test.go create mode 100644 internal/shortcut/mail/common.go create mode 100644 internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go create mode 100644 internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/mail/reads.go create mode 100644 internal/shortcut/mail/writes.go create mode 100644 internal/shortcut/semantic_catalog_mail.json create mode 100644 internal/shortcut/smart/mail_strict.go create mode 100644 internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/smart/triage_mail.go diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md new file mode 100644 index 00000000..e7d76783 --- /dev/null +++ b/docs/shortcut-downstream-requirements/mail.md @@ -0,0 +1,171 @@ +# Mail Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut only;不修改 `skills/multi` 或 `skills/mono` +> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 + +## 1. 执行摘要 + +本轮把 Mail 从 10 个列表/搜索入口扩展并收紧为 18 个公开 Shortcut:新增 `+triage`、`+message`、`+messages`、`+thread`、`+draft-create`、`+draft-edit`、`+template-create`、`+template-update`;现有列表和 smart 入口全部改为严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result。真实 E2E 已覆盖已知非空、保证零命中、单封/多封/会话身份绑定、草稿与模板写后读回、联系人和模板临时 fixture 清理。 + +仍不能诚实对齐的任务集中在发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口补足可验证合同。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | +| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | +| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | +| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | +| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | +| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | +| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | +| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | +| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 浏览/筛选摘要 | `+triage`、`+search-mail`、`+unread-mail`、`+recent-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | +| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | covered | 无 | 公开,确认 + 写后读回 | +| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | partial | 附件与 HTML 资源事务缺失 | 公开核心字段;不承诺附件对齐 | +| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | +| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | +| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | +| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | +| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | +| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | +| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | +| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | +| 文件夹/标签/联系人/企业邮箱用户 | 现有严格列表与搜索入口 | 无同名任务入口 | DWS extra | 非对齐项 | 作为 DWS 原生增量保留 | + +## 3. 下游需求明细 + +### `DS-Mail-001` — 可验证的发送生命周期 + +- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 +- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 +- 所需接口合同: + - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 + - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 + - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 + - 支持幂等键,或明确 unknown commit 不可自动重试。 + - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 +- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 + +### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 + +- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 +- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 +- 所需接口合同: + - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 + - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 + - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 +- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 + +### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 + +- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 +- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 +- 所需接口合同: + - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 + - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 + - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 + - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 +- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 + +### `DS-Mail-004` — 已读回执与签名 + +- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 +- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 +- 所需接口合同: + - 消息详情公开稳定回执请求状态和请求者身份类型。 + - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 + - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 +- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 + +### `DS-Mail-005` — 新邮件监听的订阅生命周期 + +- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 +- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 +- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 +- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 + +### `DS-Mail-006` — 模板附件与内联图片事务 + +- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 +- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 +- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 +- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 + +### `DS-Mail-007` — 联系人写操作的稳定身份 + +- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 +- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 +- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 +- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 + +### `DS-Mail-008` — 统一成功、空结果与分页协议 + +- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 +- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。 +- 所需接口合同: + - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 + - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 + - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 + - 保留兼容期,但提供 capability/version 让上游安全切换。 +- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 + +### `DS-Mail-009` — 安全租户与真实 E2E fixture + +- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 +- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 +- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 +- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---| +| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | +| `+draft-create` / `+draft-edit` | yes(核心正文) | 附件编辑仍依赖下游事务 | 已公开,附件不宣称完整对齐 | +| `+template-create` / `+template-update` | partial | 核心字段已读回;附件/内联资源/并发更新不足 | 已公开核心能力,差异写入合同 | +| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | +| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | +| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | +| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | +| `+signature` | no | 缺签名读取资源 | platform unavailable | +| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | +| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | +| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---| +| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | +| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | +| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | +| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | +| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID | 单测矩阵 + live 非空/空结果 | +| `+search-mail` / `+unread-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | exact live 零命中 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | exact live 非空和随机零命中 | +| `+contact-list` | 字符串 `hasMore` 会造成漂移或误判 | 只接受已观测布尔/精确字符串 true/false | 临时联系人 create→list→delete | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | exact live 同 ID | +| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对 | exact live create/update/readback/cleanup | + +## 7. 安全与脱敏声明 + +- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 +- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 +- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 +- 临时草稿已移出草稿箱,临时模板和联系人已删除并复查;最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index ee9f8452..2803af01 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T12:08:16.875520", - "count": 433, + "generated_at": "2026-08-18T13:37:43.285113", + "count": 441, "results": [ { "suite": "semantic", @@ -3342,74 +3342,184 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+contact-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+draft-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "mail", + "command": "+draft-edit", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+find-mail-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+folder-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+message", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "mail", + "command": "+messages", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+recent-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+search-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+tag-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+template-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+template-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+template-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "mail", + "command": "+thread", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+thread-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+triage", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+unread-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+user-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index c072b15f..c92a37a7 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 432 + publicShortcutCount = 440 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 438 + schemaPublishedShortcutCount = 446 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 432 + publiclyDeliveredShortcutCount = 440 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go new file mode 100644 index 00000000..03387b0e --- /dev/null +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_mail.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "mail" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "mail" { + registered[item.Command] = item + } + } + if len(registered) != 18 || len(source.Shortcuts) != 18 { + t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + public := 0 + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 18 { + t.Fatalf("public mail shortcuts = %d, want 18", public) + } +} diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go new file mode 100644 index 00000000..116a3fbf --- /dev/null +++ b/internal/shortcut/mail/common.go @@ -0,0 +1,301 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "encoding/json" + "fmt" + "strings" + + "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 mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." + +func mailCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + } +} + +func mailObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients"}, + } +} + +func mailReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func mailWriteSafety(idempotency string) contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency} +} + +func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "mail " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: name, CanonicalPath: "mail." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"}, + Examples: examples, + }, + Parameters: flags, + } +} + +func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...) + contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"} + return contractDecl +} + +func mailResponseError(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), + ) +} + +// mailRequireSuccess accepts only the two success encodings observed from the +// Mail backend: boolean true and the exact string "true". Missing, false, or +// malformed status values fail closed. +func mailRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态") + } + message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务未明确返回成功状态" + } + return mailResponseError(operation, "remote_failure", message) +} + +func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present { + return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present || value == nil { + return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := mailFirstString(object, keys...) + if actual == "" { + return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致") + } + return nil +} + +func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error { + for index, item := range items { + if mailFirstString(item, identityKeys...) == "" { + return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return nil +} + +func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) { + items, err := mailRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + if err := mailValidateRows(items, operation, identityKeys...); err != nil { + return nil, err + } + projected := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := make(map[string]any, len(fields)) + for outputName, candidates := range fields { + for _, candidate := range candidates { + if value, present := item[candidate]; present && value != nil { + row[outputName] = value + break + } + } + } + projected = append(projected, row) + } + return projected, nil +} + +func mailPage(data map[string]any, operation, prefix string) (bool, string, error) { + container := data + if prefix != "" { + value, present := mailLookup(data, prefix) + if !present { + return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象") + } + } + nextValue, nextPresent := container["nextCursor"] + next, nextOK := nextValue.(string) + if !nextPresent || !nextOK { + return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整") + } + if raw, present := container["hasMore"]; present { + hasMore, ok := mailBool(raw) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore && terminal { + return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空") + } + if !hasMore && !terminal { + return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + if !hasMore { + return true, "", nil + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + return false, next, nil +} + +func mailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func mailLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func mailFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func hardenPublicMailContracts() { + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + }{ + {&ThreadList, "threads", "严格校验的邮件会话列表"}, + {&FolderList, "folders", "严格校验的邮箱文件夹列表"}, + {&TagList, "tags", "严格校验的邮件标签列表"}, + {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果"}, + {&TemplateList, "templates", "严格校验的邮件模板列表"}, + {&ContactList, "contacts", "严格校验的邮件联系人列表"}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description) + item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} + } +} diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 525505ce..9e099282 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -110,76 +110,20 @@ var ThreadList = shortcut.Shortcut{ if err != nil { return err } - threads := threadListProject(data) - return rt.Output(map[string]any{"count": len(threads), "threads": threads}) + threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result") + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("threads", threads, complete, next)) }, } -// threadListProject reshapes the raw list_mailbox_threads response into a clean -// {conversationId, subject, lastUpdated, isRead} thread list — -// clean output projection. Both the list container and per-item -// field names are probed defensively across candidate keys, so an empty/unknown -// shape yields an empty list rather than a crash or fabricated data. -func threadListProject(data map[string]any) []map[string]any { - raw := threadListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok { - row["conversationId"] = v - } - if v, ok := threadListFirst(m, "subject", "title", "topic"); ok { - row["subject"] = v - } - if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok { - row["lastUpdated"] = v - } - if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok { - row["isRead"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// threadListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func threadListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} { - v, ok := data[key] - 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", "threads", "conversations", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// threadListFirst returns the first present candidate key's value. -func threadListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // Thread 根据会话 ID 获取会话详情。 // ThreadUpdate 修改单个邮件会话的状态或标签。 // ThreadBatchUpdate 批量修改邮件会话的状态或标签。 @@ -238,73 +182,16 @@ var FolderList = shortcut.Shortcut{ if err != nil { return err } - folders := folderListProject(data) - return rt.Output(map[string]any{"count": len(folders), "folders": folders}) + folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"}, + }) + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("folders", folders, true, "")) }, } -// folderListProject reshapes the raw list_folders response into a clean -// {id, name, parentId} folder list — clean output projection. Both -// the list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func folderListProject(data map[string]any) []map[string]any { - raw := folderListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok { - row["id"] = v - } - if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok { - row["name"] = v - } - if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// folderListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func folderListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "folders"} { - v, ok := data[key] - 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", "folders", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// folderListFirst returns the first present candidate key's value. -func folderListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // FolderCreate 创建邮件文件夹。 // FolderUpdate 更新邮件文件夹名称。 // FolderDelete 删除邮件文件夹。 @@ -356,73 +243,16 @@ var TagList = shortcut.Shortcut{ if err != nil { return err } - tags := tagListProject(data) - return rt.Output(map[string]any{"count": len(tags), "tags": tags}) + tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "parentId": {"parentId"}, + }) + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("tags", tags, true, "")) }, } -// tagListProject reshapes the raw list_tags response into a clean -// {id, name, parentId} tag list — clean output projection. Both the -// list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func tagListProject(data map[string]any) []map[string]any { - raw := tagListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok { - row["id"] = v - } - if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok { - row["name"] = v - } - if v, ok := tagListFirst(m, "parentId", "parent_id"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// tagListResolveList locates the list payload inside the response, tolerating a -// bare top-level array container or nesting one level deeper. -func tagListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "tags"} { - v, ok := data[key] - 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", "tags", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// tagListFirst returns the first present candidate key's value. -func tagListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TagCreate 创建邮件标签。 // TagUpdate 更新邮件标签名称。 // TagDelete 删除邮件标签。 @@ -499,76 +329,20 @@ var UserSearch = shortcut.Shortcut{ if err != nil { return err } - users := userSearchProject(data) - return rt.Output(map[string]any{"count": len(users), "users": users}) + users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{ + "name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/search_mail_users", "") + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("users", users, complete, next)) }, } -// userSearchProject reshapes the raw search_mail_users response into a clean -// {name, email, employeeNo, userId} user list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func userSearchProject(data map[string]any) []map[string]any { - raw := userSearchResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok { - row["name"] = v - } - if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok { - row["email"] = v - } - if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok { - row["employeeNo"] = v - } - if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok { - row["userId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// userSearchResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func userSearchResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "users"} { - v, ok := data[key] - 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", "users", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// userSearchFirst returns the first present candidate key's value. -func userSearchFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ── attachment ───────────────────────────────────────────── // AttachmentList 列举邮件附件。 @@ -628,73 +402,20 @@ var TemplateList = shortcut.Shortcut{ if err != nil { return err } - templates := templateListProject(data) - return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "subject": {"subject"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_message_templates", "") + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("templates", templates, complete, next)) }, } -// templateListProject reshapes the raw list_user_message_templates response into -// a clean {id, name, subject} template list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func templateListProject(data map[string]any) []map[string]any { - raw := templateListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok { - row["id"] = v - } - if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok { - row["name"] = v - } - if v, ok := templateListFirst(m, "subject", "title"); ok { - row["subject"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// templateListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func templateListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "templates"} { - v, ok := data[key] - 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", "templates", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// templateListFirst returns the first present candidate key's value. -func templateListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TemplateGet 获取邮件模板详情。 // TemplateUpdate 更新邮件模板。 // TemplateDelete 删除邮件模板。 @@ -754,73 +475,20 @@ var ContactList = shortcut.Shortcut{ if err != nil { return err } - contacts := contactListProject(data) - return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts}) + contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{ + "id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "") + if err != nil { + return err + } + return rt.Output(mailCollectionPayload("contacts", contacts, complete, next)) }, } -// contactListProject reshapes the raw list_user_mail_contacts response into a -// clean {id, contactEmail, displayName} contact list — output-projection -// clean output projection. Both the list container and per-item field names are probed -// defensively across candidate keys, so an empty/unknown shape yields an empty -// list rather than a crash or fabricated data. -func contactListProject(data map[string]any) []map[string]any { - raw := contactListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok { - row["id"] = v - } - if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok { - row["contactEmail"] = v - } - if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok { - row["displayName"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// contactListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func contactListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "contacts"} { - v, ok := data[key] - 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", "contacts", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// contactListFirst returns the first present candidate key's value. -func contactListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ContactUpdate 更新邮件联系人。 // ContactBatchDelete 批量删除邮件联系人。 // ── auto-reply ───────────────────────────────────────────── @@ -834,6 +502,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) { // RuleDelete 删除个人收信规则。 // RuleAdjust 调整收信规则排序。 func init() { + hardenPublicMailContracts() shortcut.Register( ThreadList, FolderList, diff --git a/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go new file mode 100644 index 00000000..99b7ea65 --- /dev/null +++ b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "io" + "testing" + + "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 mailConfirmationCaller struct{ calls int } + +func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil +} + +func (*mailConfirmationCaller) Format() string { return "json" } +func (*mailConfirmationCaller) DryRun() bool { return false } +func (*mailConfirmationCaller) Fields() string { return "" } +func (*mailConfirmationCaller) JQ() string { return "" } + +func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) { + cases := [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"}, + } + for _, args := range cases { + caller := &mailConfirmationCaller{} + if err := runUnconfirmedMailWrite(t, caller, args...); err == nil { + t.Fatalf("unconfirmed %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls) + } + } +} diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..7864ca0f --- /dev/null +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { + declarations := []*struct { + rollout output.RolloutState + result bool + }{ + {ThreadList.OutputRollout, ThreadList.Contract.Result != nil}, + {FolderList.OutputRollout, FolderList.Contract.Result != nil}, + {TagList.OutputRollout, TagList.Contract.Result != nil}, + {UserSearch.OutputRollout, UserSearch.Contract.Result != nil}, + {TemplateList.OutputRollout, TemplateList.Contract.Result != nil}, + {ContactList.OutputRollout, ContactList.Contract.Result != nil}, + {Message.OutputRollout, Message.Contract.Result != nil}, + {Messages.OutputRollout, Messages.Contract.Result != nil}, + {Thread.OutputRollout, Thread.Contract.Result != nil}, + {DraftCreate.OutputRollout, DraftCreate.Contract.Result != nil}, + {DraftEdit.OutputRollout, DraftEdit.Contract.Result != nil}, + {TemplateCreate.OutputRollout, TemplateCreate.Contract.Result != nil}, + {TemplateUpdate.OutputRollout, TemplateUpdate.Contract.Result != nil}, + } + for index, declaration := range declarations { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("mail declaration %d is not unified with Result", index) + } + } +} + +func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) { + for _, declaration := range []struct { + name string + confirmation string + }{ + {DraftCreate.Command, DraftCreate.Safety.Confirmation}, + {DraftEdit.Command, DraftEdit.Safety.Confirmation}, + {TemplateCreate.Command, TemplateCreate.Safety.Confirmation}, + {TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation}, + } { + if declaration.confirmation != "user_required" { + t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation) + } + } +} + +func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "folders": []any{}} + items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "missing success": {"folders": []any{}}, + "missing collection": {"success": "true"}, + "wrong collection": {"success": true, "folders": map[string]any{}}, + "bad item": {"success": "true", "folders": []any{"bad"}}, + } { + if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } + if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil { + t.Fatal("string false must not be success") + } + if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil { + t.Fatalf("boolean true must be accepted: %v", err) + } +} + +func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) { + complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "") + if err != nil || !complete || next != "" { + t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err) + } + complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "") + if err != nil || !complete || next != "" { + t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", ""); err == nil { + t.Fatal("hasMore without a progressing cursor must fail") + } + if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil { + t.Fatal("identity mismatch must fail") + } +} diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go new file mode 100644 index 00000000..edf688c1 --- /dev/null +++ b/internal/shortcut/mail/reads.go @@ -0,0 +1,143 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "strings" + + "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" +) + +func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) { + if email := strings.TrimSpace(rt.Str("email")); email != "" { + return email, nil + } + data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil) + if err != nil { + return "", err + } + mailboxes, err := mailRequireCollection(data, "mail/list_user_mailboxes", "emailAccounts") + if err != nil { + return "", err + } + if len(mailboxes) == 0 { + return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定") + } + email := mailFirstString(mailboxes[0], "email") + if email == "" { + return "", mailResponseError("mail/list_user_mailboxes", "missing_mailbox_identity", "首个邮箱对象缺少 email") + } + return email, nil +} + +func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id}) + if err != nil { + return nil, err + } + message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message") + if err != nil { + return nil, err + } + if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil { + return nil, err + } + return message, nil +} + +var Message = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+message", Product: "mail", + Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + message, err := mailReadMessage(rt, email, rt.Str("id")) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": message}) + }, +} + +var Messages = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+messages", Product: "mail", + Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`), + Flags: []shortcut.Flag{ + {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,最多 100 个"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := rt.StrSlice("ids") + if len(ids) == 0 || len(ids) > 100 { + return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") + } + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + messages := make([]map[string]any, 0, len(ids)) + for index, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + message, err := mailReadMessage(rt, email, id) + if err != nil { + return err + } + messages = append(messages, message) + } + return rt.Output(mailCollectionPayload("messages", messages, true, "")) + }, +} + +var Thread = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+thread", Product: "mail", + Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")}) + if err != nil { + return err + } + conversation, err := mailRequireObject(data, "mail/get_thread", "conversation") + if err != nil { + return err + } + if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil { + return err + } + return rt.Output(map[string]any{"value": conversation}) + }, +} + +func init() { + shortcut.Register(Message, Messages, Thread) +} diff --git a/internal/shortcut/mail/thread_list_projection_test.go b/internal/shortcut/mail/thread_list_projection_test.go index c2f178d5..d34f639e 100644 --- a/internal/shortcut/mail/thread_list_projection_test.go +++ b/internal/shortcut/mail/thread_list_projection_test.go @@ -21,18 +21,23 @@ import ( // TestThreadListProjectConversationsShape guards the exact // list_mailbox_threads response contract, including lastModifiedDateTime. func TestThreadListProjectConversationsShape(t *testing.T) { - const raw = `{"result":{"conversations":[{ + const raw = `{"success":true,"result":{"conversations":[{ "id":"thread-1", "subject":"Projection contract", "lastModifiedDateTime":"2026-07-26T10:00:00Z", "isRead":false - }]}}` + }],"hasMore":false,"nextCursor":""}}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := threadListProject(data) + got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + t.Fatalf("strict projection: %v", err) + } if len(got) != 1 { t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got) } diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go new file mode 100644 index 00000000..9484776e --- /dev/null +++ b/internal/shortcut/mail/writes.go @@ -0,0 +1,287 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "strings" + + "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" +) + +func mailRecipients(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, "result.message") + if !present { + return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message") + } + message, ok := value.(map[string]any) + if !ok || len(message) == 0 { + return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value)) + } + if err := mailRequireIdentity(message, operation, "", "id"); err != nil { + return nil, err + } + return message, nil +} + +func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id}) + if err != nil { + return nil, err + } + if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil { + return nil, err + } + template := make(map[string]any, len(data)) + for key, value := range data { + switch key { + case "success", "errorCode", "errorMsg", "arguments": + continue + default: + template[key] = value + } + } + if len(template) == 0 { + return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空") + } + if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil { + return nil, err + } + return template, nil +} + +func mailTemplateSubject(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "subject") +} + +func mailTemplateBody(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "markdownBody", "body") +} + +var DraftCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-create", Product: "mail", + Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")} + if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 { + params["toRecipients"] = recipients + } + if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 { + params["ccRecipients"] = recipients + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/create_draft") + if err != nil { + return err + } + id := mailFirstString(receipt, "id") + verified, err := mailReadMessage(rt, rt.Str("from"), id) + if err != nil { + return err + } + if mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var DraftEdit = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-edit", Product: "mail", + Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一") + } + params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")} + if rt.Changed("to") { + params["toRecipients"] = mailRecipients(rt.StrSlice("to")) + } + if rt.Changed("cc") { + params["ccRecipients"] = mailRecipients(rt.StrSlice("cc")) + } + if rt.Changed("subject") { + params["subject"] = rt.Str("subject") + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/update_draft") + if err != nil { + return err + } + if mailFirstString(receipt, "id") != rt.Str("id") { + return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致") + } + verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-create", Product: "mail", + Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "from", Property: "from"}, {Name: "body", Property: "body"}, {Name: "draft", Property: "isDraft"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "from", Type: shortcut.FlagString, Desc: "模板发件邮箱"}, + {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"}, + {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"}, + {Name: "draft", Type: shortcut.FlagBool, Desc: "创建可编辑的草稿模板"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body"), "isDraft": rt.Bool("draft")} + if rt.Changed("from") { + params["from"] = rt.Str("from") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil { + return err + } + id := mailFirstString(written, "id") + if id == "" { + return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID") + } + verified, err := mailReadTemplate(rt, rt.Str("email"), id) + if err != nil { + return err + } + if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateUpdate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-update", Product: "mail", + Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --name、--subject、--body 之一") + } + params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")} + for _, flag := range []string{"name", "subject", "body"} { + if rt.Changed(flag) { + params[flag] = rt.Str(flag) + } + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil { + return err + } + verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致") + } + if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致") + } + if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +func init() { + shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate) +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 2d2c6ca8..139840f6 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -352,13 +352,21 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "drive\u0000+version-history": {}, "drive\u0000+version-revert": {}, "mail\u0000+contact-list": {}, + "mail\u0000+draft-create": {}, + "mail\u0000+draft-edit": {}, "mail\u0000+find-mail-user": {}, "mail\u0000+folder-list": {}, + "mail\u0000+message": {}, + "mail\u0000+messages": {}, "mail\u0000+recent-mail": {}, "mail\u0000+search-mail": {}, "mail\u0000+tag-list": {}, + "mail\u0000+template-create": {}, "mail\u0000+template-list": {}, + "mail\u0000+template-update": {}, + "mail\u0000+thread": {}, "mail\u0000+thread-list": {}, + "mail\u0000+triage": {}, "mail\u0000+unread-mail": {}, "mail\u0000+user-search": {}, "minutes\u0000+action-items": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index f08b297d..b3226a80 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -37,6 +37,9 @@ var todoSemanticCatalogJSON []byte //go:embed semantic_catalog_attendance.json var attendanceSemanticCatalogJSON []byte +//go:embed semantic_catalog_mail.json +var mailSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -64,6 +67,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( calendarSemanticCatalogJSON, todoSemanticCatalogJSON, attendanceSemanticCatalogJSON, + mailSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json new file mode 100644 index 00000000..93bff2ad --- /dev/null +++ b/internal/shortcut/semantic_catalog_mail.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "service": "mail", + "default_availability": "unavailable", + "shortcuts": { + "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+draft-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。","risk":"write","availability":"available","public":true,"reviewed":true}, + "+draft-edit": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。","risk":"write","availability":"available","public":true,"reviewed":true}, + "+template-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。","risk":"write","availability":"available","public":true,"reviewed":true}, + "+template-update": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。","risk":"write","availability":"available","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 98d2be5f..824ca738 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -19,7 +19,6 @@ import ( "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/shortcut" ) @@ -107,7 +106,10 @@ var FindMailUser = shortcut.Shortcut{ } // Step 2 — project matched users. - users := findMailUserUnwrap(data) + users, err := smartMailRows(data, "mail/search_mail_users", "users", "id", "email") + if err != nil { + return err + } results := make([]map[string]any, 0, len(users)) for _, u := range users { results = append(results, map[string]any{ @@ -121,45 +123,14 @@ var FindMailUser = shortcut.Shortcut{ }) } - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没搜到邮箱联系人") + complete, next, err := smartMailPage(data, "mail/search_mail_users", "") + if err != nil { + return err } - - return rt.Output(map[string]any{"users": results, "count": len(results)}) + return rt.Output(smartMailPayload("users", results, complete, next)) }, } -// findMailUserUnwrap extracts the user list from a search_mail_users response. -// The helper documents the list under "users"; we probe several container keys -// at the top level and one level deep, defensively. -func findMailUserUnwrap(data map[string]any) []map[string]any { - keys := []string{"users", "contacts", "result", "data", "list", "items", "records"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return findMailUserToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return findMailUserToMaps(arr) - } - } - } - } - return nil -} - -func findMailUserToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - func findMailUserString(m map[string]any, keys ...string) string { for _, key := range keys { if s, ok := m[key].(string); ok && s != "" { @@ -182,5 +153,6 @@ func findMailUserAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果") shortcut.Register(FindMailUser) } diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go index ba41a90c..fd18632e 100644 --- a/internal/shortcut/smart/mail_space_todo_projection_test.go +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -31,34 +31,6 @@ import ( var errStubTool = errors.New("stub tool error") -// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with -// an email field and bare string arrays, each at the top level or one level -// under a result/data wrapper. The nested bare-string case regressed silently -// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly -// demand an explicit --email. -func TestSearchMailPickMailbox(t *testing.T) { - cases := []struct { - name, raw, want string - }{ - {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"}, - {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"}, - {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"}, - {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"}, - {"empty", `{"result":{"emailAccounts":[]}}`, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var data map[string]any - if err := json.Unmarshal([]byte(tc.raw), &data); err != nil { - t.Fatal(err) - } - if got := searchMailPickMailbox(data); got != tc.want { - t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want) - } - }) - } -} - // TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces // container is probed — otherwise +resolve-space silently finds no candidates. func TestResolveSpaceItemsWikiSpaces(t *testing.T) { @@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error // "provide --email" validation error rather than silently proceeding. func TestSmartSearchMailNoMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[]}`, }} err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json") if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") { @@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) { // the no-mailbox validation error because emailAccounts was not probed. func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`, - "search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`, + "search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`, }} out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json") if strings.Contains(out, "未找到可用邮箱") || out == "" { diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go new file mode 100644 index 00000000..d8fb916e --- /dev/null +++ b/internal/shortcut/smart/mail_strict.go @@ -0,0 +1,236 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "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" +) + +func smartMailError(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 smartMailSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return smartMailError(operation, "missing_success", "响应缺少 success 业务状态") + } + return smartMailError(operation, "remote_failure", "服务未明确返回成功状态") +} + +func smartMailLookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + value, present := smartMailLookup(data, path) + if !present { + return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, path) + if err != nil { + return nil, err + } + for index, item := range items { + if searchMailFirstString(item, identityKeys...) == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire +// shape observed from search_emails: total="0", terminal nextCursor="$", and +// exactly one placeholder whose only fields are null recipient metadata. It is +// deliberately narrower than a general "drop bad item" rule. +func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, "messages") + if err != nil { + return nil, err + } + total, totalOK := smartMailInt(data["total"]) + next, nextOK := data["nextCursor"].(string) + if totalOK && total == 0 && nextOK && (next == "" || next == "$") { + if len(items) == 0 { + return items, nil + } + if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) { + return []map[string]any{}, nil + } + return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象") + } + if totalOK && total < 0 { + return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数") + } + for index, item := range items { + if searchMailFirstString(item, "id") == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +func smartMailEmptySearchSentinel(item map[string]any) bool { + if len(item) == 0 { + return false + } + allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true} + for key, value := range item { + if !allowed[key] || value != nil { + return false + } + } + return true +} + +func smartMailInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != float64(int(typed)) { + return 0, false + } + return int(typed), true + case string: + parsed, err := strconv.Atoi(typed) + return parsed, err == nil + default: + return 0, false + } +} + +func smartMailPage(data map[string]any, operation, prefix string) (bool, string, error) { + container := data + if prefix != "" { + value, present := smartMailLookup(data, prefix) + if !present { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误") + } + } + raw, present := container["nextCursor"] + next, ok := raw.(string) + if !present || !ok { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor") + } + if rawMore, present := container["hasMore"]; present { + hasMore, ok := smartMailBool(rawMore) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore == terminal { + return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾") + } + if !hasMore { + return true, "", nil + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + return false, next, nil +} + +func smartMailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func smartMailResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录","additionalProperties":true}},"complete":{"type":"boolean","description":"分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection)), + } +} + +func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) { + declaration.OutputRollout = output.RolloutUnifiedActive + declaration.Contract.Result = smartMailResult(collection, description) + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.", + } +} + +func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..1b2e3ce1 --- /dev/null +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + result bool + }{ + {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil}, + {"unread", UnreadMail.OutputRollout, UnreadMail.Contract.Result != nil}, + {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil}, + {"recent", RecentMail.OutputRollout, RecentMail.Contract.Result != nil}, + {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil}, + } { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("%s is not unified with Result", declaration.name) + } + } +} + +func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) { + fixture := map[string]any{ + "success": "true", "total": "0", "nextCursor": "$", + "messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}}, + } + items, err := smartMailSearchRows(fixture, "mail/search_emails") + if err != nil || len(items) != 0 { + t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err) + } + for name, bad := range map[string]map[string]any{ + "unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}}, + "non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}}, + "multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}}, + } { + if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) { + for name, fixture := range map[string]map[string]any{ + "missing success": {"messages": []any{}}, + "missing collection": {"success": "true", "total": "0", "nextCursor": "$"}, + "wrong type": {"success": "true", "messages": map[string]any{}}, + "bad item": {"success": "true", "messages": []any{"bad"}}, + } { + if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) { + if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil { + t.Fatal("malformed message from field must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil { + t.Fatal("non-object conversation sender must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil { + t.Fatal("sender without identity must fail") + } +} diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index c5b920ed..affeaf0f 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -14,6 +14,8 @@ package smart import ( + "fmt" + "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" @@ -136,23 +138,29 @@ var RecentMail = shortcut.Shortcut{ } // Step 4 — project each conversation to a compact record. - threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records") + threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id") + if err != nil { + return err + } results := make([]map[string]any, 0, len(threads)) for _, t := range threads { + from, err := recentMailSenders(t) + if err != nil { + return err + } results = append(results, map[string]any{ "subject": searchMailFirstString(t, "subject", "title", "topic"), - "from": recentMailSenders(t), + "from": from, "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), }) } - // Empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("最近没有邮件") + complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result") + if err != nil { + return err } - - return rt.Output(map[string]any{"count": len(results), "mails": results}) + return rt.Output(smartMailPayload("mails", results, complete, next)) }, } @@ -166,7 +174,10 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e if err != nil { return "", err } - folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records") + folders, err := smartMailRows(data, "mail/list_folders", "folders", "id") + if err != nil { + return "", err + } inboxNames := map[string]bool{ "收件箱": true, "inbox": true, } @@ -184,16 +195,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e // recentMailSenders reads a conversation's sender list. The helper documents a // "senders" array of {email, name}; we tolerate a plain string, a single object, // and fall back to the shared searchMailFrom logic. -func recentMailSenders(t map[string]any) any { - if arr, ok := t["senders"].([]any); ok && len(arr) > 0 { +func recentMailSenders(t map[string]any) (any, error) { + raw, present := t["senders"] + if arr, ok := raw.([]any); ok && len(arr) > 0 { names := make([]string, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) - if !ok { - if s, ok := it.(string); ok && s != "" { - names = append(names, s) - } - continue + if !ok || len(m) == 0 { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index)) } name := searchMailFirstString(m, "name", "displayName") addr := searchMailFirstString(m, "email", "emailAddress", "address") @@ -204,15 +213,22 @@ func recentMailSenders(t map[string]any) any { names = append(names, addr) case name != "": names = append(names, name) + default: + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index)) } } if len(names) > 0 { - return names + return names, nil + } + } else if present { + if _, ok := raw.([]any); !ok { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组") } } return searchMailFrom(t) } func init() { + hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index a6d824e2..f2fe84ec 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -14,6 +14,8 @@ package smart import ( + "fmt" + "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" @@ -82,6 +84,7 @@ var SearchMail = shortcut.Shortcut{ {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, @@ -109,27 +112,42 @@ var SearchMail = shortcut.Shortcut{ if size == "" { size = "20" } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": rt.Str("query"), "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record. - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "") + if err != nil { + return err + } + return rt.Output(smartMailPayload("messages", out, complete, next)) }, } @@ -142,109 +160,48 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - if addr := searchMailPickMailbox(data); addr != "" { - return addr, nil + mailboxes, err := smartMailRows(data, "mail/list_user_mailboxes", "emailAccounts", "email") + if err != nil { + return "", err + } + if len(mailboxes) > 0 { + return searchMailFirstString(mailboxes[0], "email"), nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } -// searchMailPickMailbox resolves the first usable mailbox address from a -// list_user_mailboxes response. It tolerates every observed shape: objects with -// an email field (result.emailAccounts[].email) and bare string arrays -// (result.emailAccounts[]), each at the top level or one level under a -// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string -// case is scanned separately — otherwise all three smart mail shortcuts wrongly -// report that no mailbox exists. -func searchMailPickMailbox(data map[string]any) string { - containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"} - for _, m := range searchMailUnwrapList(data, containers...) { - if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { - return addr - } - } - scanStrings := func(m map[string]any) string { - for _, key := range containers { - if arr, ok := m[key].([]any); ok { - for _, it := range arr { - if s, ok := it.(string); ok && s != "" { - return s - } - } - } - } - return "" - } - if s := scanStrings(data); s != "" { - return s - } - for _, wrap := range []string{"result", "data"} { - if inner, ok := data[wrap].(map[string]any); ok { - if s := scanStrings(inner); s != "" { - return s - } - } - } - return "" -} - -// searchMailMessages extracts the message list from a search_emails response. -// The helper documents the list under "messages". -func searchMailMessages(data map[string]any) []map[string]any { - return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records") -} - -// searchMailUnwrapList probes the given container keys at the top level, and one -// level deep, returning the first list found as []map[string]any. -func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any { - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMailToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return searchMailToMaps(arr) - } - } - } - } - return nil -} - -func searchMailToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). -func searchMailFrom(m map[string]any) any { - switch v := m["from"].(type) { +func searchMailFrom(m map[string]any) (any, error) { + raw, present := m["from"] + switch v := raw.(type) { case string: if v != "" { - return v + return v, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") addr := searchMailFirstString(v, "email", "emailAddress", "address") switch { case name != "" && addr != "": - return name + " <" + addr + ">" + return name + " <" + addr + ">", nil case addr != "": - return addr + return addr, nil case name != "": - return name + return name, nil } + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱") + case nil: + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null") + } + default: + return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw)) } if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { - return s + return s, nil } - return "" + return "", nil } func searchMailFirstString(m map[string]any, keys ...string) string { @@ -269,5 +226,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要") shortcut.Register(SearchMail) } diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go new file mode 100644 index 00000000..bd2ca713 --- /dev/null +++ b/internal/shortcut/smart/triage_mail.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// TriageMail aligns the Lark task-level triage entry while preserving DWS's +// easier mailbox and inbox resolution. It returns summaries only; callers use +// +message or +messages for full bodies. +var TriageMail = shortcut.Shortcut{ + Service: "mail", Command: "+triage", Product: "mail", + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage", + CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage", + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."}, + Selection: contract.SelectionSpec{ + AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"}, + AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"}, + Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`}, + }, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}}, + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页摘要数量"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + query := rt.Str("query") + if query == "" { + folderID, err := recentMailInboxFolder(rt, email) + if err != nil { + return err + } + query = "folderId:" + folderID + } + args := map[string]any{"email": email, "query": query, "size": rt.Str("limit")} + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) + if err != nil { + return err + } + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + from, err := searchMailFrom(message) + if err != nil { + return err + } + rows = append(rows, map[string]any{ + "messageId": searchMailFirstString(message, "id"), + "subject": searchMailFirstString(message, "subject"), + "from": from, + "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"), + }) + } + complete, next, err := smartMailPage(data, "mail/search_emails", "") + if err != nil { + return err + } + return rt.Output(smartMailPayload("messages", rows, complete, next)) + }, +} + +func init() { + hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据") + shortcut.Register(TriageMail) +} diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index e72a91fa..05d2d7db 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -81,6 +81,7 @@ var UnreadMail = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, Tips: []string{ `dws mail +unread-mail`, @@ -105,30 +106,46 @@ var UnreadMail = shortcut.Shortcut{ if size == "" { size = "20" } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": "isRead:false", "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record (shared helpers). - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "") + if err != nil { + return err + } + return rt.Output(smartMailPayload("messages", out, complete, next)) }, } func init() { + hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") shortcut.Register(UnreadMail) } diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index e0542f45..5c1e6546 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -28,6 +28,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json", ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ] From 3a2b738c067935056b221cd21c2fe82a840c331b Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 20:06:29 +0800 Subject: [PATCH 149/267] fix(shortcut): close attendance and mail release gates --- .../attendance.md | 45 +- docs/shortcut-downstream-requirements/mail.md | 53 +- docs/shortcut-public-catalog.json | 180 +--- internal/app/schema_shortcut_contract_test.go | 4 +- internal/shortcut/attendance/attendance.go | 424 +++++++- ...ce_release_cross_platform_coverage_test.go | 992 ++++++++++++++++++ ...nce_strict_cross_platform_coverage_test.go | 105 +- internal/shortcut/attendance/common.go | 411 +++++++- .../attendance_semantic_catalog_test.go | 4 +- .../builtin/mail_semantic_catalog_test.go | 17 +- internal/shortcut/mail/common.go | 224 +++- internal/shortcut/mail/mail.go | 91 +- ...anged_line_cross_platform_coverage_test.go | 363 +++++++ ...ail_strict_cross_platform_coverage_test.go | 238 ++++- ...e_contract_cross_platform_coverage_test.go | 394 +++++++ internal/shortcut/mail/reads.go | 38 +- internal/shortcut/mail/writes.go | 124 ++- internal/shortcut/public_catalog_generated.go | 16 - .../responsecheck/responsecheck_test.go | 77 ++ .../shortcut/semantic_catalog_attendance.json | 28 +- internal/shortcut/semantic_catalog_mail.json | 20 +- internal/shortcut/smart/attendance_strict.go | 31 + ...nce_strict_cross_platform_coverage_test.go | 236 ++++- internal/shortcut/smart/find_mail_user.go | 124 ++- ...anged_line_cross_platform_coverage_test.go | 316 ++++++ internal/shortcut/smart/mail_strict.go | 96 +- ...ail_strict_cross_platform_coverage_test.go | 279 ++++- internal/shortcut/smart/my_attendance.go | 84 +- internal/shortcut/smart/recent_mail.go | 38 +- internal/shortcut/smart/search_mail.go | 37 +- .../shortcut/smart/this_month_attendance.go | 9 +- internal/shortcut/smart/triage_mail.go | 14 +- internal/shortcut/smart/unread_mail.go | 19 +- 33 files changed, 4618 insertions(+), 513 deletions(-) create mode 100644 internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go create mode 100644 internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go create mode 100644 internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go create mode 100644 internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 2ff7bf2d..b8a7ebc7 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -7,9 +7,9 @@ ## 1. 执行摘要 -- Attendance 共审核 35 个源码 Shortcut;17 个具备公开条件,18 个保持 hidden/unavailable。 +- Attendance 共审核 35 个源码 Shortcut;11 个具备公开条件,24 个保持 hidden/unavailable。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 - `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 -- 已确认 4 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少确定终止证据、缺少安全可回收的管理员/写操作 fixture。 +- 已确认 5 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture,以及 6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture。 - 班次详情曾因未公开而缺失,现已通过“搜索非空结果 → 同一稳定 ID 详情”闭环恢复;严格响应校验、统一 Result 和空结果判定均属于上游修复,不要求下游改动。 | ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | @@ -18,12 +18,13 @@ | `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | | `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | | `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | +| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | ## 2. 用户任务与能力缺口总览 | 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | |---|---|---|---|---|---| -| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;分页终止为保守推断 | contract insufficient | 返回 `complete`/`nextOffset`,不声明框架 cursor 分页 | +| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | | 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | covered | 无 | 公开,严格身份闭环 | | 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | | 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均保持 hidden/unavailable | @@ -103,7 +104,7 @@ #### A. 用户任务与现状 - `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 -- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `complete=false` 和建议 nextOffset,不能声明全量完成。 +- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 - 安全证据句柄:`ATT-CHECK-PAGE-01`。 #### B. 需要下游提供的合同 @@ -116,7 +117,7 @@ #### C. 验收标准与临时处置 - 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 -- 下游完成前,DWS 保留数据层 `complete/nextOffset`,不伪造 cursor PaginationSpec,也不把满页当完整结果。 +- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 ### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 @@ -146,6 +147,31 @@ 在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 +### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture + +#### A. 用户任务与现状 + +- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 +- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 +- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 +- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 +- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 + +#### B. 需要下游提供的合同与 fixture + +- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 +- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 +- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 +- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 + +#### C. 验收标准与临时处置 + +1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 +2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 +3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 + +在上述证据完整前,6 个 Shortcut 均保持 hidden/unavailable;已实现的严格校验不等于已获得发布证据。 + ## 4. Lark 对齐与平台差异 | Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | @@ -166,10 +192,11 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 所有公开 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法 | 单元负向矩阵;真实非空与零命中 E2E | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求稳定 ID、公开分页完成证据 | 非空搜索、零命中、详情闭环和坏 item 回归 | -| `+get-class` | 能力存在但未公开、无 Result/严格读回 | 补 Contract/Safety/Result,搜索 ID 精确读回并恢复公开 | exact live E2E | -| `+my-attendance`, `+this-month` | 直接透传 raw data,缺严格空结果边界 | 统一结果 envelope 并复用严格打卡流水校验 | exact live E2E 与 contract gate | +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 已完成单元负向矩阵;最终 clean-HEAD 真实 nonempty/zero 双层矩阵待发布前集中执行,本文不提前泛化声称全通过 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 已有坏 item/空 ID/重复 ID/分页矛盾单元回归;最终搜索 nonempty/guaranteed-zero 与 raw 对照待 clean-HEAD 集中执行 | +| `+get-class`, `+get-overtime-rule` | 能力存在但无请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `shiftVO.id` / `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;最终同 ID live 读回待 clean-HEAD 集中执行 | +| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型 | 5 个可用 scene 的 exact/raw 同场景双层对照;boss scene 当前身份双层均显式业务失败,不冒充成功 fixture | +| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | ## 7. 安全与脱敏声明 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index e7d76783..699fcd75 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -1,16 +1,16 @@ # Mail Shortcut 下游业务能力需求规格 -> 日期:2026-08-18 -> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` -> 对比基线:lark-cli 1.0.87 -> 范围:Shortcut only;不修改 `skills/multi` 或 `skills/mono` +> 日期:2026-08-18 +> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut only;不修改 `skills/multi` 或 `skills/mono` > 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 ## 1. 执行摘要 -本轮把 Mail 从 10 个列表/搜索入口扩展并收紧为 18 个公开 Shortcut:新增 `+triage`、`+message`、`+messages`、`+thread`、`+draft-create`、`+draft-edit`、`+template-create`、`+template-update`;现有列表和 smart 入口全部改为严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result。真实 E2E 已覆盖已知非空、保证零命中、单封/多封/会话身份绑定、草稿与模板写后读回、联系人和模板临时 fixture 清理。 +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。静态发布候选为 8 个只读入口,仍须在最终 clean HEAD 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 -仍不能诚实对齐的任务集中在发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口补足可验证合同。 +仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 | ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | |---|---|---|---|---|---|---| @@ -23,15 +23,17 @@ | `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | | `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | | `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | +| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | ## 2. 用户任务与能力缺口总览 | 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | |---|---|---|---|---|---| -| 浏览/筛选摘要 | `+triage`、`+search-mail`、`+unread-mail`、`+recent-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | | 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | -| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | covered | 无 | 公开,确认 + 写后读回 | -| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | partial | 附件与 HTML 资源事务缺失 | 公开核心字段;不承诺附件对齐 | +| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | +| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | | 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | | 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | | 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | @@ -40,7 +42,7 @@ | 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | | HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | | 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | -| 文件夹/标签/联系人/企业邮箱用户 | 现有严格列表与搜索入口 | 无同名任务入口 | DWS extra | 非对齐项 | 作为 DWS 原生增量保留 | +| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | ## 3. 下游需求明细 @@ -111,7 +113,7 @@ ### `DS-Mail-008` — 统一成功、空结果与分页协议 - 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 -- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。 +- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 - 所需接口合同: - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 @@ -126,13 +128,21 @@ - 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 - 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 +### `DS-Mail-010` — 草稿/模板可证明的清理终态 + +- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 +- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 +- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 +- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 +- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 + ## 4. Lark 对齐与平台差异 | Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | |---|---|---|---| | `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | -| `+draft-create` / `+draft-edit` | yes(核心正文) | 附件编辑仍依赖下游事务 | 已公开,附件不宣称完整对齐 | -| `+template-create` / `+template-update` | partial | 核心字段已读回;附件/内联资源/并发更新不足 | 已公开核心能力,差异写入合同 | +| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | +| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | | `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | | `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | | `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | @@ -156,16 +166,19 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID | 单测矩阵 + live 非空/空结果 | -| `+search-mail` / `+unread-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | exact live 零命中 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | exact live 非空和随机零命中 | -| `+contact-list` | 字符串 `hasMore` 会造成漂移或误判 | 只接受已观测布尔/精确字符串 true/false | 临时联系人 create→list→delete | -| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | exact live 同 ID | -| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对 | exact live create/update/readback/cleanup | +| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | fresh query 可构造 known-nonempty + guaranteed-zero;clean HEAD 重跑后收口 | +| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | clean HEAD 待执行 exact/raw 非空与随机零命中双验 | +| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | clean HEAD 待执行同 ID exact/raw 双验 | +| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | ## 7. 安全与脱敏声明 - 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 - 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 - 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 -- 临时草稿已移出草稿箱,临时模板和联系人已删除并复查;最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 +- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 +- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 +- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 2803af01..8e2f9c93 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T13:37:43.285113", - "count": 441, + "generated_at": "2026-08-18T19:08:40.354559", + "count": 425, "results": [ { "suite": "semantic", @@ -939,7 +939,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,缺字段、错型或坏记录均失败。", + "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。", "availability": "available" }, { @@ -949,7 +949,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "对齐 Lark attendance user_tasks query 的打卡结果任务;严格验证 success 与显式 result 数组,并公开 offset/limit 的完整性和 nextOffset 证据。", + "semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。", "availability": "available" }, { @@ -962,16 +962,6 @@ "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。", "availability": "available" }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-checkin-record", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格读取 result.list 签到数组,区分外勤签到与考勤打卡流水。", - "availability": "available" - }, { "suite": "semantic", "service": "attendance", @@ -979,17 +969,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "schema_leaf", - "semantic_delta": "使用搜索得到的班次 ID 读取详情,要求显式成功和非空 result;已用同一真实班次完成搜索到详情闭环。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-leave-records", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "按用户、假期编码和日期读取余额变更记录;显式空数组合法,缺失结果或坏记录失败。", + "semantic_delta": "使用搜索得到的班次 ID 读取详情,要求显式成功、非空 result,且 shiftVO.id 与请求 classId 精确一致。", "availability": "available" }, { @@ -999,7 +979,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "schema_leaf", - "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result;真实搜索到详情闭环已通过。", + "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。", "availability": "available" }, { @@ -1009,7 +989,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "规范日期范围并严格验证排班数组;缺失或畸形响应不再成为空排班。", + "semantic_delta": "规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。", "availability": "available" }, { @@ -1019,17 +999,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "schema_leaf", - "semantic_delta": "按用户和设置场景读取个人考勤开关,要求显式业务成功和非空 result。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-summary", - "risk": "read", - "status": "reviewed_available", - "disposition": "schema_leaf", - "semantic_delta": "按周或月读取个人考勤摘要,要求显式业务成功和非空 result。", + "semantic_delta": "按归一化 userId 和场景读取个人考勤设置;响应用户必须精确匹配,场景字段必须非空并符合已观测 object/boolean/integer 类型。", "availability": "available" }, { @@ -1039,27 +1009,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "把审批类型语义映射为业务枚举并严格验证审批数组;真实非空与空结果均已执行。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "attendance", - "command": "+list-leave-types", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格验证显式假期类型数组,为假期编码的后续读取提供可验证入口。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "attendance", - "command": "+my-attendance", - "risk": "read", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "自动解析当前用户并查询今天的打卡流水,最终仍按严格 success/result 数组契约输出。", + "semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。", "availability": "available" }, { @@ -1079,7 +1029,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "严格读取 result.items、展开 shiftVO、要求稳定班次 ID,并公开 page/totalCount 完整性证据。", + "semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。", "availability": "available" }, { @@ -1092,16 +1042,6 @@ "semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", "availability": "available" }, - { - "suite": "semantic", - "service": "attendance", - "command": "+this-month", - "risk": "read", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "自动解析当前用户并查询本月打卡流水,最终仍按严格 success/result 数组契约输出。", - "availability": "available" - }, { "suite": "semantic", "service": "calendar", @@ -3341,36 +3281,6 @@ "semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。", "availability": "available" }, - { - "suite": "semantic", - "service": "mail", - "command": "+contact-list", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "mail", - "command": "+draft-create", - "risk": "write", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "mail", - "command": "+draft-edit", - "risk": "write", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。", - "availability": "available" - }, { "suite": "semantic", "service": "mail", @@ -3411,16 +3321,6 @@ "semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。", "availability": "available" }, - { - "suite": "semantic", - "service": "mail", - "command": "+recent-mail", - "risk": "read", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。", - "availability": "available" - }, { "suite": "semantic", "service": "mail", @@ -3431,46 +3331,6 @@ "semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。", "availability": "available" }, - { - "suite": "semantic", - "service": "mail", - "command": "+tag-list", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "mail", - "command": "+template-create", - "risk": "write", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "mail", - "command": "+template-list", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "mail", - "command": "+template-update", - "risk": "write", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。", - "availability": "available" - }, { "suite": "semantic", "service": "mail", @@ -3481,16 +3341,6 @@ "semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。", "availability": "available" }, - { - "suite": "semantic", - "service": "mail", - "command": "+thread-list", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。", - "availability": "available" - }, { "suite": "semantic", "service": "mail", @@ -3501,16 +3351,6 @@ "semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。", "availability": "available" }, - { - "suite": "semantic", - "service": "mail", - "command": "+unread-mail", - "risk": "read", - "status": "reviewed_available", - "disposition": "primary_smart", - "semantic_delta": "自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。", - "availability": "available" - }, { "suite": "semantic", "service": "mail", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index c92a37a7..95728137 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 440 + publicShortcutCount = 424 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. schemaPublishedShortcutCount = 446 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 440 + publiclyDeliveredShortcutCount = 424 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index b0018c7d..bd0659ce 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -26,6 +26,7 @@ package attendance import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -198,11 +199,33 @@ var CheckResult = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, - {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,最多 100 个,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "--offset 不能小于 0,默认 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "--limit 必须大于 0 且不超过 1000,默认 100"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复,最多 100 个"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 1000"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"offset"}, Description: "--offset 不能小于 0"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 100); err != nil { + return err + } + if err := attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")); err != nil { + return err + } + if limit := rt.Int("limit"); limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if rt.Int("offset") < 0 { + return fmt.Errorf("--offset 不能小于 0") + } + return nil }, Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -238,12 +261,23 @@ var CheckResult = shortcut.Shortcut{ if err != nil { return err } + if err := attendanceValidatePositiveIntegerIDs(records, serverWukong+"/query_check_result", "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dayMillis(rt.Str("end")) + if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { + return err + } complete := len(records) < limit extra := map[string]any{"limit": limit} + nextToken := "" if !complete { - extra["nextOffset"] = offset + len(records) + nextOffset := offset + len(records) + extra["nextOffset"] = nextOffset + nextToken = strconv.Itoa(nextOffset) } - return rt.Output(attendanceCollectionPayload("records", records, complete, extra)) + return attendanceOutputCollection(rt, "records", records, complete, extra, true, nextToken) }, } @@ -281,9 +315,20 @@ var CheckRecord = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + return attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")) }, Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -301,7 +346,14 @@ var CheckRecord = shortcut.Shortcut{ "checkDateFrom": from, "checkDateTo": to, }, - }, "records", true, nil, "result") + }, "records", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_check_record", "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dayMillis(rt.Str("end")) + return attendanceValidateUserAndTimeBinding(items, serverWukong+"/query_check_record", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis) + }, "result") }, } @@ -341,10 +393,47 @@ var ListApprove = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "--types 不能为空,映射后的审批类型不能重复;overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, Description: "--types 不能为空,映射后的审批类型不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + seen := map[int]struct{}{} + for _, value := range rt.StrSlice("types") { + mapped, ok := approveTypeMapping[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return fmt.Errorf("无效的审批类型: %s", value) + } + if _, duplicate := seen[mapped]; duplicate { + return fmt.Errorf("审批类型不能重复") + } + seen[mapped] = struct{}{} + } + if len(seen) == 0 { + return fmt.Errorf("--types 不能为空") + } + return nil }, Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -375,7 +464,18 @@ var ListApprove = shortcut.Shortcut{ "fromDate": from, "toDate": to, }, - }, "approvals", true, nil, "result") + }, "approvals", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_user_approve", "id"); err != nil { + return err + } + requestedTypes := make(map[int]struct{}, len(bizTypes)) + for _, value := range bizTypes { + requestedTypes[value] = struct{}{} + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dayMillis(rt.Str("end")) + return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) + }, "result") }, } @@ -429,7 +529,9 @@ var GetApproveTemplate = shortcut.Shortcut{ } return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, - }, "templates", true, nil, "result") + }, "templates", true, nil, func(items []map[string]any) error { + return attendanceValidateExpectedStrings(items, serverWukong+"/query_at_approve_template", "approveType", approveType) + }, "result") }, } @@ -472,9 +574,31 @@ var GetSchedule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dateToMillis(rt.Str("start"), false) + if err != nil { + return err + } + end, err := dateToMillis(rt.Str("end"), true) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil }, Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -492,7 +616,12 @@ var GetSchedule = shortcut.Shortcut{ "workDateBegin": begin, "workDateEnd": end, }, - }, "schedules", true, nil, "result") + }, "schedules", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/getScheduleByRange", "id"); err != nil { + return err + } + return attendanceValidateUserAndTimeBinding(items, serverWukong+"/getScheduleByRange", rt.StrSlice("users"), "userId", "workDate", begin, end) + }, "result") }, } @@ -582,10 +711,15 @@ var SearchClass = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"}, {Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, Execute: func(rt *shortcut.RuntimeContext) error { page, limit, err := attendancePageInput(rt) if err != nil { @@ -614,11 +748,15 @@ var SearchClass = shortcut.Shortcut{ if err != nil { return err } - complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit) + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit, len(classes)) if err != nil { return err } - return rt.Output(attendanceCollectionPayload("classes", classes, complete, extra)) + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "classes", classes, complete, extra, true, nextToken) }, } @@ -647,10 +785,11 @@ func searchClassProject(data map[string]any) ([]map[string]any, error) { } row := map[string]any{} v, ok := attendanceFirst(m, "id", "classId", "class_id") - if !ok || v == nil { + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index)) } - row["classId"] = v + row["classId"] = identity if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { row["name"] = v } @@ -659,6 +798,9 @@ func searchClassProject(data map[string]any) ([]map[string]any, error) { } out = append(out, row) } + if err := attendanceValidatePositiveIntegerIDs(out, serverWukong+"/get_class_list", "classId"); err != nil { + return nil, err + } return out, nil } @@ -706,13 +848,32 @@ var GetClass = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + {Name: "class-id", Type: shortcut.FlagInt, Desc: "--class-id 必须大于 0,表示班次 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"class-id"}, Description: "--class-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("class-id") <= 0 { + return fmt.Errorf("--class-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-class --class-id 1170996821`}, Execute: func(rt *shortcut.RuntimeContext) error { - return attendanceCallObject(rt, serverWukong, "get_class_detail", map[string]any{ - "classId": int64(rt.Int("class-id")), - }) + requested := int64(rt.Int("class-id")) + data, err := rt.CallMCPData(serverWukong, "get_class_detail", map[string]any{"classId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_class_detail") + if err != nil { + return err + } + identityValue, ok := attendanceNestedValue(value, "shiftVO", "id") + identity, valid := attendancePositiveInteger(identityValue) + if !ok || !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_class_detail", "object_identity_mismatch", "响应 shiftVO.id 与请求 classId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -873,10 +1034,15 @@ var SearchAdjustmentRule = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { @@ -898,11 +1064,15 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if err != nil { return err } - complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit) + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit, len(rules)) if err != nil { return err } - return rt.Output(attendanceCollectionPayload("rules", rules, complete, extra)) + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } @@ -932,15 +1102,19 @@ func searchRuleProject(data map[string]any, operation string, paths ...string) ( } row := map[string]any{} v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId") - if !ok || v == nil { + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index)) } - row["ruleId"] = v + row["ruleId"] = identity if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { row["name"] = v } out = append(out, row) } + if err := attendanceValidatePositiveIntegerIDs(out, operation, "ruleId"); err != nil { + return nil, err + } return out, nil } @@ -980,13 +1154,31 @@ var GetOvertimeRule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true}, + {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "--overtime-id 必须大于 0,表示加班规则主键 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"overtime-id"}, Description: "--overtime-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("overtime-id") <= 0 { + return fmt.Errorf("--overtime-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return attendanceCallObject(rt, serverWukong, "get_overtime_rule_detail", map[string]any{ - "overtimeId": int64(rt.Int("overtime-id")), - }) + requested := int64(rt.Int("overtime-id")) + data, err := rt.CallMCPData(serverWukong, "get_overtime_rule_detail", map[string]any{"overtimeId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_overtime_rule_detail") + if err != nil { + return err + } + identity, valid := attendancePositiveInteger(value["id"]) + if !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_overtime_rule_detail", "object_identity_mismatch", "响应 id 与请求 overtimeId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -1025,10 +1217,15 @@ var SearchOvertimeRule = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { @@ -1050,11 +1247,15 @@ var SearchOvertimeRule = shortcut.Shortcut{ if err != nil { return err } - complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit) + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit, len(rules)) if err != nil { return err } - return rt.Output(attendanceCollectionPayload("rules", rules, complete, extra)) + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } @@ -1138,7 +1339,7 @@ var SearchGroup = shortcut.Shortcut{ if err != nil { return err } - complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit) + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit, len(groups)) if err != nil { return err } @@ -1469,19 +1670,80 @@ var GetSelfSetting = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, - {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "--user 不能为空,表示查询用户 userId", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + return nil }, Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return attendanceCallObject(rt, serverWukong, "query_self_setting", map[string]any{ + userID := rt.Str("user") + scene := rt.Str("setting-scene") + data, err := rt.CallMCPData(serverWukong, "query_self_setting", map[string]any{ "RuleMcpQuerySelfSettingRequest": map[string]any{ - "settingScene": rt.Str("setting-scene"), - "userId": rt.Str("user"), + "settingScene": scene, + "userId": userID, }, }) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/query_self_setting") + if err != nil { + return err + } + if err := attendanceValidateSelfSetting(value, userID, scene); err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) }, } +func attendanceValidateSelfSetting(value map[string]any, requestedUser, scene string) error { + operation := serverWukong + "/query_self_setting" + requestedUser = strings.TrimSpace(requestedUser) + if requestedUser == "" { + return responsecheck.Error(operation, "object_identity_mismatch", "请求 userId 不能为空") + } + returnedUser, ok := value["userId"].(string) + if !ok || returnedUser == "" || returnedUser != requestedUser { + return responsecheck.Error(operation, "object_identity_mismatch", "响应 userId 与请求用户不一致") + } + type sceneContract struct { + field string + valid func(any) bool + } + contracts := map[string]sceneContract{ + "checkRemind": {field: "checkRemindSetting", valid: func(v any) bool { _, ok := v.(map[string]any); return ok }}, + "fastCheck": {field: "fastCheckLateNeedConfirm", valid: func(v any) bool { _, ok := v.(bool); return ok }}, + "checkResultNotify": {field: "checkResultMsg", valid: attendanceSettingInteger}, + "lackRemind": {field: "lackRemindUser", valid: attendanceSettingInteger}, + "personalAttendStatNotify": {field: "personDailyReportSwitch", valid: attendanceSettingInteger}, + "bossAttendStatNotify": {field: "bossMonthReportType", valid: attendanceSettingInteger}, + } + expected, ok := contracts[scene] + if !ok { + return fmt.Errorf("无效的 --setting-scene: %s", scene) + } + setting, present := value[expected.field] + if !present || setting == nil { + return responsecheck.Error(operation, "setting_scene_mismatch", "响应缺少请求场景对应的非空设置字段") + } + if !expected.valid(setting) { + return responsecheck.Error(operation, "malformed_setting", "响应中请求场景的设置字段类型不正确") + } + return nil +} + +func attendanceSettingInteger(value any) bool { + number, ok := value.(float64) + return ok && !math.IsNaN(number) && !math.IsInf(number, 0) && math.Trunc(number) == number +} + // ── global setting ────────────────────────────────────────── // GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。 @@ -1668,7 +1930,9 @@ var ListLeaveTypes = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{ "McpLeaveTypeRequest": map[string]any{}, - }, "leaveTypes", true, nil, "result") + }, "leaveTypes", true, nil, func(items []map[string]any) error { + return attendanceValidateExpectedStrings(items, serverWukong+"/get_leave_types", "leaveCode", "") + }, "result") }, } @@ -1737,6 +2001,27 @@ var GetLeaveRecords = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 去空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 YYYY-MM-DD 且 end 不早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil + }, Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`}, Execute: func(rt *shortcut.RuntimeContext) error { start, err := dayMillis(rt.Str("start")) @@ -1757,7 +2042,7 @@ var GetLeaveRecords = shortcut.Shortcut{ } return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{ "McpLeaveRecordRequest": req, - }, "records", true, nil, "result") + }, "records", true, nil, nil, "result") }, } @@ -1921,6 +2206,33 @@ var GetCheckinRecord = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "--staff-ids 去空白后必须为 1..100 个且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 yyyy-MM-dd HH:mm:ss,end 不早于 start,且跨度不超过 7 天"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("staff-ids"), 100); err != nil { + return err + } + if rt.Str("operator-corp-id") == "" || rt.Str("operator-staff-id") == "" { + return fmt.Errorf("--operator-corp-id 与 --operator-staff-id 不能为空") + } + start, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("start"), time.Local) + if err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("end"), time.Local) + if err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.Sub(start) > 7*24*time.Hour { + return fmt.Errorf("--start 到 --end 的跨度不能超过 7 天") + } + return nil + }, Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`}, Execute: func(rt *shortcut.RuntimeContext) error { startStr := rt.Str("start") @@ -1944,7 +2256,7 @@ var GetCheckinRecord = shortcut.Shortcut{ "startTime": startStr, "endTime": endStr, }, - }, "records", true, nil, "result.list") + }, "records", true, nil, nil, "result.list") }, } diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go new file mode 100644 index 00000000..3c9f1f6a --- /dev/null +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -0,0 +1,992 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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" +) + +func attendanceRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +type attendanceCoverageCaller struct { + text string + err error + calls int + tools []string + arguments []map[string]any +} + +func (caller *attendanceCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.tools = append(caller.tools, tool) + caller.arguments = append(caller.arguments, arguments) + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.text}}}, nil +} + +func (*attendanceCoverageCaller) Format() string { return "json" } +func (*attendanceCoverageCaller) DryRun() bool { return false } +func (*attendanceCoverageCaller) Fields() string { return "" } +func (*attendanceCoverageCaller) JQ() string { return "" } + +func executeAttendanceResponse(t *testing.T, declaration shortcut.Shortcut, values map[string]string, response string) (*attendanceCoverageCaller, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: response} + helpers.InitDepsForTest(t, caller) + err := declaration.Execute(attendanceRuntimeForTest(t, declaration, values)) + return caller, err +} + +func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { + userCases := []struct { + name string + items []map[string]any + valid bool + }{ + {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, + {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, + {"missing time", []map[string]any{{"userId": "u1"}}, false}, + {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, + {"before range", []map[string]any{{"userId": "u1", "workDate": float64(899)}}, false}, + {"after range", []map[string]any{{"userId": "u1", "workDate": float64(1101)}}, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("user/time %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + + approvalCases := []struct { + name string + item map[string]any + valid bool + }{ + {"valid", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, true}, + {"missing user", map[string]any{"bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong user", map[string]any{"userId": "u2", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"missing type", map[string]any{"userId": "u1", "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, + {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, + } + for _, tc := range approvalCases { + err := attendanceValidateApprovalBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, map[int]struct{}{3: {}}, 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("approval %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}}, "attendance/test", "approveType", "OVERTIME"); err == nil { + t.Fatal("expected string mismatch was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) { + detailCases := []struct { + name string + declaration shortcut.Shortcut + flags map[string]string + valid string + invalid []string + }{ + { + name: "class", + declaration: GetClass, + flags: map[string]string{"class-id": "7"}, + valid: `{"success":true,"result":{"shiftVO":{"id":7}}}`, + invalid: []string{ + `{"result":{"shiftVO":{"id":7}}}`, + `{"success":false,"result":{"shiftVO":{"id":7}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"shiftVO":{"id":8}}}`, + }, + }, + { + name: "overtime", + declaration: GetOvertimeRule, + flags: map[string]string{"overtime-id": "9"}, + valid: `{"success":true,"result":[{"id":9}]}`, + invalid: []string{ + `{"result":[{"id":9}]}`, + `{"success":false,"result":[{"id":9}]}`, + `{"success":true,"result":null}`, + `{"success":true,"result":["bad"]}`, + `{"success":true,"result":[{"id":10}]}`, + }, + }, + } + for _, detail := range detailCases { + t.Run(detail.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, detail.valid) + if err != nil || caller.calls != 1 { + t.Fatalf("valid detail err=%v calls=%d", err, caller.calls) + } + }) + for index, response := range detail.invalid { + t.Run(fmt.Sprintf("%s invalid %d", detail.name, index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid detail err=%v calls=%d", err, caller.calls) + } + }) + } + } +} + +func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) { + invalid := []string{ + `{"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":false,"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"userId":"other","checkRemindSetting":{}}}`, + `{"success":true,"result":{"userId":"user"}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":null}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":true}}`, + } + for index, response := range invalid { + t.Run(fmt.Sprintf("invalid %d", index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": "user", "setting-scene": "checkRemind"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid self setting err=%v calls=%d", err, caller.calls) + } + }) + } + t.Run("normalizes user once", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": " user ", "setting-scene": "checkRemind"}, `{"success":true,"result":{"userId":"user","checkRemindSetting":{}}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("normalized self setting err=%v calls=%d", err, caller.calls) + } + request, ok := caller.arguments[0]["RuleMcpQuerySelfSettingRequest"].(map[string]any) + if !ok || request["userId"] != "user" { + t.Fatalf("normalized request = %#v", caller.arguments[0]) + } + }) + + validTypes := []struct { + scene string + field string + value any + }{ + {"checkRemind", "checkRemindSetting", map[string]any{}}, + {"fastCheck", "fastCheckLateNeedConfirm", false}, + {"checkResultNotify", "checkResultMsg", float64(0)}, + {"lackRemind", "lackRemindUser", float64(1)}, + {"personalAttendStatNotify", "personDailyReportSwitch", float64(1)}, + {"bossAttendStatNotify", "bossMonthReportType", float64(3)}, + } + for _, item := range validTypes { + value := map[string]any{"userId": "user", item.field: item.value} + if err := attendanceValidateSelfSetting(value, "user", item.scene); err != nil { + t.Errorf("scene %s rejected observed type: %v", item.scene, err) + } + } + + var schema map[string]any + if err := json.Unmarshal(GetSelfSetting.Contract.Result.DataSchema, &schema); err != nil { + t.Fatal(err) + } + properties := schema["properties"].(map[string]any)["value"].(map[string]any)["properties"].(map[string]any) + expectedTypes := map[string]string{ + "checkRemindSetting": "object", + "fastCheckLateNeedConfirm": "boolean", + "checkResultMsg": "integer", + "lackRemindUser": "integer", + "personDailyReportSwitch": "integer", + "bossMonthReportType": "integer", + } + for field, expectedType := range expectedTypes { + property, ok := properties[field].(map[string]any) + if !ok || property["type"] != expectedType || strings.TrimSpace(fmt.Sprint(property["description"])) == "" { + t.Errorf("self-setting schema %s=%#v, want type=%s and description", field, properties[field], expectedType) + } + } +} + +func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { + expected := map[*shortcut.Shortcut]map[string]string{ + &CheckResult: {"users": "userIds", "start": "workDateFrom", "end": "workDateTo", "offset": "offset", "limit": "limit"}, + &CheckRecord: {"users": "userIds", "start": "checkDateFrom", "end": "checkDateTo"}, + &ListApprove: {"users": "userIds", "types": "bizTypes", "start": "fromDate", "end": "toDate"}, + &GetApproveTemplate: {"type": "approveType"}, + &GetSchedule: {"users": "userIdList", "start": "workDateBegin", "end": "workDateEnd"}, + &SearchClass: {"query": "searchName", "filter-type": "filterType", "page": "pageIndex", "limit": "pageSize"}, + &GetClass: {"class-id": "classId"}, + &SearchAdjustmentRule: {"query": "name", "page": "currentPage", "limit": "pageSize"}, + &GetOvertimeRule: {"overtime-id": "overtimeId"}, + &SearchOvertimeRule: {"query": "name", "page": "currentPage", "limit": "pageSize"}, + &GetSelfSetting: {"setting-scene": "settingScene", "user": "userId"}, + } + for declaration, want := range expected { + got := make(map[string]string, len(declaration.Contract.Parameters)) + for _, parameter := range declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if len(got) != len(want) { + t.Errorf("%s parameter count=%d want=%d: %#v", declaration.Command, len(got), len(want), got) + } + for name, property := range want { + if got[name] != property { + t.Errorf("%s --%s property=%q want=%q", declaration.Command, name, got[name], property) + } + } + } +} + +func TestCrossPlatformCoverageAttendanceRuntimeConstraintsFailBeforeCall(t *testing.T) { + manyUsers := make([]string, 101) + for index := range manyUsers { + manyUsers[index] = fmt.Sprintf("u%d", index) + } + cases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {"check result too many users", CheckResult, map[string]string{"users": strings.Join(manyUsers, ","), "start": "2026-01-01", "end": "2026-01-31"}}, + {"check result reversed dates", CheckResult, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"check result over month", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-02-02"}}, + {"check result bad limit", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1001"}}, + {"check result bad offset", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}}, + {"check record duplicate users", CheckRecord, map[string]string{"users": "u1,u1", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "types": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve reversed dates", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-02-01", "end": "2026-01-31"}}, + {"schedule empty users", GetSchedule, map[string]string{"users": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"schedule reversed dates", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"search class page zero", SearchClass, map[string]string{"page": "0"}}, + {"search adjustment limit too large", SearchAdjustmentRule, map[string]string{"limit": "201"}}, + {"search overtime limit zero", SearchOvertimeRule, map[string]string{"limit": "0"}}, + {"class id zero", GetClass, map[string]string{"class-id": "0"}}, + {"overtime id zero", GetOvertimeRule, map[string]string{"overtime-id": "0"}}, + {"leave records reversed dates", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"checkin duplicate users", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1,u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}}, + {"checkin over seven days", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-08 00:00:01"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no Validate", tc.declaration.Command) + } + if err := tc.declaration.Validate(rt); err == nil { + t.Fatalf("invalid values accepted for %s", tc.declaration.Command) + } + if caller.calls != 0 { + t.Fatalf("%s made %d downstream calls before validation", tc.declaration.Command, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendancePaginationLivesInEnvelopeMeta(t *testing.T) { + emit := func(complete bool, nextToken string) map[string]any { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(CheckResult)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, CheckResult) + items := []map[string]any{{"id": int64(1), "userId": "redacted", "workDate": int64(1)}} + if err := attendanceOutputCollection(rt, "records", items, complete, map[string]any{"limit": 1, "nextOffset": 1}, true, nextToken); err != nil { + t.Fatal(err) + } + if _, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted { + t.Fatalf("emit err=%v emitted=%t", err, emitted) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope + } + + envelope := emit(false, "1") + data, ok := envelope["data"].(map[string]any) + if !ok || data["count"] != float64(1) { + t.Fatalf("business data=%#v", envelope["data"]) + } + for _, forbidden := range []string{"complete", "limit", "nextOffset", "nextPage", "totalCount", "totalPage"} { + if _, present := data[forbidden]; present { + t.Errorf("pagination field %q leaked into business data", forbidden) + } + } + meta, ok := envelope["meta"].(map[string]any) + if !ok { + t.Fatalf("meta=%#v", envelope["meta"]) + } + pagination, ok := meta["pagination"].(map[string]any) + if !ok || pagination["endpoint_exhausted"] != false || pagination["next_token"] != "1" { + t.Fatalf("pagination=%#v", meta["pagination"]) + } + exhaustedEnvelope := emit(true, "") + exhaustedMeta := exhaustedEnvelope["meta"].(map[string]any) + exhaustedPagination := exhaustedMeta["pagination"].(map[string]any) + if exhaustedPagination["endpoint_exhausted"] != true { + t.Fatalf("exhausted pagination=%#v", exhaustedPagination) + } + if _, present := exhaustedPagination["next_token"]; present { + t.Fatalf("exhausted pagination published next_token: %#v", exhaustedPagination) + } +} + +func TestCrossPlatformCoverageAttendanceCommonCallersFailClosed(t *testing.T) { + tests := []struct { + name string + run func(*shortcut.RuntimeContext) error + text string + err error + want bool + }{ + { + name: "collection downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "collection malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + text: `{"success":true,"result":null}`, + want: false, + }, + { + name: "collection validator", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, func([]map[string]any) error { + return fmt.Errorf("binding mismatch") + }, "result") + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "collection success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, map[string]any{"source": "test"}, nil, "result") + }, + text: `{"success":true,"result":[]}`, + want: true, + }, + { + name: "value downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "value malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true}`, + want: false, + }, + { + name: "value success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true,"result":false}`, + want: true, + }, + { + name: "object downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "object malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "object success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":{"id":1}}`, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: tc.text, err: tc.err} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, CheckResult, nil) + err := tc.run(rt) + if (err == nil) != tc.want || caller.calls != 1 { + t.Fatalf("err=%v calls=%d wantSuccess=%t", err, caller.calls, tc.want) + } + }) + } + + t.Run("legacy output", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, ListLeaveTypes, nil) + if err := attendanceOutputCollection(rt, "items", []map[string]any{{"leaveCode": "code"}}, true, map[string]any{"source": "legacy"}, false, ""); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid continuation", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, CheckResult, nil) + if err := attendanceOutputCollection(rt, "records", nil, false, nil, true, ""); err == nil { + t.Fatal("non-terminal pagination without next token was accepted") + } + }) +} + +func TestCrossPlatformCoverageAttendanceIdentityAndInputHelpers(t *testing.T) { + if value, ok := attendanceNestedValue(map[string]any{"outer": map[string]any{"id": int32(7)}}, "outer", "id"); !ok || value != int32(7) { + t.Fatalf("nested identity=%#v ok=%t", value, ok) + } + for _, path := range [][]string{{"missing"}, {"outer", "id"}} { + item := map[string]any{"outer": "not-an-object"} + if _, ok := attendanceNestedValue(item, path...); ok { + t.Fatalf("invalid nested path accepted: %#v", path) + } + } + + positive := []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} + for _, value := range positive { + if _, ok := attendancePositiveInteger(value); !ok { + t.Errorf("positive identity rejected: %#v", value) + } + } + invalid := []any{int(0), int32(-1), int64(0), 1.5, json.Number("bad"), "1", nil} + for _, value := range invalid { + if _, ok := attendancePositiveInteger(value); ok { + t.Errorf("invalid identity accepted: %#v", value) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"nested": map[string]any{"id": int32(1)}}}, "attendance/test", "nested", "id"); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"id": 0}}, + {{"id": 1}, {"id": 1}}, + } { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid identity collection accepted: %#v", items) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"code": " A "}, {"code": "B"}}, "attendance/test", "code", ""); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"code": 1}}, + {{"code": " "}}, + {{"code": "A"}, {"code": "A"}}, + } { + if err := attendanceValidateExpectedStrings(items, "attendance/test", "code", ""); err == nil { + t.Fatalf("invalid string identity collection accepted: %#v", items) + } + } + + userCases := []struct { + values []string + maximum int + valid bool + }{ + {[]string{"u1"}, 1, true}, + {nil, 1, false}, + {[]string{"u1", "u2"}, 1, false}, + {[]string{""}, 1, false}, + {[]string{" u1"}, 1, false}, + {[]string{"u1", "u1"}, 2, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserIDs(tc.values, tc.maximum) + if (err == nil) != tc.valid { + t.Errorf("users=%#v maximum=%d err=%v", tc.values, tc.maximum, err) + } + } + + for _, tc := range []struct { + start string + end string + valid bool + }{ + {"2026-01-01", "2026-02-01", true}, + {"bad", "2026-01-01", false}, + {"2026-01-01", "bad", false}, + {"2026-02-01", "2026-01-01", false}, + {"2026-01-01", "2026-02-02", false}, + } { + err := attendanceValidateMonthRange(tc.start, tc.end) + if (err == nil) != tc.valid { + t.Errorf("month range %q..%q err=%v", tc.start, tc.end, err) + } + } +} + +func TestCrossPlatformCoverageAttendancePaginationEvidenceBoundaries(t *testing.T) { + tests := []struct { + name string + data map[string]any + page int + limit int + itemCount int + complete bool + valid bool + }{ + {"invalid request", map[string]any{"success": true, "result": map[string]any{}}, 0, 10, 0, false, false}, + {"malformed result", map[string]any{"success": true, "result": []any{}}, 1, 10, 0, false, false}, + {"current page type", map[string]any{"success": true, "result": map[string]any{"currentPage": "1", "totalPage": 1}}, 1, 10, 0, false, false}, + {"current page mismatch", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 1}}, 1, 10, 0, false, false}, + {"total page type", map[string]any{"success": true, "result": map[string]any{"totalPage": "1"}}, 1, 10, 0, false, false}, + {"negative total page", map[string]any{"success": true, "result": map[string]any{"totalPage": -1}}, 1, 10, 0, false, false}, + {"zero pages invalid current page", map[string]any{"success": true, "result": map[string]any{"totalPage": 0}}, 2, 10, 0, false, false}, + {"page out of range", map[string]any{"success": true, "result": map[string]any{"totalPage": 1}}, 2, 10, 0, false, false}, + {"total count type", map[string]any{"success": true, "result": map[string]any{"totalCount": "1"}}, 1, 10, 0, false, false}, + {"negative total count", map[string]any{"success": true, "result": map[string]any{"totalCount": -1}}, 1, 10, 0, false, false}, + {"empty with nonzero total", map[string]any{"success": true, "result": map[string]any{"totalCount": 1}}, 1, 10, 0, false, false}, + {"count before page", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 2, 10, 0, false, false}, + {"items exceed total", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 1, 10, 6, false, false}, + {"missing evidence", map[string]any{"success": true, "result": map[string]any{}}, 1, 10, 0, false, false}, + {"conflicting evidence", map[string]any{"success": true, "result": map[string]any{"totalPage": 2, "totalCount": 5}}, 1, 10, 5, false, false}, + {"total mismatch", map[string]any{"success": true, "result": map[string]any{"totalPage": 3, "totalCount": 15}}, 1, 10, 10, false, false}, + {"no progress", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 0, false, false}, + {"short page", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 5, false, false}, + {"terminal count mismatch", map[string]any{"success": true, "result": map[string]any{"totalCount": 15}}, 2, 10, 4, false, false}, + {"empty terminal", map[string]any{"success": true, "result": map[string]any{"totalPage": 0, "totalCount": 0}}, 1, 10, 0, true, true}, + {"full nonterminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 1, "totalPage": 2, "totalCount": 20}}, 1, 10, 10, false, true}, + {"short terminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 2, "totalCount": 15}}, 2, 10, 5, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + complete, extra, err := attendancePageEvidence(tc.data, "attendance/test", tc.page, tc.limit, tc.itemCount) + if (err == nil) != tc.valid { + t.Fatalf("err=%v extra=%#v", err, extra) + } + if tc.valid && complete != tc.complete { + t.Fatalf("complete=%t want=%t extra=%#v", complete, tc.complete, extra) + } + if tc.valid && !tc.complete && extra["nextPage"] != tc.page+1 { + t.Fatalf("nonterminal extra=%#v", extra) + } + }) + } + + for _, tc := range []struct { + page string + limit string + valid bool + }{ + {"1", "200", true}, + {"0", "20", false}, + {"1", "0", false}, + {"1", "201", false}, + } { + rt := attendanceRuntimeForTest(t, SearchClass, map[string]string{"page": tc.page, "limit": tc.limit}) + page, limit, err := attendancePageInput(rt) + if (err == nil) != tc.valid { + t.Errorf("page=%s limit=%s got=(%d,%d) err=%v", tc.page, tc.limit, page, limit, err) + } + if err2 := attendanceValidatePageRequest(rt); (err2 == nil) != tc.valid { + t.Errorf("validator page=%s limit=%s err=%v", tc.page, tc.limit, err2) + } + } + + for _, tc := range []struct { + value any + want int + valid bool + }{ + {int(1), 1, true}, {int32(2), 2, true}, {int64(3), 3, true}, {float64(4), 4, true}, + {float64(1.5), 0, false}, {"1", 0, false}, + } { + got, ok := attendanceInt(tc.value) + if got != tc.want || ok != tc.valid { + t.Errorf("attendanceInt(%#v)=(%d,%t), want (%d,%t)", tc.value, got, ok, tc.want, tc.valid) + } + } +} + +func attendanceResponseJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid bool + }{ + {"check result valid", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "0"}, true}, + {"check record valid", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"approve empty users", ListApprove, map[string]string{"types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid start", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "bad", "end": "2026-01-31"}, false}, + {"approve invalid end", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "bad"}, false}, + {"approve duplicate mapped type", ListApprove, map[string]string{"users": "u1", "types": "leave,请假", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid type", ListApprove, map[string]string{"users": "u1", "types": "invalid", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve valid", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"schedule invalid start", GetSchedule, map[string]string{"users": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"schedule invalid end", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"schedule reverse", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}, false}, + {"schedule valid", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"class valid", GetClass, map[string]string{"class-id": "1"}, true}, + {"overtime valid", GetOvertimeRule, map[string]string{"overtime-id": "1"}, true}, + {"self setting empty user", GetSelfSetting, map[string]string{"user": "", "setting-scene": "checkRemind"}, false}, + {"self setting valid", GetSelfSetting, map[string]string{"user": "u1", "setting-scene": "checkRemind"}, true}, + {"leave records empty user", GetLeaveRecords, map[string]string{"user": "", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"leave records invalid start", GetLeaveRecords, map[string]string{"user": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"leave records invalid end", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"leave records valid", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"checkin missing operator", GetCheckinRecord, map[string]string{"operator-corp-id": "", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid start", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "bad", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid end", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "bad"}, false}, + {"checkin reverse", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-02 00:00:00", "end": "2026-01-01 00:00:00"}, false}, + {"checkin valid", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no validator", tc.declaration.Command) + } + err := tc.declaration.Validate(rt) + if (err == nil) != tc.valid { + t.Fatalf("err=%v wantValid=%t", err, tc.valid) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { + startMillis, _ := dayMillis("2026-01-01") + endMillis, _ := dayMillis("2026-01-31") + validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + + t.Run("check result bad execute bounds", func(t *testing.T) { + for _, values := range []map[string]string{ + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "0"}, + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}, + } { + caller, err := executeAttendanceResponse(t, CheckResult, values, `{"success":true,"result":[]}`) + if err == nil || caller.calls != 0 { + t.Fatalf("values=%#v err=%v calls=%d", values, err, caller.calls) + } + } + }) + + t.Run("check result downstream and response failures", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + responses := []string{ + `{"success":true,"result":null}`, + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 0, "userId": "u1", "workDate": startMillis}}}), + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}}), + } + for _, response := range responses { + caller, err := executeAttendanceResponse(t, CheckResult, values, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckResult.Execute(attendanceRuntimeForTest(t, CheckResult, values)); err == nil || caller.calls != 1 { + t.Fatalf("downstream err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("check result short and full pages", func(t *testing.T) { + shortValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2", "offset": "0"} + caller, err := executeAttendanceResponse(t, CheckResult, shortValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("short err=%v calls=%d", err, caller.calls) + } + fullValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "4"} + caller, err = executeAttendanceResponse(t, CheckResult, fullValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("full err=%v calls=%d", err, caller.calls) + } + }) + + collectionCases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid map[string]any + invalid map[string]any + }{ + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, + {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + } + for _, tc := range collectionCases { + t.Run(tc.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid binding", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.invalid}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid identity", func(t *testing.T) { + invalidIdentity := make(map[string]any, len(tc.valid)) + for key, value := range tc.valid { + invalidIdentity[key] = value + } + invalidIdentity["id"] = 0 + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{invalidIdentity}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceSearchProjectorsFailClosed(t *testing.T) { + classValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": map[string]any{"id": 1, "name": "n", "ownerName": "o"}}}}} + classes, err := searchClassProject(classValid) + if err != nil || len(classes) != 1 || classes[0]["classId"] != int64(1) || classes[0]["name"] != "n" || classes[0]["ownerName"] != "o" { + t.Fatalf("class projection=%#v err=%v", classes, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"classId": 1}}}}, + } { + if _, err := searchClassProject(data); err == nil { + t.Fatalf("invalid class projection accepted: %#v", data) + } + } + + ruleValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": map[string]any{"id": 2, "name": "rule"}}}}} + rules, err := searchRuleProject(ruleValid, "attendance/test", "result.items") + if err != nil || len(rules) != 1 || rules[0]["ruleId"] != int64(2) || rules[0]["name"] != "rule" { + t.Fatalf("rule projection=%#v err=%v", rules, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"ruleId": 1}}}}, + } { + if _, err := searchRuleProject(data, "attendance/test", "result.items"); err == nil { + t.Fatalf("invalid rule projection accepted: %#v", data) + } + } + + groupValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"group_id": "g1", "group_name": "group", "group_type": "FIXED"}}}} + groups, err := searchGroupProject(groupValid) + if err != nil || len(groups) != 1 || groups[0]["groupId"] != "g1" || groups[0]["name"] != "group" || groups[0]["type"] != "FIXED" { + t.Fatalf("group projection=%#v err=%v", groups, err) + } + if _, err := searchGroupProject(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}); err == nil { + t.Fatal("group without identity was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceSearchExecutorsPagination(t *testing.T) { + type searchCase struct { + name string + declaration shortcut.Shortcut + tool string + resultKey string + item map[string]any + } + tests := []searchCase{ + {"class", SearchClass, "get_class_list", "items", map[string]any{"shiftVO": map[string]any{"id": 1}}}, + {"adjustment", SearchAdjustmentRule, "get_adjustment_rule", "adjustmentList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + {"overtime", SearchOvertimeRule, "get_overtime_rule", "atRuleList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + } + for _, tc := range tests { + t.Run(tc.name+" invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" malformed collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, `{"success":true,"result":{}}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" missing pagination evidence", func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + for _, page := range []struct { + name string + totalPage int + total int + }{ + {"terminal", 1, 1}, + {"nonterminal", 2, 2}, + } { + t.Run(tc.name+" "+page.name, func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}, "currentPage": 1, "totalPage": page.totalPage, "totalCount": page.total} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + if caller.tools[0] != tc.tool { + t.Fatalf("tool=%q want=%q", caller.tools[0], tc.tool) + } + }) + } + } + + t.Run("group invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run("group malformed and pagination failure", func(t *testing.T) { + for _, response := range []string{ + `{"success":true,"result":{}}`, + `{"success":true,"result":{"items":[{"id":"g1"}]}}`, + } { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"query": "q", "type": "FIXED", "query-position": "true", "query-ble": "false", "page": "1", "limit": "1"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + }) + t.Run("group terminal", func(t *testing.T) { + response := `{"success":true,"result":{"items":[{"id":"g1","name":"group","type":"FIXED"}],"currentPage":1,"totalPage":1,"totalCount":1}}` + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "1", "limit": "1"}, response) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) { + t.Run("approve template exact type", func(t *testing.T) { + for _, tc := range []struct { + value string + response string + valid bool + calls int + }{ + {"invalid", `{"success":true,"result":[]}`, false, 0}, + {"leave", `{"success":true,"result":[{"approveType":"OVERTIME"}]}`, false, 1}, + {"leave", `{"success":true,"result":[{"approveType":"LEAVE"}]}`, true, 1}, + {"OUT", `{"success":true,"result":[{"approveType":"OUT"}]}`, true, 1}, + } { + caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response) + if (err == nil) != tc.valid || caller.calls != tc.calls { + t.Errorf("type=%q err=%v calls=%d wantValid=%t wantCalls=%d", tc.value, err, caller.calls, tc.valid, tc.calls) + } + } + }) + + t.Run("hidden adjustment detail still fails closed", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetAdjustmentRule, map[string]string{"adjustment-id": "7"}, `{"success":true,"result":{"id":7}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("summary object", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSummary, map[string]string{"user": "u1", "date": "2026-01-01", "stats-type": "week"}, `{"success":true,"result":{"days":1}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("self setting helper rejects invalid request and scene", func(t *testing.T) { + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1", "checkRemindSetting": map[string]any{}}, " ", "checkRemind"); err == nil { + t.Fatal("empty requested user accepted") + } + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1"}, "u1", "not-a-scene"); err == nil { + t.Fatal("invalid setting scene accepted") + } + }) + + t.Run("report value", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, QueryReportData, map[string]string{"users": "u1", "columns": "1, 2", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, `{"success":true,"result":false}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave type exact collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, ListLeaveTypes, nil, `{"success":true,"result":[{"leaveCode":"code"}]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave records optional code", func(t *testing.T) { + values := map[string]string{"user": "u1", "leave-code": "code", "start": "2026-01-01", "end": "2026-01-31"} + caller, err := executeAttendanceResponse(t, GetLeaveRecords, values, `{"success":true,"result":[]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("checkin record nested collection", func(t *testing.T) { + values := map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"} + caller, err := executeAttendanceResponse(t, GetCheckinRecord, values, `{"success":true,"result":{"list":[]}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +var _ edition.ToolCaller = (*attendanceCoverageCaller)(nil) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go index bbe160a9..29001758 100644 --- a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -4,10 +4,12 @@ package attendance import ( + "encoding/json" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) { @@ -16,25 +18,20 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes rollout output.RolloutState hasResult bool resultSchema string + identity string + cursor string }{ - {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema)}, - {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema)}, - {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema)}, - {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema)}, - {GetSchedule.Command, GetSchedule.OutputRollout, GetSchedule.Contract.Result != nil, string(GetSchedule.Contract.Result.DataSchema)}, - {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema)}, - {GetClass.Command, GetClass.OutputRollout, GetClass.Contract.Result != nil, string(GetClass.Contract.Result.DataSchema)}, - {GetAdjustmentRule.Command, GetAdjustmentRule.OutputRollout, GetAdjustmentRule.Contract.Result != nil, string(GetAdjustmentRule.Contract.Result.DataSchema)}, - {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema)}, - {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema)}, - {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema)}, - {SearchGroup.Command, SearchGroup.OutputRollout, SearchGroup.Contract.Result != nil, string(SearchGroup.Contract.Result.DataSchema)}, - {GetSummary.Command, GetSummary.OutputRollout, GetSummary.Contract.Result != nil, string(GetSummary.Contract.Result.DataSchema)}, - {GetSelfSetting.Command, GetSelfSetting.OutputRollout, GetSelfSetting.Contract.Result != nil, string(GetSelfSetting.Contract.Result.DataSchema)}, - {QueryReportData.Command, QueryReportData.OutputRollout, QueryReportData.Contract.Result != nil, string(QueryReportData.Contract.Result.DataSchema)}, - {ListLeaveTypes.Command, ListLeaveTypes.OutputRollout, ListLeaveTypes.Contract.Result != nil, string(ListLeaveTypes.Contract.Result.DataSchema)}, - {GetLeaveRecords.Command, GetLeaveRecords.OutputRollout, GetLeaveRecords.Contract.Result != nil, string(GetLeaveRecords.Contract.Result.DataSchema)}, - {GetCheckinRecord.Command, GetCheckinRecord.OutputRollout, GetCheckinRecord.Contract.Result != nil, string(GetCheckinRecord.Contract.Result.DataSchema)}, + {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"}, + {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, + {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"approveType"`, ""}, + {GetSchedule.Command, GetSchedule.OutputRollout, GetSchedule.Contract.Result != nil, string(GetSchedule.Contract.Result.DataSchema), `"id"`, ""}, + {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, + {GetClass.Command, GetClass.OutputRollout, GetClass.Contract.Result != nil, string(GetClass.Contract.Result.DataSchema), `"shiftVO"`, ""}, + {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, + {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + {GetSelfSetting.Command, GetSelfSetting.OutputRollout, GetSelfSetting.Contract.Result != nil, string(GetSelfSetting.Contract.Result.DataSchema), `"userId"`, ""}, } for _, item := range shortcuts { if item.rollout != output.RolloutUnifiedActive { @@ -43,6 +40,42 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" { t.Errorf("%s must publish a non-empty Result", item.name) } + if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) { + t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema) + } + if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 { + t.Errorf("%s must publish reviewed sensitive_paths", item.name) + } + if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) { + t.Errorf("%s Result must not place pagination controls in business data", item.name) + } + if item.cursor == "" { + if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil { + t.Errorf("%s unexpectedly publishes Pagination", item.name) + } + } else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor { + t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor) + } + } +} + +func attendanceDeclarationByName(name string) *shortcut.Shortcut { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &GetSchedule, &SearchClass, &GetClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule, &GetSelfSetting} { + if declaration.Command == name { + return declaration + } + } + return &shortcut.Shortcut{} +} + +func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || strings.TrimSpace(declaration.Contract.Interface.Reason) == "" { + t.Errorf("%s must publish a precise unavailable Interface", declaration.Command) + } } } @@ -94,11 +127,14 @@ func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t * func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) { withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}} - if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20); err == nil { + if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil { t.Fatal("missing totalCount/totalPage must not be reported as a complete page") } firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}} - complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20) + if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("nonzero total with empty page must fail closed") + } + complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20) if err != nil { t.Fatalf("valid page evidence: %v", err) } @@ -106,20 +142,41 @@ func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t * t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra) } lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}} - complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20) + complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1) if err != nil || !complete { t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err) } conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}} - if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20); err == nil { + if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil { t.Fatal("conflicting totalPage/totalCount evidence must fail closed") } mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}} - if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20); err == nil { + if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil { t.Fatal("mismatched currentPage must fail closed") } negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}} - if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20); err == nil { + if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil { t.Fatal("negative pagination evidence must fail closed") } + wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}} + if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("wrong pagination type must fail closed") + } +} + +func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) { + for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid integer identity accepted: %#v", items) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil { + t.Fatalf("valid integer identities rejected: %v", err) + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("blank string identity accepted") + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("duplicate string identity accepted") + } } diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index a1ba2fb4..a56f77a4 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -6,6 +6,9 @@ package attendance import ( "encoding/json" "fmt" + "strconv" + "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" @@ -13,63 +16,133 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) -func attendanceObjectResult(description string) *contract.ResultSpec { +func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec { return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(fmt.Sprintf( - `{"type":"object","description":%q,"properties":{"value":{"description":"严格校验后的考勤业务结果"}},"required":["value"],"additionalProperties":false}`, - description, + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, identity, identity, )), } } -func attendanceCollectionResult(collection, description string) *contract.ResultSpec { +func attendanceNestedIntegerObjectResult(description, container, identity string) *contract.ResultSpec { return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(fmt.Sprintf( - `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"考勤业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"现有服务端证据是否证明结果已经完整"},"page":{"type":"integer","description":"当前页码"},"limit":{"type":"integer","description":"请求的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"totalPage":{"type":"integer","description":"服务端报告的总页数"},"nextPage":{"type":"integer","description":"结果未完整时建议请求的下一页"},"nextOffset":{"type":"integer","description":"结果未完整时建议请求的下一偏移量"}},"required":["count",%q,"complete"],"additionalProperties":false}`, - description, collection, description, collection, + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"object","description":"承载详情稳定身份的业务对象","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, container, identity, identity, container, )), } } +func attendanceSelfSettingResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格绑定用户与设置场景的个人考勤设置","properties":{"value":{"type":"object","description":"个人考勤设置对象","properties":{"userId":{"type":"string","minLength":1,"description":"与请求精确绑定的用户 ID"},"checkRemindSetting":{"type":"object","description":"打卡提醒场景设置","additionalProperties":true},"fastCheckLateNeedConfirm":{"type":"boolean","description":"极速打卡场景设置"},"checkResultMsg":{"type":"integer","description":"打卡结果通知场景设置"},"lackRemindUser":{"type":"integer","description":"缺卡提醒场景设置"},"personDailyReportSwitch":{"type":"integer","description":"个人考勤统计通知场景设置"},"bossMonthReportType":{"type":"integer","description":"团队考勤统计通知场景设置"}},"required":["userId"],"anyOf":[{"required":["checkRemindSetting"]},{"required":["fastCheckLateNeedConfirm"]},{"required":["checkResultMsg"]},{"required":["lackRemindUser"]},{"required":["personDailyReportSwitch"]},{"required":["bossMonthReportType"]}],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`), + } +} + +func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec { + identityConstraint := `"minLength":1` + if identityType == "integer" { + identityConstraint = `"minimum":1` + } + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, identityType, identityConstraint, identity, collection, + )), + } +} + +func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: cursorParameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + func hardenPublicAttendanceContracts() { + CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "start", Property: "workDateFrom"}, {Name: "end", Property: "workDateTo"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} + CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "start", Property: "checkDateFrom"}, {Name: "end", Property: "checkDateTo"}} + ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "types", Property: "bizTypes"}, {Name: "start", Property: "fromDate"}, {Name: "end", Property: "toDate"}} + GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "approveType"}} + GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIdList"}, {Name: "start", Property: "workDateBegin"}, {Name: "end", Property: "workDateEnd"}} + SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "searchName"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "pageIndex"}, {Name: "limit", Property: "pageSize"}} + GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}} + SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "name"}, {Name: "page", Property: "currentPage"}, {Name: "limit", Property: "pageSize"}} + GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}} + SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "name"}, {Name: "page", Property: "currentPage"}, {Name: "limit", Property: "pageSize"}} + GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "userId"}} + collections := []struct { - declaration *shortcut.Shortcut - collection string - description string + declaration *shortcut.Shortcut + collection string + description string + identity string + identityType string + cursor string }{ - {&CheckResult, "records", "严格校验的员工打卡结果"}, - {&CheckRecord, "records", "严格校验的员工打卡流水"}, - {&ListApprove, "approvals", "严格校验的考勤审批记录"}, - {&GetApproveTemplate, "templates", "严格校验的考勤审批模板"}, - {&GetSchedule, "schedules", "严格校验的员工排班记录"}, - {&SearchClass, "classes", "严格校验的班次搜索结果"}, - {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果"}, - {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果"}, - {&SearchGroup, "groups", "严格校验的考勤组搜索结果"}, - {&ListLeaveTypes, "leaveTypes", "严格校验的假期类型列表"}, - {&GetLeaveRecords, "records", "严格校验的假期余额变更记录"}, - {&GetCheckinRecord, "records", "严格校验的签到记录"}, + {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, + {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, + {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, + {&GetApproveTemplate, "templates", "严格校验的考勤审批模板", "approveType", "string", ""}, + {&GetSchedule, "schedules", "严格校验的员工排班记录", "id", "integer", ""}, + {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, + {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, + {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"}, } for _, item := range collections { item.declaration.OutputRollout = output.RolloutUnifiedActive - item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description) + item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType) + if item.cursor != "" { + item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor) + } } - objects := []struct { + CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"} + CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"} + ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"} + GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} + GetSchedule.Contract.Result.SensitivePaths = []string{"schedules.className", "schedules.corpId", "schedules.userId"} + SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} + SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} + SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} + GetClass.OutputRollout = output.RolloutUnifiedActive + GetClass.Contract.Result = attendanceNestedIntegerObjectResult("严格校验的班次详情", "shiftVO", "id") + GetClass.Contract.Result.SensitivePaths = []string{"value.shiftVO.name", "value.shiftVO.owner", "value.shiftVO.ownerName", "value.shiftVO.sections"} + GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive + GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") + GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} + GetSelfSetting.OutputRollout = output.RolloutUnifiedActive + GetSelfSetting.Contract.Result = attendanceSelfSettingResult() + GetSelfSetting.Contract.Result.SensitivePaths = []string{"value.bossMonthReportType", "value.checkRemindSetting", "value.checkResultMsg", "value.fastCheckLateNeedConfirm", "value.lackRemindUser", "value.personDailyReportSwitch", "value.userId"} + + for _, item := range []struct { declaration *shortcut.Shortcut - description string + reason string }{ - {&GetClass, "严格校验的班次详情"}, - {&GetAdjustmentRule, "严格校验的补卡规则详情"}, - {&GetOvertimeRule, "严格校验的加班规则详情"}, - {&GetSummary, "严格校验的个人考勤统计摘要"}, - {&GetSelfSetting, "严格校验的个人考勤设置"}, - {&QueryReportData, "严格校验的考勤报表数据"}, + {&GetSummary, "真实详情响应不回显请求用户、统计周期或统计类型,无法把结果精确绑定到请求。"}, + {&ListLeaveTypes, "当前安全身份只有非空假期类型列表,且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。"}, + {&GetLeaveRecords, "使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。"}, + {&GetCheckinRecord, "当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。"}, + } { + markAttendanceUnavailable(item.declaration, item.reason) } - for _, item := range objects { - item.declaration.OutputRollout = output.RolloutUnifiedActive - item.declaration.Contract.Result = attendanceObjectResult(item.description) +} + +func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: "composite", + Availability: "unavailable", + Reason: reason, } } @@ -80,6 +153,7 @@ func attendanceCallCollection( collection string, complete bool, extra map[string]any, + validate func([]map[string]any) error, paths ...string, ) error { data, err := rt.CallMCPData(product, tool, params) @@ -90,15 +164,12 @@ func attendanceCallCollection( if err != nil { return err } - payload := map[string]any{ - "count": len(items), - collection: items, - "complete": complete, + if validate != nil { + if err := validate(items); err != nil { + return err + } } - for key, value := range extra { - payload[key] = value - } - return rt.Output(payload) + return attendanceOutputCollection(rt, collection, items, complete, extra, false, "") } func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { @@ -137,28 +208,253 @@ func attendanceCollectionPayload(collection string, items []map[string]any, comp return payload } -func attendancePageEvidence(data map[string]any, operation string, page, limit int) (bool, map[string]any, error) { +func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error { + payload := attendanceCollectionPayload(collection, items, complete, legacyExtra) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Count: output.NewCount(len(items))} + if paginated { + pagination, err := output.NewPagination(complete, nextToken) + if err != nil { + return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error()) + } + meta.Pagination = pagination + } + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error { + seen := make(map[int64]struct{}, len(items)) + for index, item := range items { + value, ok := attendanceNestedValue(item, path...) + if !ok { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index)) + } + identity, ok := attendancePositiveInteger(value) + if !ok { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return nil +} + +func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error { + expected = strings.TrimSpace(expected) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field)) + } + if expected != "" && value != expected { + return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field)) + } + if _, duplicate := seen[value]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field)) + } + seen[value] = struct{}{} + } + return nil +} + +func attendanceNestedValue(item map[string]any, path ...string) (any, bool) { + var current any = item + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[key] + if !ok { + return nil, false + } + } + return current, true +} + +func attendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} + +func attendanceValidateUserIDs(values []string, maximum int) error { + if len(values) == 0 { + return fmt.Errorf("用户 ID 列表不能为空") + } + if maximum > 0 && len(values) > maximum { + return fmt.Errorf("用户 ID 数量不能超过 %d", maximum) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("用户 ID 不能为空字符串") + } + if value != trimmed { + return fmt.Errorf("用户 ID 不能包含首尾空白") + } + if _, duplicate := seen[trimmed]; duplicate { + return fmt.Errorf("用户 ID 不能重复") + } + seen[trimmed] = struct{}{} + } + return nil +} + +func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + user, ok := item[userField].(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + timestamp, ok := attendancePositiveInteger(item[timeField]) + if !ok || timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) + } + } + return nil +} + +func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + user, ok := item["userId"].(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + bizType, ok := attendancePositiveInteger(item["bizType"]) + if !ok { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index)) + } + if _, requested := requestedTypes[int(bizType)]; !requested { + return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index)) + } + begin, beginOK := attendancePositiveInteger(item["beginTime"]) + end, endOK := attendancePositiveInteger(item["endTime"]) + if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index)) + } + } + return nil +} + +func attendanceValidateMonthRange(startText, endText string) error { + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local) + if err != nil { + return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local) + if err != nil { + return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.After(start.AddDate(0, 1, 0)) { + return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月") + } + return nil +} + +func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error { + _, _, err := attendancePageInput(rt) + return err +} + +func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) { + if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit { + return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效") + } result, err := responsecheck.RequireObjectResult(data, operation) if err != nil { return false, nil, err } extra := map[string]any{"page": page, "limit": limit} - if currentPage, ok := attendanceInt(result["currentPage"]); ok && currentPage != page { - return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + if raw, present := result["currentPage"]; present { + currentPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数") + } + if currentPage != page { + return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + } } var evidence []bool - if totalPage, ok := attendanceInt(result["totalPage"]); ok { + var totalPageValue, totalCountValue *int + if raw, present := result["totalPage"]; present { + totalPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数") + } if totalPage < 0 { return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数") } extra["totalPage"] = totalPage - evidence = append(evidence, page >= totalPage) + totalPageValue = &totalPage + if totalPage == 0 { + if page != 1 || itemCount != 0 { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空") + } + evidence = append(evidence, true) + } else { + if page > totalPage { + return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage") + } + evidence = append(evidence, page == totalPage) + } } - if totalCount, ok := attendanceInt(result["totalCount"]); ok { + if raw, present := result["totalCount"]; present { + totalCount, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数") + } if totalCount < 0 { return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数") } extra["totalCount"] = totalCount + totalCountValue = &totalCount + if totalCount > 0 && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止") + } + pageStart := (page - 1) * limit + if pageStart > totalCount || pageStart+itemCount > totalCount { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾") + } evidence = append(evidence, page*limit >= totalCount) } if len(evidence) == 0 { @@ -170,6 +466,27 @@ func attendancePageEvidence(data map[string]any, operation string, page, limit i return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据") } } + if totalPageValue != nil && totalCountValue != nil { + expectedPages := 0 + if *totalCountValue > 0 { + expectedPages = (*totalCountValue + limit - 1) / limit + } + if *totalPageValue != expectedPages { + return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾") + } + } + if !complete && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进") + } + if !complete && itemCount != limit { + return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾") + } + if complete && totalCountValue != nil { + pageStart := (page - 1) * limit + if pageStart+itemCount != *totalCountValue { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount") + } + } if !complete { extra["nextPage"] = page + 1 } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go index 081ffcd6..ffe3b903 100644 --- a/internal/shortcut/builtin/attendance_semantic_catalog_test.go +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -79,7 +79,7 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 17 { - t.Fatalf("public attendance shortcuts = %d, want 17", public) + if public != 11 { + t.Fatalf("public attendance shortcuts = %d, want 11", public) } } diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go index 03387b0e..1d5676ae 100644 --- a/internal/shortcut/builtin/mail_semantic_catalog_test.go +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -37,6 +37,7 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( } var missing, stale []string public := 0 + unavailable := 0 for command, item := range registered { record, ok := source.Shortcuts[command] if !ok { @@ -62,6 +63,18 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) } } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s: unavailable shortcut remains visible", command) + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: unavailable runtime interface is not explicit", command) + } + if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) + } + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -73,7 +86,7 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 18 { - t.Fatalf("public mail shortcuts = %d, want 18", public) + if public != 8 || unavailable != 10 { + t.Fatalf("mail public/unavailable shortcuts = %d/%d, want 8/10", public, unavailable) } } diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go index 116a3fbf..1674f058 100644 --- a/internal/shortcut/mail/common.go +++ b/internal/shortcut/mail/common.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -18,23 +19,69 @@ import ( const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." func mailCollectionResult(collection, description string) *contract.ResultSpec { + identity := mailCollectionIdentitySchema(collection) return &contract.ResultSpec{ - Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: mailCollectionSensitivePaths(collection), DataSchema: json.RawMessage(fmt.Sprintf( - `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`, - description, collection, description, collection, + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection, )), } } +func mailCollectionSensitivePaths(collection string) []string { + switch collection { + case "threads": + return []string{"threads.subject", "threads.lastUpdated"} + case "folders": + return []string{"folders.name"} + case "tags": + return []string{"tags.name"} + case "users": + return []string{"users.name", "users.email", "users.employeeNo"} + case "templates": + return []string{"templates.name", "templates.subject"} + case "contacts": + return []string{"contacts.contactEmail", "contacts.displayName"} + case "messages": + return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"} + default: + panic("unsupported Mail collection sensitive paths: " + collection) + } +} + +func mailCollectionIdentitySchema(collection string) string { + switch collection { + case "threads": + return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]` + case "folders", "tags", "templates", "contacts", "messages": + return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]` + case "users": + return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]` + default: + panic("unsupported Mail collection Result identity: " + collection) + } +} + +func mailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + func mailObjectResult(description string) *contract.ResultSpec { return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(fmt.Sprintf( - `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, description, )), - SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients"}, + SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"}, } } @@ -42,6 +89,51 @@ func mailReadSafety() contract.SafetySpec { return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} } +func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func mailValidateThreadList(rt *shortcut.RuntimeContext) error { + if err := mailValidatePageSize(rt, "limit", true); err != nil { + return err + } + parsed := make(map[string]time.Time, 2) + for _, flag := range []string{"start", "end"} { + if !rt.Changed(flag) { + continue + } + value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag)) + } + _, offset := value.Zone() + if offset != 0 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag)) + } + parsed[flag] = value + } + start, hasStart := parsed["start"] + end, hasEnd := parsed["end"] + if hasStart && hasEnd && end.Before(start) { + return apperrors.NewValidation("--end 不能早于 --start") + } + return nil +} + func mailWriteSafety(idempotency string) contract.SafetySpec { return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency} } @@ -160,14 +252,66 @@ func mailRequireIdentity(object map[string]any, operation, expected string, keys } func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error { + allowNumericID := false + for _, key := range identityKeys { + if key == "email" { + allowNumericID = true + break + } + } for index, item := range items { - if mailFirstString(item, identityKeys...) == "" { + valid := mailFirstString(item, identityKeys...) != "" + if !valid && allowNumericID { + valid = mailNumericIdentity(item["id"]) + } + if !valid { return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) } } return nil } +func mailNumericIdentity(value any) bool { + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number: + return true + default: + return false + } +} + +func mailProjectValue(operation, outputName string, value any) (any, bool, error) { + if value == nil { + return nil, false, nil + } + switch outputName { + case "email": + text, ok := value.(string) + if !ok { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + return text, text != "", nil + case "userId": + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + return text, text != "", nil + } + if !mailNumericIdentity(value) { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字") + } + return value, true, nil + default: + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return nil, false, nil + } + return text, true, nil + } + return value, true, nil + } +} + func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) { items, err := mailRequireCollection(data, operation, path) if err != nil { @@ -182,7 +326,14 @@ func mailProjectCollection(data map[string]any, operation, path string, identity for outputName, candidates := range fields { for _, candidate := range candidates { if value, present := item[candidate]; present && value != nil { - row[outputName] = value + projectedValue, include, err := mailProjectValue(operation, outputName, value) + if err != nil { + return nil, err + } + if !include { + continue + } + row[outputName] = projectedValue break } } @@ -192,7 +343,7 @@ func mailProjectCollection(data map[string]any, operation, path string, identity return projected, nil } -func mailPage(data map[string]any, operation, prefix string) (bool, string, error) { +func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { container := data if prefix != "" { value, present := mailLookup(data, prefix) @@ -225,11 +376,17 @@ func mailPage(data map[string]any, operation, prefix string) (bool, string, erro if !hasMore { return true, "", nil } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } return false, next, nil } if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { return true, "", nil } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } return false, next, nil } @@ -280,22 +437,61 @@ func mailCollectionPayload(collection string, items []map[string]any, complete b return payload } +func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any { + return map[string]any{"count": len(items), collection: items} +} + +// mailOutputPage preserves legacy payload compatibility while keeping cursor +// controls in the unified envelope's meta.pagination contract. +func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := mailCollectionPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return mailResponseError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + func hardenPublicMailContracts() { collections := []struct { declaration *shortcut.Shortcut collection string description string + paginated bool }{ - {&ThreadList, "threads", "严格校验的邮件会话列表"}, - {&FolderList, "folders", "严格校验的邮箱文件夹列表"}, - {&TagList, "tags", "严格校验的邮件标签列表"}, - {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果"}, - {&TemplateList, "templates", "严格校验的邮件模板列表"}, - {&ContactList, "contacts", "严格校验的邮件联系人列表"}, + {&ThreadList, "threads", "严格校验的邮件会话列表", true}, + {&FolderList, "folders", "严格校验的邮箱文件夹列表", false}, + {&TagList, "tags", "严格校验的邮件标签列表", false}, + {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true}, + {&TemplateList, "templates", "严格校验的邮件模板列表", true}, + {&ContactList, "contacts", "严格校验的邮件联系人列表", true}, } for _, item := range collections { item.declaration.OutputRollout = output.RolloutUnifiedActive item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description) + if item.paginated { + item.declaration.Contract.Pagination = mailCursorPagination() + } item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} } + markMailUnavailable(&ThreadList, "当前租户全部可见文件夹均有会话,且时间过滤未提供可靠零命中;没有已验证空文件夹 fixture,无法完成 known-nonempty + guaranteed-zero 双态发布审计。") + markMailUnavailable(&TagList, "标签列表没有 query/parent 过滤;当前没有专用空邮箱 fixture,不能用删除后的列表或越界状态冒充 guaranteed-zero。") + markMailUnavailable(&TemplateList, "模板列表没有 query 过滤;当前没有专用空邮箱 fixture,且模板删除后缺少 typed nonfound 清理证据,无法证明 guaranteed-zero。") + markMailUnavailable(&ContactList, "联系人列表没有 query 过滤;当前没有专用空邮箱 fixture,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。") +} + +func markMailUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } } diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 9e099282..9bd31f8d 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -18,8 +18,12 @@ package mail import ( + "fmt" + "strings" + "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/shortcut" ) @@ -64,6 +68,13 @@ var ThreadList = shortcut.Shortcut{ PrimaryCLIPath: "mail +thread-list", }, Description: "列出指定邮箱文件夹下的邮件会话(thread)", + Parameters: []contract.ParamDecl{ + {Name: "folder", Property: "folderId"}, + {Name: "limit", Property: "size"}, + {Name: "start", Property: "startTime"}, + {Name: "end", Property: "endTime"}, + {Name: "ascending", Property: "isAscending"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -85,6 +96,13 @@ var ThreadList = shortcut.Shortcut{ {Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"}, {Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateThreadList(rt) + }, Tips: []string{ `dws mail +thread-list --email user@company.com --folder 104 --limit 20`, }, @@ -116,11 +134,11 @@ var ThreadList = shortcut.Shortcut{ if err != nil { return err } - complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result") + complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) if err != nil { return err } - return rt.Output(mailCollectionPayload("threads", threads, complete, next)) + return mailOutputPage(rt, "threads", threads, complete, next) }, } @@ -152,6 +170,7 @@ var FolderList = shortcut.Shortcut{ PrimaryCLIPath: "mail +folder-list", }, Description: "列出顶层文件夹或指定父文件夹下的子文件夹", + Parameters: []contract.ParamDecl{{Name: "folder", Property: "folderId"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -165,9 +184,13 @@ var FolderList = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "email") + }, Tips: []string{ `dws mail +folder-list --email user@company.com`, }, @@ -188,7 +211,7 @@ var FolderList = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(mailCollectionPayload("folders", folders, true, "")) + return rt.Output(mailBusinessCollectionPayload("folders", folders)) }, } @@ -249,7 +272,7 @@ var TagList = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(mailCollectionPayload("tags", tags, true, "")) + return rt.Output(mailBusinessCollectionPayload("tags", tags)) }, } @@ -279,6 +302,10 @@ var UserSearch = shortcut.Shortcut{ PrimaryCLIPath: "mail +user-search", }, Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", + Parameters: []contract.ParamDecl{ + {Name: "employee-no", Property: "employeeNo"}, + {Name: "limit", Property: "size"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -295,14 +322,30 @@ var UserSearch = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"}, - {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"}, + {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := mailValidatePageSize(rt, "limit", false); err != nil { + return err + } + for _, name := range []string{"keyword", "employee-no"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation("--" + name + " 显式提供时不能为空") + } + } + if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" { + return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值") + } + return nil }, Tips: []string{ `dws mail +user-search --keyword "张三"`, @@ -323,7 +366,7 @@ var UserSearch = shortcut.Shortcut{ params["cursor"] = rt.Str("cursor") } if rt.Changed("limit") { - params["size"] = rt.Str("limit") + params["size"] = fmt.Sprintf("%d", rt.Int("limit")) } data, err := rt.CallMCPData("mail", "search_mail_users", params) if err != nil { @@ -335,11 +378,11 @@ var UserSearch = shortcut.Shortcut{ if err != nil { return err } - complete, next, err := mailPage(data, "mail/search_mail_users", "") + complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(mailCollectionPayload("users", users, complete, next)) + return mailOutputPage(rt, "users", users, complete, next) }, } @@ -370,6 +413,7 @@ var TemplateList = shortcut.Shortcut{ PrimaryCLIPath: "mail +template-list", }, Description: "列出指定邮箱的所有邮件模板", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -384,16 +428,20 @@ var TemplateList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidatePageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +template-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": fmt.Sprintf("%d", rt.Int("limit")), } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -408,11 +456,11 @@ var TemplateList = shortcut.Shortcut{ if err != nil { return err } - complete, next, err := mailPage(data, "mail/list_user_message_templates", "") + complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(mailCollectionPayload("templates", templates, complete, next)) + return mailOutputPage(rt, "templates", templates, complete, next) }, } @@ -443,6 +491,7 @@ var ContactList = shortcut.Shortcut{ PrimaryCLIPath: "mail +contact-list", }, Description: "列出指定邮箱的所有邮件联系人", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -457,16 +506,20 @@ var ContactList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidatePageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +contact-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": fmt.Sprintf("%d", rt.Int("limit")), } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -481,11 +534,11 @@ var ContactList = shortcut.Shortcut{ if err != nil { return err } - complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "") + complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(mailCollectionPayload("contacts", contacts, complete, next)) + return mailOutputPage(rt, "contacts", contacts, complete, next) }, } diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..e91ef9bb --- /dev/null +++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,363 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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" +) + +func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) { + for _, tc := range []struct { + name string + args []string + responses map[string][]string + errors map[string]error + wantErr bool + wantCalls int + }{ + {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1}, + {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1}, + {name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1}, + {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2}, + {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2}, + {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1}, + {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1}, + {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors} + if caller.responses == nil { + caller.responses = map[string][]string{} + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid read unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid read failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + if err := mailValidateMessageIDs(nil); err == nil { + t.Fatal("empty ids unexpectedly valid") + } + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "}) + if err := Messages.Execute(rt); err == nil { + t.Fatal("defensive empty-id guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls)) + } +} + +func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) { + for _, tc := range []struct { + name string + args []string + tool string + response string + remoteErr bool + wantErr bool + }{ + {name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`}, + {name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true}, + {name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true}, + {name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`}, + {name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true}, + {name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true}, + {name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true}, + {name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true}, + {name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true}, + {name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true}, + {name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true}, + {name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true}, + {name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true}, + {name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if tc.remoteErr { + caller.errors[tc.tool] = fmt.Errorf("remote failure") + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid list response unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid list response failed: %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + deferred := false + defer func() { + if recover() == nil { + t.Fatal("unsupported declaration did not panic") + } + deferred = true + }() + fn() + _ = deferred + }) + } + assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") }) + assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") }) + + if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil { + t.Fatal("object accepted missing success") + } + if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" { + t.Fatalf("valid object rejected: %#v %v", object, err) + } + if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil { + t.Fatal("numeric email unexpectedly projected") + } + if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include { + t.Fatalf("blank user id include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include { + t.Fatalf("nil projection include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include { + t.Fatalf("blank projection include=%v err=%v", include, err) + } + if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 { + t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err) + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "malformed cursor": {"nextCursor": 7}, + "malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := mailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if _, ok := mailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } + if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object intermediate lookup unexpectedly succeeded") + } + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) { + for name, message := range map[string]map[string]any{ + "missing addresses": {}, + "wrong address collection": {"toRecipients": "bad"}, + "wrong address item": {"toRecipients": []any{7}}, + "blank address item": {"toRecipients": []any{map[string]any{"email": " "}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil { + t.Fatal("malformed recipients unexpectedly succeeded") + } + }) + } + addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients") + if err != nil || len(addresses) != 2 { + t.Fatalf("valid recipients rejected: %v %v", addresses, err) + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil { + t.Fatal("recipient length mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient value mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient parser error was not propagated") + } + if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil { + t.Fatal("failed draft receipt unexpectedly succeeded") + } + for name, message := range map[string]map[string]any{ + "missing sender": {}, + "wrong sender": {"from": []any{}}, + "blank sender": {"from": " "}, + } { + t.Run(name, func(t *testing.T) { + if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil { + t.Fatal("malformed sender unexpectedly accepted") + } + }) + } + if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil { + t.Fatalf("case-insensitive sender rejected: %v", err) + } + + for _, args := range [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"}, + } { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, args...); err != nil { + t.Fatalf("dry-run failed: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run made %d remote calls", len(caller.calls)) + } + } + + for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "sender@example.invalid", "id": "template-1"} + if declaration.Command == "+draft-edit" { + values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"} + } + rt := mailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatal("execute-level no-change guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("no-change guard made %d calls", len(caller.calls)) + } + } + + for _, tc := range []struct { + name string + readback string + }{ + {name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {tc.readback}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("draft verification mismatch unexpectedly succeeded") + } + }) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":"false"}`}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("failed template readback unexpectedly succeeded") + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + values map[string]string + }{ + {declaration: Message, values: map[string]string{"id": " "}}, + {declaration: FolderList, values: map[string]string{"email": " "}}, + } { + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Validate(rt); err == nil { + t.Fatal("direct required-text validator accepted whitespace") + } + } + rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{}) + if err := UserSearch.Validate(rt); err == nil { + t.Fatal("direct user-search validator accepted missing identities") + } +} + +func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "get_user_message_template": {`{"id":"template-1"}`}, + }, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{}) + if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil { + t.Fatal("failed template detail response unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d want=1", len(caller.calls)) + } +} diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go index 7864ca0f..d9a39dff 100644 --- a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -4,9 +4,14 @@ package mail import ( + "bytes" + "context" + "encoding/json" "testing" + "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/shortcut" ) func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { @@ -14,19 +19,11 @@ func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { rollout output.RolloutState result bool }{ - {ThreadList.OutputRollout, ThreadList.Contract.Result != nil}, {FolderList.OutputRollout, FolderList.Contract.Result != nil}, - {TagList.OutputRollout, TagList.Contract.Result != nil}, {UserSearch.OutputRollout, UserSearch.Contract.Result != nil}, - {TemplateList.OutputRollout, TemplateList.Contract.Result != nil}, - {ContactList.OutputRollout, ContactList.Contract.Result != nil}, {Message.OutputRollout, Message.Contract.Result != nil}, {Messages.OutputRollout, Messages.Contract.Result != nil}, {Thread.OutputRollout, Thread.Contract.Result != nil}, - {DraftCreate.OutputRollout, DraftCreate.Contract.Result != nil}, - {DraftEdit.OutputRollout, DraftEdit.Contract.Result != nil}, - {TemplateCreate.OutputRollout, TemplateCreate.Contract.Result != nil}, - {TemplateUpdate.OutputRollout, TemplateUpdate.Contract.Result != nil}, } for index, declaration := range declarations { if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { @@ -35,6 +32,171 @@ func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { } } +func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailUnavailableReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) { + want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false} + for _, constraint := range DraftEdit.Constraints { + if constraint.Kind != shortcut.ConstraintAtLeastOne { + continue + } + for _, flag := range constraint.Flags { + if _, ok := want[flag]; ok { + want[flag] = true + } + } + } + for flag, found := range want { + if !found { + t.Fatalf("draft edit missing at-least-one declaration for %s", flag) + } + } + if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil { + t.Fatal("messages must declare and execute the 1-100 nonempty ID contract") + } + userSearchNonempty := false + for _, constraint := range UserSearch.Constraints { + if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" { + userSearchNonempty = true + } + } + if !userSearchNonempty { + t.Fatal("user search must declare its explicit nonempty string contract") + } +} + +func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UserSearch} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor pagination declaration", declaration.Command) + } + } + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} { + if declaration.Contract.Pagination != nil { + t.Fatalf("%s must not declare pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) { + for _, tc := range []struct { + name string + complete bool + next string + exhausted bool + }{ + {name: "continuing", complete: false, next: "cursor-2", exhausted: false}, + {name: "terminal", complete: true, next: "", exhausted: true}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil { + t.Fatalf("store pagination: %v", err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + }) + } +} + +func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) { + collections := []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&FolderList, "folders", "id"}, + {&UserSearch, "users", "email"}, + {&Messages, "messages", "id"}, + } + for _, tc := range collections { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + count := properties["count"].(map[string]any) + if count["minimum"] != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"]) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if !ok || identity["description"] == "" { + t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity) + } + } + for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} { + if len(declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", declaration.Command, err) + } + value := schema["properties"].(map[string]any)["value"].(map[string]any) + required := value["required"].([]any) + if len(required) != 1 || required[0] != "id" { + t.Fatalf("%s value does not require id", declaration.Command) + } + } +} + func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) { for _, declaration := range []struct { name string @@ -76,18 +238,72 @@ func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) { } func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) { - complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "") + complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "") if err != nil || !complete || next != "" { t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err) } - complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "") + complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "") if err != nil || !complete || next != "" { t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err) } - if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", ""); err == nil { + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil { t.Fatal("hasMore without a progressing cursor must fail") } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil { + t.Fatal("repeated cursor must fail closed") + } if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil { t.Fatal("identity mismatch must fail") } } + +func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"}, + {name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{tc.user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%v err=%v", rows, err) + } + if _, ok := rows[0][tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0]) + } + if _, ok := rows[0][tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0]) + } + }) + } + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("user without a stable identity unexpectedly projected") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go new file mode 100644 index 00000000..43693973 --- /dev/null +++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go @@ -0,0 +1,394 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "maps" + "strings" + "testing" + + "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 mailWriteCall struct { + tool string + args map[string]any +} + +type mailWriteContractCaller struct { + responses map[string][]string + errors map[string]error + calls []mailWriteCall +} + +func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, fmt.Errorf("unexpected Mail tool call: %s", tool) + } + text := queue[0] + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*mailWriteContractCaller) Format() string { return "json" } +func (*mailWriteContractCaller) DryRun() bool { return false } +func (*mailWriteContractCaller) Fields() string { return "" } +func (*mailWriteContractCaller) JQ() string { return "" } + +func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(append(args, "--yes", "--format", "json")) + return root.Execute() +} + +func mailDraftReadback(id, subject, body, sender, to, cc string) string { + return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc) +} + +func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantTools []string + }{ + { + name: "draft create", + args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"create_draft", "get_email_by_message_id"}, + }, + { + name: "draft edit", + args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"update_draft", "get_email_by_message_id"}, + }, + { + name: "template create core fields", + args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"create_user_message_template", "get_user_message_template"}, + }, + { + name: "template update success plus requested id readback", + args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_user_message_template": {`{"success":true}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"update_user_message_template", "get_user_message_template"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("write execution failed: %v", err) + } + if len(caller.calls) != len(tc.wantTools) { + t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools)) + } + for index, tool := range tc.wantTools { + if caller.calls[index].tool != tool { + t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool) + } + } + }) + } +} + +func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) { + tests := []struct { + name string + writeBody string + readBody string + writeErr error + wantCalls int + }{ + {name: "empty write body", writeBody: "", wantCalls: 1}, + {name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1}, + {name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1}, + {name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2}, + {name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: map[string]error{"create_draft": tc.writeErr}, + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if err == nil { + t.Fatal("invalid receipt/readback unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantCalls int + }{ + {name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1}, + {name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1}, + {name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1}, + {name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid write unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + args []string + tool string + response string + }{ + {name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + {name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor was not passed through: %#v", caller.calls) + } + }) + } + caller := &mailWriteContractCaller{ + responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}, + errors: map[string]error{}, + } + if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) { + tooManyIDs := make([]string, 101) + for index := range tooManyIDs { + tooManyIDs[index] = fmt.Sprintf("message-%d", index) + } + for _, tc := range []struct { + name string + args []string + }{ + {name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}}, + {name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}}, + {name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}}, + {name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}}, + {name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}}, + {name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}}, + {name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}}, + {name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}}, + {name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}}, + {name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}}, + {name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}}, + {name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}}, + {name: "message empty id", args: []string{"mail", "+message", "--id", " "}}, + {name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}}, + {name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input reached remote calls: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) { + goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}` + goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid") + for _, tc := range []struct { + name string + writeBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1}, + {name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1}, + {name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1}, + {name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2}, + {name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2}, + {name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: tc.errors, + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatalf("happy control: %v", err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + createBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1}, + {name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1}, + {name: "missing id", createBody: `{"success":true}`, wantCalls: 1}, + {name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + updateBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", updateBody: `{}`, wantCalls: 1}, + {name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1}, + {name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go index edf688c1..e41cd2f1 100644 --- a/internal/shortcut/mail/reads.go +++ b/internal/shortcut/mail/reads.go @@ -57,9 +57,13 @@ var Message = shortcut.Shortcut{ Risk: shortcut.RiskRead, Safety: mailReadSafety(), Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`), Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, Execute: func(rt *shortcut.RuntimeContext) error { email, err := mailResolveMailbox(rt) if err != nil { @@ -80,14 +84,18 @@ var Messages = shortcut.Shortcut{ Risk: shortcut.RiskRead, Safety: mailReadSafety(), Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`), Flags: []shortcut.Flag{ - {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,最多 100 个"}, + {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"ids"}, + Description: "1-100 个且不能为空", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateMessageIDs(rt.StrSlice("ids")) + }, Execute: func(rt *shortcut.RuntimeContext) error { ids := rt.StrSlice("ids") - if len(ids) == 0 || len(ids) > 100 { - return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") - } email, err := mailResolveMailbox(rt) if err != nil { return err @@ -104,10 +112,22 @@ var Messages = shortcut.Shortcut{ } messages = append(messages, message) } - return rt.Output(mailCollectionPayload("messages", messages, true, "")) + return rt.Output(mailBusinessCollectionPayload("messages", messages)) }, } +func mailValidateMessageIDs(ids []string) error { + if len(ids) == 0 || len(ids) > 100 { + return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") + } + for index, id := range ids { + if strings.TrimSpace(id) == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + } + return nil +} + var Thread = shortcut.Shortcut{ OutputRollout: output.RolloutUnifiedActive, Service: "mail", Command: "+thread", Product: "mail", @@ -115,9 +135,13 @@ var Thread = shortcut.Shortcut{ Risk: shortcut.RiskRead, Safety: mailReadSafety(), Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`), Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, Execute: func(rt *shortcut.RuntimeContext) error { email, err := mailResolveMailbox(rt) if err != nil { diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go index 9484776e..63989e89 100644 --- a/internal/shortcut/mail/writes.go +++ b/internal/shortcut/mail/writes.go @@ -5,6 +5,7 @@ package mail import ( "fmt" + "sort" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -25,6 +26,87 @@ func mailRecipients(values []string) []string { return out } +func mailNormalizedAddresses(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized != "" { + seen[normalized] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) { + raw, present := message[field] + if !present { + return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field)) + } + items, ok := raw.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field)) + } + addresses := make([]string, 0, len(items)) + for index, item := range items { + var address string + switch typed := item.(type) { + case string: + address = typed + case map[string]any: + address = mailFirstString(typed, "email", "address") + default: + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index)) + } + if strings.TrimSpace(address) == "" { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index)) + } + addresses = append(addresses, address) + } + return mailNormalizedAddresses(addresses), nil +} + +func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error { + actual, err := mailReadAddresses(message, operation, field) + if err != nil { + return err + } + expected := mailNormalizedAddresses(requested) + if len(actual) != len(expected) { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + for index := range expected { + if actual[index] != expected[index] { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + } + return nil +} + +func mailVerifySender(message map[string]any, operation, requested string) error { + raw, present := message["from"] + if !present { + return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from") + } + var actual string + switch typed := raw.(type) { + case string: + actual = typed + case map[string]any: + actual = mailFirstString(typed, "email", "address") + default: + return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效") + } + if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) { + return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致") + } + return nil +} + func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) { if err := mailRequireSuccess(data, operation); err != nil { return nil, err @@ -92,6 +174,7 @@ var DraftCreate = shortcut.Shortcut{ {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"}, {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")} if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 { @@ -125,6 +208,19 @@ var DraftCreate = shortcut.Shortcut{ if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致") } + if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } return rt.Output(map[string]any{"value": verified}) }, } @@ -143,6 +239,7 @@ var DraftEdit = shortcut.Shortcut{ {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, Execute: func(rt *shortcut.RuntimeContext) error { if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") { return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一") @@ -184,6 +281,19 @@ var DraftEdit = shortcut.Shortcut{ if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致") } + if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } return rt.Output(map[string]any{"value": verified}) }, } @@ -193,20 +303,15 @@ var TemplateCreate = shortcut.Shortcut{ Service: "mail", Command: "+template-create", Product: "mail", Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), - Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "from", Property: "from"}, {Name: "body", Property: "body"}, {Name: "draft", Property: "isDraft"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), + Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, - {Name: "from", Type: shortcut.FlagString, Desc: "模板发件邮箱"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"}, {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"}, {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"}, - {Name: "draft", Type: shortcut.FlagBool, Desc: "创建可编辑的草稿模板"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body"), "isDraft": rt.Bool("draft")} - if rt.Changed("from") { - params["from"] = rt.Str("from") - } + params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")} if rt.DryRun() { return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}}) } @@ -245,6 +350,7 @@ var TemplateUpdate = shortcut.Shortcut{ {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}}, Execute: func(rt *shortcut.RuntimeContext) error { if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") { return apperrors.NewValidation("至少指定 --name、--subject、--body 之一") @@ -283,5 +389,9 @@ var TemplateUpdate = shortcut.Shortcut{ } func init() { + markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate) } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 139840f6..bbdd1bfe 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -102,20 +102,14 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-checkin-record": {}, "attendance\u0000+get-class": {}, - "attendance\u0000+get-leave-records": {}, "attendance\u0000+get-overtime-rule": {}, "attendance\u0000+get-schedule": {}, "attendance\u0000+get-self-setting": {}, - "attendance\u0000+get-summary": {}, "attendance\u0000+list-approve": {}, - "attendance\u0000+list-leave-types": {}, - "attendance\u0000+my-attendance": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, "attendance\u0000+search-overtime-rule": {}, - "attendance\u0000+this-month": {}, "calendar\u0000+agenda": {}, "calendar\u0000+attendee-list": {}, "calendar\u0000+book": {}, @@ -351,23 +345,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "drive\u0000+version-get": {}, "drive\u0000+version-history": {}, "drive\u0000+version-revert": {}, - "mail\u0000+contact-list": {}, - "mail\u0000+draft-create": {}, - "mail\u0000+draft-edit": {}, "mail\u0000+find-mail-user": {}, "mail\u0000+folder-list": {}, "mail\u0000+message": {}, "mail\u0000+messages": {}, - "mail\u0000+recent-mail": {}, "mail\u0000+search-mail": {}, - "mail\u0000+tag-list": {}, - "mail\u0000+template-create": {}, - "mail\u0000+template-list": {}, - "mail\u0000+template-update": {}, "mail\u0000+thread": {}, - "mail\u0000+thread-list": {}, "mail\u0000+triage": {}, - "mail\u0000+unread-mail": {}, "mail\u0000+user-search": {}, "minutes\u0000+action-items": {}, "minutes\u0000+apply-permission": {}, diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go index 6d1a64df..41a5c3c2 100644 --- a/internal/shortcut/responsecheck/responsecheck_test.go +++ b/internal/shortcut/responsecheck/responsecheck_test.go @@ -15,6 +15,7 @@ func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) { {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1}, {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1}, {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0}, + {name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true}, {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true}, {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true}, {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true}, @@ -72,13 +73,89 @@ func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) { } } for _, data := range []map[string]any{ + {}, {"success": true, "result": map[string]any{}}, {"success": true, "result": []any{}}, {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}}, {"success": true, "result": []any{"bad"}}, + {"success": true, "result": "bad"}, } { if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil { t.Fatalf("ambiguous/malformed detail returned success: %v", object) } } } + +func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing success", data: map[string]any{"result": map[string]any{}}}, + {name: "malformed success", data: map[string]any{"success": "true"}}, + {name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}}, + {name: "failure without message", data: map[string]any{"success": false, "error": 42}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil { + t.Fatal("expected fail-closed envelope validation") + } + }) + } + + if _, err := RequireResult(map[string]any{}, "test/result"); err == nil { + t.Fatal("expected upstream envelope failure") + } + if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil { + t.Fatal("expected missing result failure") + } + if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" { + t.Fatalf("valid object result rejected: object=%v err=%v", object, err) + } + if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil { + t.Fatal("expected upstream object envelope failure") + } +} + +func TestCrossPlatformCoverageLookupObject(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "detail": map[string]any{"id": "one"}, + "scalar": "bad", + }, + } + object, err := LookupObject(data, "test/lookup", "result.detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid nested object rejected: object=%v err=%v", object, err) + } + if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil { + t.Fatal("expected missing nested object failure") + } + if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil { + t.Fatal("expected malformed nested object failure") + } + if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present { + t.Fatal("scalar parent must not satisfy a nested lookup") + } +} + +func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) { + items, err := RequireObjectCollection( + map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}}, + "test/fallback", + "result.records", + "result.items", + ) + if err != nil || len(items) != 1 { + t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err) + } + if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil { + t.Fatal("expected collection envelope failure") + } + if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" { + t.Fatalf("firstString = %q, want value", got) + } + if got := firstString(map[string]any{"only": " "}, "only"); got != "" { + t.Fatalf("firstString whitespace = %q, want empty", got) + } +} diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json index 7c8716fe..a487b60d 100644 --- a/internal/shortcut/semantic_catalog_attendance.json +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -3,23 +3,23 @@ "service": "attendance", "default_availability": "unavailable", "shortcuts": { - "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query 的打卡结果任务;严格验证 success 与显式 result 数组,并公开 offset/limit 的完整性和 nextOffset 证据。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,缺字段、错型或坏记录均失败。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举并严格验证审批数组;真实非空与空结果均已执行。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"规范日期范围并严格验证排班数组;缺失或畸形响应不再成为空排班。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求稳定班次 ID,并公开 page/totalCount 完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-class": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的班次 ID 读取详情,要求显式成功和非空 result;已用同一真实班次完成搜索到详情闭环。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的班次 ID 读取详情,要求显式成功、非空 result,且 shiftVO.id 与请求 classId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result;真实搜索到详情闭环已通过。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-summary": {"disposition":"schema_leaf","semantic_delta":"按周或月读取个人考勤摘要,要求显式业务成功和非空 result。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"按用户和设置场景读取个人考勤开关,要求显式业务成功和非空 result。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"严格验证显式假期类型数组,为假期编码的后续读取提供可验证入口。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"按用户、假期编码和日期读取余额变更记录;显式空数组合法,缺失结果或坏记录失败。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.list 签到数组,区分外勤签到与考勤打卡流水。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+my-attendance": {"disposition":"primary_smart","semantic_delta":"自动解析当前用户并查询今天的打卡流水,最终仍按严格 success/result 数组契约输出。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+this-month": {"disposition":"primary_smart","semantic_delta":"自动解析当前用户并查询本月打卡流水,最终仍按严格 success/result 数组契约输出。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"按归一化 userId 和场景读取个人考勤设置;响应用户必须精确匹配,场景字段必须非空并符合已观测 object/boolean/integer 类型。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json index 93bff2ad..4c1219a6 100644 --- a/internal/shortcut/semantic_catalog_mail.json +++ b/internal/shortcut/semantic_catalog_mail.json @@ -5,21 +5,21 @@ "shortcuts": { "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+unread-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+recent-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+draft-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。","risk":"write","availability":"available","public":true,"reviewed":true}, - "+draft-edit": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。","risk":"write","availability":"available","public":true,"reviewed":true}, - "+template-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。","risk":"write","availability":"available","public":true,"reviewed":true}, - "+template-update": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。","risk":"write","availability":"available","public":true,"reviewed":true} + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true} } } diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go index 40ecf569..12ebddf8 100644 --- a/internal/shortcut/smart/attendance_strict.go +++ b/internal/shortcut/smart/attendance_strict.go @@ -5,6 +5,8 @@ package smart import ( "encoding/json" + "fmt" + "strconv" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -23,9 +25,38 @@ func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string] if err != nil { return err } + seen := make(map[int64]struct{}, len(records)) + for index, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index)) + } + seen[identity] = struct{}{} + } return rt.Output(map[string]any{ "count": len(records), "records": records, "complete": true, }) } + +func smartAttendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go index 12b62f4d..c942339b 100644 --- a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -4,25 +4,241 @@ package smart import ( + "context" + "encoding/json" + "fmt" + "strings" "testing" + "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/internal/shortcut/responsecheck" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" ) -func TestCrossPlatformCoverageSmartAttendanceContractsAreUnifiedAndTyped(t *testing.T) { +func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { for _, declaration := range []struct { - name string - rollout output.RolloutState - hasResult bool + name string + rollout output.RolloutState + hasResult bool + availability string }{ - {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil}, - {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil}, + {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability}, + {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability}, } { - if declaration.rollout != output.RolloutUnifiedActive { - t.Errorf("%s rollout = %q, want unified_active", declaration.name, declaration.rollout) + if declaration.rollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout) } - if !declaration.hasResult { - t.Errorf("%s must publish a Result declaration", declaration.name) + if declaration.hasResult || declaration.availability != "unavailable" { + t.Errorf("%s must omit Result and publish unavailable", declaration.name) } } } + +func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}} + if got := strictAttendanceCurrentUserID(valid); got != "u1" { + t.Fatalf("valid profile identity = %q", got) + } + invalid := []map[string]any{ + nil, + {"result": map[string]any{"userId": "stale"}}, + {"success": false, "result": map[string]any{"userId": "stale"}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +type smartAttendanceProfileCaller struct { + profile string + attendance string + profileErr error + attendanceErr error + calls []string + arguments []map[string]any +} + +func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, server+"/"+tool) + c.arguments = append(c.arguments, arguments) + text := c.profile + callErr := c.profileErr + if len(c.calls) > 1 { + text = c.attendance + callErr = c.attendanceErr + } + if callErr != nil { + return nil, callErr + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} +func (*smartAttendanceProfileCaller) Format() string { return "json" } +func (*smartAttendanceProfileCaller) DryRun() bool { return false } +func (*smartAttendanceProfileCaller) Fields() string { return "" } +func (*smartAttendanceProfileCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) { + caller := &smartAttendanceProfileCaller{} + helpers.InitDepsForTest(t, caller) + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + caller.calls = nil + caller.arguments = nil + caller.profile = `{"success":false,"result":{"userId":"stale"}}` + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil { + t.Errorf("%s accepted success=false profile", declaration.Command) + } + if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") { + t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) { + valid := []map[string]any{ + {"success": true, "result": map[string]any{"userId": " u1 "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}, + } + for index, profile := range valid { + if got := strictAttendanceCurrentUserID(profile); got != "u1" { + t.Errorf("valid profile %d resolved %q", index, got) + } + } + invalid := []map[string]any{ + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"userId": 1}}, + {"success": true, "result": map[string]any{"userId": " "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}}, + {"success": true, "result": true}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": ""}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } { + if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil { + seen := map[int64]struct{}{} + valid := true + for _, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + valid = false + break + } + if _, duplicate := seen[identity]; duplicate { + valid = false + break + } + seen[identity] = struct{}{} + } + if valid { + t.Fatalf("invalid records accepted: %#v", data) + } + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) { + result := attendanceRecordsResult() + if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) { + t.Fatalf("invalid strict attendance result declaration: %#v", result) + } + + for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} { + if _, ok := smartAttendancePositiveInteger(value); !ok { + t.Errorf("valid record identity rejected: %#v", value) + } + } + for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} { + if _, ok := smartAttendancePositiveInteger(value); ok { + t.Errorf("invalid record identity accepted: %#v", value) + } + } + + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{}) + invalid := []map[string]any{ + nil, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } + for index, data := range invalid { + if err := outputStrictAttendanceRecords(rt, data); err == nil { + t.Errorf("invalid strict output %d accepted", index) + } + } + if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil { + t.Fatalf("valid strict output rejected: %v", err) + } +} + +func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) { + validProfile := `{"success":true,"result":{"userId":"u1"}}` + validAttendance := `{"success":true,"result":[{"id":1}]}` + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + t.Run(declaration.Command+" success", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") { + t.Fatalf("calls=%#v", caller.calls) + } + request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any) + users, usersOK := request["userIds"].([]string) + if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" { + t.Fatalf("request=%#v", caller.arguments[1]) + } + }) + t.Run(declaration.Command+" profile call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" attendance call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" malformed attendance", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + } +} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 824ca738..69d6d3f3 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -15,6 +15,7 @@ package smart import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -70,6 +71,11 @@ var FindMailUser = shortcut.Shortcut{ PrimaryCLIPath: "mail +find-mail-user", }, Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", + Parameters: []contract.ParamDecl{ + {Name: "query", Property: "keyword"}, + {Name: "limit", Property: "size"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -86,8 +92,19 @@ var FindMailUser = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +find-mail-user --query "张三"`, @@ -100,41 +117,109 @@ var FindMailUser = shortcut.Shortcut{ if rt.Changed("limit") { args["size"] = strconv.Itoa(rt.Int("limit")) } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } data, err := rt.CallMCPData("mail", "search_mail_users", args) if err != nil { return err } // Step 2 — project matched users. - users, err := smartMailRows(data, "mail/search_mail_users", "users", "id", "email") + users, err := smartMailCollection(data, "mail/search_mail_users", "users") if err != nil { return err } results := make([]map[string]any, 0, len(users)) for _, u := range users { - results = append(results, map[string]any{ - "name": findMailUserString(u, "name", "displayName", "userName", "nick"), - "nickname": findMailUserString(u, "nickname", "nick", "displayName"), - "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"), - "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"), - "jobTitle": findMailUserString(u, "jobTitle", "title", "position"), - "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"), - "id": findMailUserAny(u, "id", "userId", "userid"), - }) + projected, err := findMailUserProjection(u) + if err != nil { + return err + } + results = append(results, projected) } - complete, next, err := smartMailPage(data, "mail/search_mail_users", "") + complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(smartMailPayload("users", results, complete, next)) + return smartMailOutputPage(rt, "users", results, complete, next) }, } +func findMailUserProjection(user map[string]any) (map[string]any, error) { + result := map[string]any{ + "name": findMailUserString(user, "name", "displayName", "userName", "nick"), + "nickname": findMailUserString(user, "nickname", "nick", "displayName"), + "employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"), + "jobTitle": findMailUserString(user, "jobTitle", "title", "position"), + "workLocation": findMailUserString(user, "workLocation", "location", "workPlace"), + } + email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account") + if err != nil { + return nil, err + } + if emailPresent { + result["email"] = email + } + id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid") + if err != nil { + return nil, err + } + if idPresent { + result["id"] = id + } + if !emailPresent && !idPresent { + return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email") + } + return result, nil +} + +func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + text, ok := value.(string) + if !ok { + return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil + } + } + return "", false, nil +} + +func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil + } + continue + } + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return value, true, nil + default: + return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字") + } + } + return nil, false, nil +} + func findMailUserString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -143,8 +228,11 @@ func findMailUserString(m map[string]any, keys ...string) string { func findMailUserAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..aa17517e --- /dev/null +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,316 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "fmt" + "io" + "reflect" + "testing" + + "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/shortcut" +) + +func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} { + rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "}) + if err := declaration.Validate(rt); err == nil { + t.Fatal("direct required query validator accepted whitespace") + } + } + if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil { + t.Fatal("empty response unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil { + t.Fatal("explicit failure unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil { + t.Fatalf("string success rejected: %v", err) + } + if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object lookup unexpectedly succeeded") + } + if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted invalid response") + } + if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted missing identity") + } + + items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty search rejected: %v %v", items, err) + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("negative total unexpectedly succeeded") + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("search row without id unexpectedly succeeded") + } + if smartMailEmptySearchSentinel(map[string]any{}) { + t.Fatal("empty object treated as reviewed sentinel") + } + if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) { + t.Fatal("reviewed null-only sentinel rejected") + } + + for _, tc := range []struct { + value any + want int + ok bool + }{ + {value: 7, want: 7, ok: true}, + {value: float64(8), want: 8, ok: true}, + {value: 1.5, ok: false}, + {value: "9", want: 9, ok: true}, + {value: []any{}, ok: false}, + } { + got, ok := smartMailInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "wrong cursor": {"nextCursor": 7}, + "wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting true": {"nextCursor": "", "hasMore": true}, + "conflicting false": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" { + t.Fatalf("terminal page rejected: %v %q %v", complete, next, err) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing page rejected: %v %q %v", complete, next, err) + } + if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := smartMailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if value, ok := smartMailBool("false"); !ok || value { + t.Fatal("string false not normalized") + } + if _, ok := smartMailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } +} + +func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unsupported schema did not panic") + } + }() + fn() + }) + } + assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") }) + assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") }) + + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + message map[string]any + want any + }{ + {message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender "}, + {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, + {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{}, want: ""}, + } { + got, err := searchMailFrom(tc.message) + if err != nil || got != tc.want { + t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) + } + } + for _, message := range []map[string]any{{"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + if _, err := searchMailFrom(message); err == nil { + t.Fatal("malformed sender unexpectedly succeeded") + } + } + if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" { + t.Fatalf("string normalization=%q", got) + } + if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" { + t.Fatalf("any string normalization=%#v", got) + } + if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 { + t.Fatalf("numeric projection=%#v", got) + } + + projected, err := findMailUserProjection(map[string]any{ + "id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ", + "employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ", + }) + if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" { + t.Fatalf("normalized user projection=%#v err=%v", projected, err) + } + if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 { + t.Fatalf("find user any=%#v", got) + } + if got := findMailUserAny(map[string]any{}, "missing"); got != nil { + t.Fatalf("missing optional value=%#v", got) + } +} + +func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + errors map[string]error + wantCalls int + }{ + {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, + {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1}, + {name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1}, + {name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1}, + {name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "triage sender malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1}, + {name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1}, + {name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1}, + {name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1}, + {name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid leaf response unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { + for _, tc := range []struct { + name string + message map[string]any + want any + wantErr bool + }{ + {name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender "}}, + {name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}}, + {name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}}, + {name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true}, + {name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := recentMailSenders(tc.message) + if tc.wantErr && err == nil { + t.Fatal("malformed senders unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid senders failed: %v", err) + } + if !tc.wantErr && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("senders=%#v want=%#v", got, tc.want) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + wantErr bool + wantCalls int + }{ + { + name: "triage resolves mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{ + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`, + }, wantCalls: 2, + }, + { + name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`}, + wantErr: true, wantCalls: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses} + err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid inbox resolution unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid inbox resolution failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go index d8fb916e..cf7c3e63 100644 --- a/internal/shortcut/smart/mail_strict.go +++ b/internal/shortcut/smart/mail_strict.go @@ -25,6 +25,24 @@ func smartMailError(operation, reason, message string) error { ) } +func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + func smartMailSuccess(data map[string]any, operation string) error { if len(data) == 0 { return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空") @@ -157,7 +175,7 @@ func smartMailInt(value any) (int, bool) { } } -func smartMailPage(data map[string]any, operation, prefix string) (bool, string, error) { +func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { container := data if prefix != "" { value, present := smartMailLookup(data, prefix) @@ -187,11 +205,17 @@ func smartMailPage(data map[string]any, operation, prefix string) (bool, string, if !hasMore { return true, "", nil } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } return false, next, nil } if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { return true, "", nil } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } return false, next, nil } @@ -211,22 +235,72 @@ func smartMailBool(value any) (bool, bool) { } func smartMailResult(collection, description string) *contract.ResultSpec { + identity := smartMailIdentitySchema(collection) return &contract.ResultSpec{ - Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录","additionalProperties":true}},"complete":{"type":"boolean","description":"分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`, - description, collection, description, collection)), + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: smartMailSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection)), + } +} + +func smartMailSensitivePaths(collection string) []string { + switch collection { + case "messages": + return []string{"messages.subject", "messages.from", "messages.date"} + case "mails": + return []string{"mails.subject", "mails.from", "mails.date"} + case "users": + return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"} + default: + panic("unsupported smart Mail sensitive paths: " + collection) + } +} + +func smartMailIdentitySchema(collection string) string { + switch collection { + case "messages": + return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]` + case "mails": + return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]` + case "users": + return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]` + default: + panic("unsupported smart Mail Result identity: " + collection) + } +} + +func smartMailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, } } func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) { declaration.OutputRollout = output.RolloutUnifiedActive declaration.Contract.Result = smartMailResult(collection, description) + declaration.Contract.Pagination = smartMailCursorPagination() declaration.Contract.Interface = &contract.InterfaceSpec{ Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.", } } +func markSmartMailUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} + func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { payload := map[string]any{"count": len(items), collection: items, "complete": complete} if !complete { @@ -234,3 +308,17 @@ func smartMailPayload(collection string, items []map[string]any, complete bool, } return payload } + +func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := smartMailPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return smartMailError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go index 1b2e3ce1..a88bcde6 100644 --- a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -4,11 +4,61 @@ package smart import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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" ) +type smartMailCursorCall struct { + tool string + args map[string]any +} + +type smartMailCursorCaller struct { + responses map[string]string + errors map[string]error + calls []smartMailCursorCall +} + +func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + text, ok := caller.responses[tool] + if !ok { + return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool) + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*smartMailCursorCaller) Format() string { return "json" } +func (*smartMailCursorCaller) DryRun() bool { return false } +func (*smartMailCursorCaller) Fields() string { return "" } +func (*smartMailCursorCaller) JQ() string { return "" } + +func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) { for _, declaration := range []struct { name string @@ -16,15 +66,53 @@ func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) result bool }{ {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil}, - {"unread", UnreadMail.OutputRollout, UnreadMail.Contract.Result != nil}, {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil}, - {"recent", RecentMail.OutputRollout, RecentMail.Contract.Result != nil}, {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil}, } { if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { t.Fatalf("%s is not unified with Result", declaration.name) } } + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailUnavailableReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailDefaultsAreDeclared(t *testing.T) { + for _, tc := range []struct { + declaration *shortcut.Shortcut + flag string + }{ + {&SearchMail, "size"}, + {&UnreadMail, "size"}, + {&RecentMail, "limit"}, + } { + found := false + for _, flag := range tc.declaration.Flags { + if flag.Name == tc.flag { + found = true + if flag.Default != "20" { + t.Fatalf("%s --%s default=%q, want 20", tc.declaration.Command, tc.flag, flag.Default) + } + } + } + if !found { + t.Fatalf("%s missing --%s", tc.declaration.Command, tc.flag) + } + } } func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) { @@ -71,3 +159,190 @@ func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) { t.Fatal("sender without identity must fail") } } + +func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) { + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil { + t.Fatal(err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into business data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + + for _, tc := range []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&SearchMail, "messages", "messageId"}, + {&TriageMail, "messages", "messageId"}, + {&FindMailUser, "users", "email"}, + } { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if identity["description"] == "" { + t.Fatalf("%s missing stable identity", tc.declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor not passed through: %#v", caller.calls) + } + }) + } + caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}}, + {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}}, + {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}}, + {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}}, + {name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}}, + {name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}}, + {name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid limit reached remote: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"}, + {name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + projected, err := findMailUserProjection(tc.user) + if err != nil { + t.Fatal(err) + } + if _, ok := projected[tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, projected) + } + if _, ok := projected[tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected) + } + }) + } + + caller := &smartMailCursorCaller{responses: map[string]string{ + "search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`, + }} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil { + t.Fatal("identity-free user unexpectedly satisfied runtime Result contract") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := findMailUserProjection(user); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("whitespace stable identity unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want one response validation call", len(caller.calls)) + } + }) + } +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 54d1bf76..536da856 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -14,6 +14,7 @@ package smart import ( + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -42,7 +43,7 @@ import ( // // dws attendance +my-attendance var MyAttendance = shortcut.Shortcut{ - OutputRollout: output.RolloutUnifiedActive, + OutputRollout: output.RolloutLegacyOnly, Service: "attendance", Command: "+my-attendance", Product: "attendance", @@ -66,8 +67,8 @@ var MyAttendance = shortcut.Shortcut{ 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.", + Availability: "unavailable", + Reason: "当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法排除 exact Shortcut 的空结果假阳性。", }, Selection: contract.SelectionSpec{ AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -75,7 +76,6 @@ var MyAttendance = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws attendance +my-attendance"}, }, - Result: attendanceRecordsResult(), }, Flags: []shortcut.Flag{}, Tips: []string{ @@ -87,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -122,6 +122,80 @@ var MyAttendance = shortcut.Shortcut{ // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} // - {"result": {"userId": ...}} +func strictAttendanceCurrentUserID(data map[string]any) string { + if data == nil { + return "" + } + success, ok := data["success"].(bool) + if !ok || !success { + return "" + } + identities := map[string]struct{}{} + collect := func(item map[string]any) bool { + var direct, nested string + if value, present := item["userId"]; present { + text, valid := value.(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + direct = strings.TrimSpace(text) + } + if value, present := item["orgEmployeeModel"]; present { + object, valid := value.(map[string]any) + if !valid || object == nil { + return false + } + text, valid := object["userId"].(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + nested = strings.TrimSpace(text) + } + if direct == "" && nested == "" { + return false + } + if direct != "" && nested != "" && direct != nested { + return false + } + if direct != "" { + identities[direct] = struct{}{} + } else { + identities[nested] = struct{}{} + } + return true + } + switch result := data["result"].(type) { + case []any: + if len(result) == 0 { + return "" + } + for _, item := range result { + m, ok := item.(map[string]any) + if !ok || !collect(m) { + return "" + } + } + case map[string]any: + if !collect(result) { + return "" + } + default: + return "" + } + if len(identities) != 1 { + return "" + } + var identity string + for candidate := range identities { + identity = candidate + } + return identity +} + +// myAttendanceCurrentUserID preserves the compatibility resolver used by +// older non-Attendance smart workflows. New Attendance shortcuts must use the +// strict resolver above so a missing business success flag cannot consume a +// stale result. func myAttendanceCurrentUserID(data map[string]any) string { if data == nil { return "" diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index affeaf0f..dbcab65f 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -15,6 +15,7 @@ package smart import ( "fmt" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -73,6 +74,12 @@ var RecentMail = shortcut.Shortcut{ PrimaryCLIPath: "mail +recent-mail", }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", + Parameters: []contract.ParamDecl{ + {Name: "limit", Property: "size"}, + {Name: "email", Property: "email"}, + {Name: "folder", Property: "folderId"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -89,9 +96,14 @@ var RecentMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +recent-mail`, @@ -121,18 +133,15 @@ var RecentMail = shortcut.Shortcut{ // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. - size := rt.Int("limit") - if size <= 0 { - size = 20 - } - if size > 100 { - size = 100 - } - data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{ + args := map[string]any{ "email": email, "folderId": folderID, - "size": size, - }) + "size": rt.Int("limit"), + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", args) if err != nil { return err } @@ -156,11 +165,11 @@ var RecentMail = shortcut.Shortcut{ }) } - complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result") + complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) if err != nil { return err } - return rt.Output(smartMailPayload("mails", results, complete, next)) + return smartMailOutputPage(rt, "mails", results, complete, next) }, } @@ -182,7 +191,7 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e "收件箱": true, "inbox": true, } for _, f := range folders { - name := searchMailFirstString(f, "displayName", "name", "folderName") + name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName"))) if inboxNames[name] { if id := searchMailFirstString(f, "id", "folderId"); id != "" { return id, nil @@ -230,5 +239,6 @@ func recentMailSenders(t map[string]any) (any, error) { func init() { hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") + markSmartMailUnavailable(&RecentMail, "当前租户全部可见文件夹均有会话,且本入口没有可控零命中筛选;缺少已验证空文件夹 fixture。") shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index f2fe84ec..9685847b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -15,6 +15,8 @@ package smart import ( "fmt" + "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -81,11 +83,21 @@ var SearchMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "返回条数上限(可选,默认 20,范围 1-100)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidatePageSize(rt, "size", false) + }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, `dws mail +search-mail --query "from:alice AND date>2025-06-01T00:00:00Z"`, @@ -108,14 +120,10 @@ var SearchMail = shortcut.Shortcut{ // Step 2 — search that mailbox. email/query/size mirror the search_emails // call in helpers.messageSearch; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } args := map[string]any{ "email": email, "query": rt.Str("query"), - "size": size, + "size": strconv.Itoa(rt.Int("size")), } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") @@ -143,11 +151,11 @@ var SearchMail = shortcut.Shortcut{ "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - complete, next, err := smartMailPage(data, "mail/search_emails", "") + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(smartMailPayload("messages", out, complete, next)) + return smartMailOutputPage(rt, "messages", out, complete, next) }, } @@ -206,8 +214,8 @@ func searchMailFrom(m map[string]any) (any, error) { func searchMailFirstString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -216,8 +224,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string { func searchMailFirstAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index 461873d8..e3c37b38 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -42,7 +42,7 @@ import ( // // dws attendance +this-month var ThisMonthAttendance = shortcut.Shortcut{ - OutputRollout: output.RolloutUnifiedActive, + OutputRollout: output.RolloutLegacyOnly, Service: "attendance", Command: "+this-month", Product: "attendance", @@ -66,8 +66,8 @@ var ThisMonthAttendance = shortcut.Shortcut{ 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.", + Availability: "unavailable", + Reason: "当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法排除 exact Shortcut 的空结果假阳性。", }, Selection: contract.SelectionSpec{ AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -75,7 +75,6 @@ var ThisMonthAttendance = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws attendance +this-month"}, }, - Result: attendanceRecordsResult(), }, Flags: []shortcut.Flag{}, Tips: []string{ @@ -87,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go index bd2ca713..7f32ee13 100644 --- a/internal/shortcut/smart/triage_mail.go +++ b/internal/shortcut/smart/triage_mail.go @@ -4,6 +4,8 @@ package smart import ( + "strconv" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -37,9 +39,13 @@ var TriageMail = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"}, {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页摘要数量"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", true) + }, Execute: func(rt *shortcut.RuntimeContext) error { email := rt.Str("email") if email == "" { @@ -57,7 +63,7 @@ var TriageMail = shortcut.Shortcut{ } query = "folderId:" + folderID } - args := map[string]any{"email": email, "query": query, "size": rt.Str("limit")} + args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))} if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") } @@ -82,11 +88,11 @@ var TriageMail = shortcut.Shortcut{ "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"), }) } - complete, next, err := smartMailPage(data, "mail/search_emails", "") + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(smartMailPayload("messages", rows, complete, next)) + return smartMailOutputPage(rt, "messages", rows, complete, next) }, } diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index 05d2d7db..51ac67cb 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -14,6 +14,8 @@ package smart import ( + "strconv" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -80,9 +82,13 @@ var UnreadMail = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "返回条数上限(可选,默认 20,范围 1-100)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "size", false) + }, Tips: []string{ `dws mail +unread-mail`, `dws mail +unread-mail --email user@company.com`, @@ -102,14 +108,10 @@ var UnreadMail = shortcut.Shortcut{ // Step 2 — search unread mail. email/query/size mirror the search_emails // call in helpers.messageSearch; the isRead:false KQL filter matches the // isRead field documented there; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } args := map[string]any{ "email": email, "query": "isRead:false", - "size": size, + "size": strconv.Itoa(rt.Int("size")), } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") @@ -137,15 +139,16 @@ var UnreadMail = shortcut.Shortcut{ "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - complete, next, err := smartMailPage(data, "mail/search_emails", "") + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) if err != nil { return err } - return rt.Output(smartMailPayload("messages", out, complete, next)) + return smartMailOutputPage(rt, "messages", out, complete, next) }, } func init() { hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") + markSmartMailUnavailable(&UnreadMail, "固定 isRead:false 查询没有可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态来制造零命中。") shortcut.Register(UnreadMail) } From 69540c3372df3662b7738aea5084705fd6e7414a Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 20:08:57 +0800 Subject: [PATCH 150/267] fix(mail): preserve shortcut query schema property --- internal/shortcut/smart/find_mail_user.go | 4 +++- .../mail_strict_cross_platform_coverage_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 69d6d3f3..038f470e 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -72,7 +72,9 @@ var FindMailUser = shortcut.Shortcut{ }, Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", Parameters: []contract.ParamDecl{ - {Name: "query", Property: "keyword"}, + // Keep the published composite Shortcut property stable. Execute owns + // the explicit query -> keyword adapter for search_mail_users. + {Name: "query", Property: "query"}, {Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, }, diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go index a88bcde6..e24ce128 100644 --- a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -346,3 +346,15 @@ func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing. }) } } + +func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) { + for _, parameter := range FindMailUser.Contract.Parameters { + if parameter.Name == "query" { + if parameter.Property != "query" { + t.Fatalf("query property = %q, want stable composite property query", parameter.Property) + } + return + } + } + t.Fatal("FindMailUser query parameter declaration is missing") +} From 47d71375f687535d7c3c9164ea42e25f56793f1e Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 21:17:07 +0800 Subject: [PATCH 151/267] fix(attendance): align live identity and availability --- .../attendance.md | 82 +++++++++++++++-- docs/shortcut-public-catalog.json | 28 +----- internal/app/schema_shortcut_contract_test.go | 4 +- internal/shortcut/attendance/attendance.go | 2 +- ...ce_release_cross_platform_coverage_test.go | 26 +----- ...nce_strict_cross_platform_coverage_test.go | 92 ++++++++++++++++++- internal/shortcut/attendance/common.go | 57 ++++++++---- .../attendance_semantic_catalog_test.go | 4 +- internal/shortcut/public_catalog_generated.go | 2 - .../shortcut/semantic_catalog_attendance.json | 6 +- 10 files changed, 215 insertions(+), 88 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index b8a7ebc7..d94e7a5e 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -1,16 +1,16 @@ # Attendance Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` +> Live discovery 基线:`85c3f307eed5ca753991804ef64228eb7b1f3794`;本轮修复后的 clean HEAD 尚待提交与完整重跑 > 对比基线:Lark CLI 1.0.87 > 范围:Attendance Shortcut only;不修改 DWS 产品 Skill 或 multi Skill。 ## 1. 执行摘要 -- Attendance 共审核 35 个源码 Shortcut;11 个具备公开条件,24 个保持 hidden/unavailable。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- Attendance 共审核 35 个源码 Shortcut;9 个具备公开条件,26 个保持 hidden/unavailable。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 - `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 -- 已确认 5 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture,以及 6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture。 -- 班次详情曾因未公开而缺失,现已通过“搜索非空结果 → 同一稳定 ID 详情”闭环恢复;严格响应校验、统一 Result 和空结果判定均属于上游修复,不要求下游改动。 +- 已确认 7 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID,以及个人设置缺少逐场景权限发现与安全 fixture。 +- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 | ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | |---|---|---|---|---|---|---| @@ -19,18 +19,21 @@ | `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | | `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | | `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | +| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | +| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | ## 2. 用户任务与能力缺口总览 | 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | |---|---|---|---|---|---| | 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | -| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | covered | 无 | 公开,严格身份闭环 | +| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | | 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | | 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均保持 hidden/unavailable | | 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | | 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture,全部 hidden | | 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | +| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 hidden/unavailable | | 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | ## 3. 下游需求明细 @@ -172,6 +175,56 @@ 在上述证据完整前,6 个 Shortcut 均保持 hidden/unavailable;已实现的严格校验不等于已获得发布证据。 +### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 + +#### A. 用户任务与现状 + +- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 +- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 +- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 +- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 +- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 +- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 +- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 +- 改动需 additive/versioned;现有详情业务字段保持兼容。 + +#### C. 验收标准与临时处置 + +1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 +2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 +3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 + +下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 + +### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture + +#### A. 用户任务与现状 + +- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 +- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 +- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 +- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 + +#### B. 需要下游提供的合同与 fixture + +- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 +- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 +- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 +- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 + +#### C. 验收标准与临时处置 + +1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 +2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 +3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 +4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 + +能力发现和安全 fixture 到位前,`+get-self-setting` 保持 hidden/unavailable。 + ## 4. Lark 对齐与平台差异 | Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | @@ -193,11 +246,26 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 已完成单元负向矩阵;最终 clean-HEAD 真实 nonempty/zero 双层矩阵待发布前集中执行,本文不提前泛化声称全通过 | +| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;TRAVEL/OUT 双项成功矩阵。修复后 clean-HEAD live 待重跑 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 已有坏 item/空 ID/重复 ID/分页矛盾单元回归;最终搜索 nonempty/guaranteed-zero 与 raw 对照待 clean-HEAD 集中执行 | -| `+get-class`, `+get-overtime-rule` | 能力存在但无请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `shiftVO.id` / `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;最终同 ID live 读回待 clean-HEAD 集中执行 | -| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型 | 5 个可用 scene 的 exact/raw 同场景双层对照;boss scene 当前身份双层均显式业务失败,不冒充成功 fixture | +| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;discovery HEAD 上 exact/raw 同真实搜索 ID 对象一致 | +| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | +| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | | `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | +### 6.1 clean-HEAD live 发布门状态 + +| 叶子 | discovery HEAD 结果 | 修复后 clean HEAD 发布状态 | +|---|---|---| +| `+check-result`, `+check-record`, `+list-approve`, `+get-schedule` | exact/raw 同场景已观测 known-nonempty 与 guaranteed-zero;打卡结果另完成多页 cursor 前进与终止 | `PENDING_RERUN`;旧 PASS 不继承 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw 同场景已观测 known-nonempty、随机唯一词 guaranteed-zero、稳定 ID 与终止分页 | `PENDING_RERUN`;旧 PASS 不继承 | +| `+get-overtime-rule` | 使用同一次真实搜索取得的 ID,exact projector 与 raw 单项对象一致;不存在/非法 ID 非零 | `PENDING_RERUN`;旧 PASS 不继承 | +| `+get-approve-template` | discovery 暴露同类型多模板;本轮已修 identity/request-binding 分工 | `PENDING_RERUN`;必须逐一覆盖 5 个 approveType | +| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | +| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | + +以上 live 结果只用于发现问题和决定发布面;当前实现修复完成并形成新的 clean commit 后,9 个 public 叶子必须从零完整重跑,任何 discovery PASS 都不能直接继承。 + ## 7. 安全与脱敏声明 - 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8e2f9c93..0df657e8 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T19:08:40.354559", - "count": 425, + "generated_at": "2026-08-18T20:52:37.246788", + "count": 423, "results": [ { "suite": "semantic", @@ -959,17 +959,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-class", - "risk": "read", - "status": "reviewed_available", - "disposition": "schema_leaf", - "semantic_delta": "使用搜索得到的班次 ID 读取详情,要求显式成功、非空 result,且 shiftVO.id 与请求 classId 精确一致。", + "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。", "availability": "available" }, { @@ -992,16 +982,6 @@ "semantic_delta": "规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。", "availability": "available" }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-self-setting", - "risk": "read", - "status": "reviewed_available", - "disposition": "schema_leaf", - "semantic_delta": "按归一化 userId 和场景读取个人考勤设置;响应用户必须精确匹配,场景字段必须非空并符合已观测 object/boolean/integer 类型。", - "availability": "available" - }, { "suite": "semantic", "service": "attendance", @@ -4119,4 +4099,4 @@ "availability": "available" } ] -} +} \ No newline at end of file diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 95728137..7a2216d7 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 424 + publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. schemaPublishedShortcutCount = 446 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 424 + publiclyDeliveredShortcutCount = 422 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index bd0659ce..f318217a 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -530,7 +530,7 @@ var GetApproveTemplate = shortcut.Shortcut{ return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, }, "templates", true, nil, func(items []map[string]any) error { - return attendanceValidateExpectedStrings(items, serverWukong+"/query_at_approve_template", "approveType", approveType) + return attendanceValidateApproveTemplates(items, serverWukong+"/query_at_approve_template", approveType) }, "result") }, } diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index 3c9f1f6a..165a573b 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -211,25 +211,6 @@ func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) } } - var schema map[string]any - if err := json.Unmarshal(GetSelfSetting.Contract.Result.DataSchema, &schema); err != nil { - t.Fatal(err) - } - properties := schema["properties"].(map[string]any)["value"].(map[string]any)["properties"].(map[string]any) - expectedTypes := map[string]string{ - "checkRemindSetting": "object", - "fastCheckLateNeedConfirm": "boolean", - "checkResultMsg": "integer", - "lackRemindUser": "integer", - "personDailyReportSwitch": "integer", - "bossMonthReportType": "integer", - } - for field, expectedType := range expectedTypes { - property, ok := properties[field].(map[string]any) - if !ok || property["type"] != expectedType || strings.TrimSpace(fmt.Sprint(property["description"])) == "" { - t.Errorf("self-setting schema %s=%#v, want type=%s and description", field, properties[field], expectedType) - } - } } func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { @@ -924,9 +905,10 @@ func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) calls int }{ {"invalid", `{"success":true,"result":[]}`, false, 0}, - {"leave", `{"success":true,"result":[{"approveType":"OVERTIME"}]}`, false, 1}, - {"leave", `{"success":true,"result":[{"approveType":"LEAVE"}]}`, true, 1}, - {"OUT", `{"success":true,"result":[{"approveType":"OUT"}]}`, true, 1}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"OVERTIME","submitUrl":"https://example.invalid/1"}]}`, false, 1}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"LEAVE","submitUrl":"https://example.invalid/1"}]}`, true, 1}, + {"TRAVEL", `{"success":true,"result":[{"processCode":"p1","approveType":"TRAVEL","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"TRAVEL","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + {"OUT", `{"success":true,"result":[{"processCode":"p1","approveType":"OUT","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"OUT","submitUrl":"https://example.invalid/2"}]}`, true, 1}, } { caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response) if (err == nil) != tc.valid || caller.calls != tc.calls { diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go index 29001758..8da8915a 100644 --- a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -24,14 +24,12 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"}, {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, - {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"approveType"`, ""}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""}, {GetSchedule.Command, GetSchedule.OutputRollout, GetSchedule.Contract.Result != nil, string(GetSchedule.Contract.Result.DataSchema), `"id"`, ""}, {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, - {GetClass.Command, GetClass.OutputRollout, GetClass.Contract.Result != nil, string(GetClass.Contract.Result.DataSchema), `"shiftVO"`, ""}, {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, - {GetSelfSetting.Command, GetSelfSetting.OutputRollout, GetSelfSetting.Contract.Result != nil, string(GetSelfSetting.Contract.Result.DataSchema), `"userId"`, ""}, } for _, item := range shortcuts { if item.rollout != output.RolloutUnifiedActive { @@ -60,7 +58,7 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes } func attendanceDeclarationByName(name string) *shortcut.Shortcut { - for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &GetSchedule, &SearchClass, &GetClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule, &GetSelfSetting} { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &GetSchedule, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { if declaration.Command == name { return declaration } @@ -69,7 +67,7 @@ func attendanceDeclarationByName(name string) *shortcut.Shortcut { } func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { - for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord} { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetClass, &GetSelfSetting} { if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) } @@ -180,3 +178,87 @@ func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplica t.Fatal("duplicate string identity accepted") } } + +func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) { + valid := []map[string]any{ + {"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, + {"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}, + } + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil { + t.Fatalf("same approveType with unique processCode rejected: %v", err) + } + valid[0]["processCode"] = " process-1 " + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" { + t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err) + } + valid[0]["approveType"] = "OUT" + valid[1]["approveType"] = "OUT" + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil { + t.Fatalf("OUT templates with unique processCode rejected: %v", err) + } + + invalid := []struct { + name string + items []map[string]any + }{ + {"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}}, + {"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}}, + {"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}}, + {"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}}, + {"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}}, + {"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}}, + {"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil { + t.Fatalf("invalid templates accepted: %#v", tc.items) + } + }) + } + + var schema map[string]any + if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil { + t.Fatal(err) + } + items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any) + required := items["required"].([]any) + for _, name := range []string{"processCode", "approveType", "submitUrl"} { + found := false + for _, value := range required { + if value == name { + found = true + } + } + if !found { + t.Errorf("template schema required=%#v missing %s", required, name) + } + } + if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want { + t.Fatalf("template sensitive paths = %q, want %q", got, want) + } + properties := items["properties"].(map[string]any) + for _, name := range []string{"processCode", "submitUrl"} { + property := properties[name].(map[string]any) + if property["type"] != "string" || property["minLength"] != float64(1) { + t.Errorf("template schema %s = %#v, want nonempty string", name, property) + } + } + approveType := properties["approveType"].(map[string]any) + if approveType["type"] != "string" { + t.Errorf("template schema approveType = %#v, want string", approveType) + } + enum := approveType["enum"].([]any) + values := make([]string, 0, len(enum)) + for _, value := range enum { + values = append(values, value.(string)) + } + if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want { + t.Errorf("template approveType enum = %q, want %q", got, want) + } +} diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index a56f77a4..86e2f7e6 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -26,20 +26,10 @@ func attendanceIntegerObjectResult(description, identity string) *contract.Resul } } -func attendanceNestedIntegerObjectResult(description, container, identity string) *contract.ResultSpec { - return &contract.ResultSpec{ - Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(fmt.Sprintf( - `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"object","description":"承载详情稳定身份的业务对象","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, - description, container, identity, identity, container, - )), - } -} - -func attendanceSelfSettingResult() *contract.ResultSpec { +func attendanceApproveTemplateResult() *contract.ResultSpec { return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(`{"type":"object","description":"严格绑定用户与设置场景的个人考勤设置","properties":{"value":{"type":"object","description":"个人考勤设置对象","properties":{"userId":{"type":"string","minLength":1,"description":"与请求精确绑定的用户 ID"},"checkRemindSetting":{"type":"object","description":"打卡提醒场景设置","additionalProperties":true},"fastCheckLateNeedConfirm":{"type":"boolean","description":"极速打卡场景设置"},"checkResultMsg":{"type":"integer","description":"打卡结果通知场景设置"},"lackRemindUser":{"type":"integer","description":"缺卡提醒场景设置"},"personDailyReportSwitch":{"type":"integer","description":"个人考勤统计通知场景设置"},"bossMonthReportType":{"type":"integer","description":"团队考勤统计通知场景设置"}},"required":["userId"],"anyOf":[{"required":["checkRemindSetting"]},{"required":["fastCheckLateNeedConfirm"]},{"required":["checkResultMsg"]},{"required":["lackRemindUser"]},{"required":["personDailyReportSwitch"]},{"required":["bossMonthReportType"]}],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`), + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`), } } @@ -91,7 +81,6 @@ func hardenPublicAttendanceContracts() { {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, - {&GetApproveTemplate, "templates", "严格校验的考勤审批模板", "approveType", "string", ""}, {&GetSchedule, "schedules", "严格校验的员工排班记录", "id", "integer", ""}, {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, @@ -107,21 +96,16 @@ func hardenPublicAttendanceContracts() { CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"} CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"} ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"} + GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive + GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult() GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} GetSchedule.Contract.Result.SensitivePaths = []string{"schedules.className", "schedules.corpId", "schedules.userId"} SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} - GetClass.OutputRollout = output.RolloutUnifiedActive - GetClass.Contract.Result = attendanceNestedIntegerObjectResult("严格校验的班次详情", "shiftVO", "id") - GetClass.Contract.Result.SensitivePaths = []string{"value.shiftVO.name", "value.shiftVO.owner", "value.shiftVO.ownerName", "value.shiftVO.sections"} GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} - GetSelfSetting.OutputRollout = output.RolloutUnifiedActive - GetSelfSetting.Contract.Result = attendanceSelfSettingResult() - GetSelfSetting.Contract.Result.SensitivePaths = []string{"value.bossMonthReportType", "value.checkRemindSetting", "value.checkResultMsg", "value.fastCheckLateNeedConfirm", "value.lackRemindUser", "value.personDailyReportSwitch", "value.userId"} - for _, item := range []struct { declaration *shortcut.Shortcut reason string @@ -130,6 +114,8 @@ func hardenPublicAttendanceContracts() { {&ListLeaveTypes, "当前安全身份只有非空假期类型列表,且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。"}, {&GetLeaveRecords, "使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。"}, {&GetCheckinRecord, "当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。"}, + {&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。"}, + {&GetSelfSetting, "query_self_setting 的 bossAttendStatNotify 场景在当前安全身份下返回 NO_PERMISSION,且缺少 capability discovery、逐场景权限合同与安全权限 fixture,无法完成全部公开场景的真实验证。"}, } { markAttendanceUnavailable(item.declaration, item.reason) } @@ -264,6 +250,37 @@ func attendanceValidateExpectedStrings(items []map[string]any, operation, field, return nil } +func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error { + expectedType = strings.TrimSpace(expectedType) + seenProcessCodes := make(map[string]struct{}, len(items)) + for index, item := range items { + processCodeValue, present := item["processCode"] + if !present { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index)) + } + processCode, ok := processCodeValue.(string) + processCode = strings.TrimSpace(processCode) + if !ok || processCode == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index)) + } + item["processCode"] = processCode + if _, duplicate := seenProcessCodes[processCode]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index)) + } + seenProcessCodes[processCode] = struct{}{} + + approveType, ok := item["approveType"].(string) + if !ok || approveType != expectedType { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index)) + } + submitURL, ok := item["submitUrl"].(string) + if !ok || strings.TrimSpace(submitURL) == "" { + return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index)) + } + } + return nil +} + func attendanceNestedValue(item map[string]any, path ...string) (any, bool) { var current any = item for _, key := range path { diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go index ffe3b903..ad297225 100644 --- a/internal/shortcut/builtin/attendance_semantic_catalog_test.go +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -79,7 +79,7 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 11 { - t.Fatalf("public attendance shortcuts = %d, want 11", public) + if public != 9 { + t.Fatalf("public attendance shortcuts = %d, want 9", public) } } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index bbdd1bfe..d51bff57 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -102,10 +102,8 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-class": {}, "attendance\u0000+get-overtime-rule": {}, "attendance\u0000+get-schedule": {}, - "attendance\u0000+get-self-setting": {}, "attendance\u0000+list-approve": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json index a487b60d..a86e1603 100644 --- a/internal/shortcut/semantic_catalog_attendance.json +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -6,15 +6,15 @@ "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型,并严格验证模板数组。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-class": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的班次 ID 读取详情,要求显式成功、非空 result,且 shiftVO.id 与请求 classId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"按归一化 userId 和场景读取个人考勤设置;响应用户必须精确匹配,场景字段必须非空并符合已观测 object/boolean/integer 类型。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, From cad437aabd1fb4e96a077820f0bf9e05cbca0918 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 21:46:46 +0800 Subject: [PATCH 152/267] fix(attendance): filter validated record overfetch --- .../attendance.md | 23 +-- internal/shortcut/attendance/attendance.go | 29 ++-- ...ce_release_cross_platform_coverage_test.go | 137 ++++++++++++++++++ internal/shortcut/attendance/common.go | 47 ++++++ 4 files changed, 218 insertions(+), 18 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index d94e7a5e..0aaabea0 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -1,7 +1,7 @@ # Attendance Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> Live discovery 基线:`85c3f307eed5ca753991804ef64228eb7b1f3794`;本轮修复后的 clean HEAD 尚待提交与完整重跑 +> Live discovery 基线:`0ad61b3a9d60adf917311987039d51b43b217315`;本轮 `+check-record` overfetch 修复后的 clean HEAD 尚待提交与完整重跑 > 对比基线:Lark CLI 1.0.87 > 范围:Attendance Shortcut only;不修改 DWS 产品 Skill 或 multi Skill。 @@ -246,9 +246,10 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 已完成单元负向矩阵;最终 clean-HEAD 真实 nonempty/zero 双层矩阵待发布前集中执行,本文不提前泛化声称全通过 | -| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;TRAVEL/OUT 双项成功矩阵。修复后 clean-HEAD live 待重跑 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 已有坏 item/空 ID/重复 ID/分页矛盾单元回归;最终搜索 nonempty/guaranteed-zero 与 raw 对照待 clean-HEAD 集中执行 | -| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;discovery HEAD 上 exact/raw 同真实搜索 ID 对象一致 | +| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper 覆盖 missing/wrong/malformed、前后边界;Execute 覆盖 in-range + start-24h 成功、全 out-of-range 显式空、错用户/坏时间/重复 ID 不可被过滤隐藏。修复后 clean-HEAD live 待重跑 | +| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | +| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | | `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | | `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | | `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | @@ -257,14 +258,18 @@ | 叶子 | discovery HEAD 结果 | 修复后 clean HEAD 发布状态 | |---|---|---| -| `+check-result`, `+check-record`, `+list-approve`, `+get-schedule` | exact/raw 同场景已观测 known-nonempty 与 guaranteed-zero;打卡结果另完成多页 cursor 前进与终止 | `PENDING_RERUN`;旧 PASS 不继承 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw 同场景已观测 known-nonempty、随机唯一词 guaranteed-zero、稳定 ID 与终止分页 | `PENDING_RERUN`;旧 PASS 不继承 | -| `+get-overtime-rule` | 使用同一次真实搜索取得的 ID,exact projector 与 raw 单项对象一致;不存在/非法 ID 非零 | `PENDING_RERUN`;旧 PASS 不继承 | -| `+get-approve-template` | discovery 暴露同类型多模板;本轮已修 identity/request-binding 分工 | `PENDING_RERUN`;必须逐一覆盖 5 个 approveType | +| `+check-result` | clean HEAD 上 exact/raw 同场景 known-nonempty 与合法 guaranteed-zero 均通过;非空三页实际前进并终止,稳定 ID 集合、请求用户与每页内容一致 | `PASS@0ad61b3a`;后续任一代码修复产生新 HEAD 时仍需重跑,不跨 HEAD 继承 | +| `+check-record` | clean HEAD 的 raw 非空集合含一个 start-24h overfetch;其余行按 Asia/Shanghai 业务日一致,exact 因严格范围校验非零,未误记成功;合法 guaranteed-zero 双层通过 | `FAIL@0ad61b3a`;本轮已加入“全量先验证、再过滤 overfetch”的专用上游修复,新 clean HEAD 必须重跑 nonempty/zero | +| `+list-approve`, `+get-schedule` | clean HEAD 上 exact/raw 同场景 known-nonempty 与合法 guaranteed-zero 均通过;稳定 ID、用户/类型或用户/日期绑定及集合一致 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | clean HEAD 上 exact/raw 同场景 known-nonempty、随机唯一词 guaranteed-zero、稳定 ID 集合与分页终止通过;班次与加班规则另完成真实多页前进 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | +| `+get-overtime-rule` | clean HEAD 上使用同一次真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | +| `+get-approve-template` | clean HEAD 上 5 个 approveType 全部 exact/raw 通过;TRAVEL/OUT 多项的 `processCode` 非空唯一且集合一致,类型绑定和提交入口均有效 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | | `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | | `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | -以上 live 结果只用于发现问题和决定发布面;当前实现修复完成并形成新的 clean commit 后,9 个 public 叶子必须从零完整重跑,任何 discovery PASS 都不能直接继承。 +同轮多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合。 + +以上 live 结果只用于发现问题和决定发布面;当前 `+check-record` 修复形成新的 clean commit 后,9 个 public 叶子必须从零完整重跑,任何 `PASS@0ad61b3a` 都不能直接继承。 ## 7. 安全与脱敏声明 diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index f318217a..aeedf75a 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -340,20 +340,31 @@ var CheckRecord = shortcut.Shortcut{ if err != nil { return err } - return attendanceCallCollection(rt, serverWukong, "query_check_record", map[string]any{ + data, err := rt.CallMCPData(serverWukong, "query_check_record", map[string]any{ "QueryCheckRecordRequest": map[string]any{ "userIds": rt.StrSlice("users"), "checkDateFrom": from, "checkDateTo": to, }, - }, "records", true, nil, func(items []map[string]any) error { - if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_check_record", "id"); err != nil { - return err - } - startMillis, _ := dayMillis(rt.Str("start")) - endMillis, _ := dayMillis(rt.Str("end")) - return attendanceValidateUserAndTimeBinding(items, serverWukong+"/query_check_record", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis) - }, "result") + }) + if err != nil { + return err + } + operation := serverWukong + "/query_check_record" + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(items, operation, "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + items, err = attendanceFilterUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis) + if err != nil { + return err + } + return attendanceOutputCollection(rt, "records", items, true, nil, false, "") }, } diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index 165a573b..df84c599 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -109,6 +109,143 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { } } +func TestCrossPlatformCoverageAttendanceCheckRecordOverfetchFilter(t *testing.T) { + const startMillis, endMillis = int64(1000), int64(2000) + items := []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 1}, + {"id": 2, "userId": "u1", "workDate": startMillis}, + {"id": 3, "userId": "u1", "workDate": endMillis}, + {"id": 4, "userId": "u1", "workDate": endMillis + 1}, + } + filtered, err := attendanceFilterUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "workDate", startMillis, endMillis) + if err != nil || len(filtered) != 2 || filtered[0]["id"] != 2 || filtered[1]["id"] != 3 { + t.Fatalf("filtered=%#v err=%v", filtered, err) + } + + invalid := []struct { + name string + items []map[string]any + start int64 + end int64 + }{ + {"reversed request", nil, endMillis, startMillis}, + {"missing user", []map[string]any{{"workDate": startMillis}}, startMillis, endMillis}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": startMillis}}, startMillis, endMillis}, + {"empty user", []map[string]any{{"userId": " ", "workDate": startMillis}}, startMillis, endMillis}, + {"wrong user outside range", []map[string]any{{"userId": "u2", "workDate": startMillis - 1}}, startMillis, endMillis}, + {"missing time", []map[string]any{{"userId": "u1"}}, startMillis, endMillis}, + {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, startMillis, endMillis}, + {"zero time", []map[string]any{{"userId": "u1", "workDate": 0}}, startMillis, endMillis}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if got, err := attendanceFilterUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", tc.start, tc.end); err == nil || got != nil { + t.Fatalf("filtered=%#v err=%v", got, err) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfetch(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + startMillis, _ := dayMillis(values["start"]) + endMillis, _ := dateToMillis(values["end"], true) + execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(CheckRecord)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + err := CheckRecord.Execute(shortcut.RuntimeContextForTest(cmd, CheckRecord)) + if err != nil { + return nil, err + } + if caller.calls != 1 { + t.Fatalf("calls=%d", caller.calls) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%t err=%v", code, emitted, err) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope, nil + } + + t.Run("keeps in-range and filters start minus 24 hours", func(t *testing.T) { + envelope, err := execute(t, []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, + {"id": 2, "userId": "u1", "workDate": startMillis}, + }) + if err != nil { + t.Fatal(err) + } + data, ok := envelope["data"].(map[string]any) + records, recordsOK := data["records"].([]any) + if !ok || !recordsOK || data["count"] != float64(1) || len(records) != 1 || records[0].(map[string]any)["id"] != float64(2) { + t.Fatalf("data=%#v", envelope["data"]) + } + }) + + t.Run("all valid overfetch becomes explicit empty", func(t *testing.T) { + envelope, err := execute(t, []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, + {"id": 2, "userId": "u1", "workDate": endMillis + 1}, + }) + if err != nil { + t.Fatal(err) + } + data, ok := envelope["data"].(map[string]any) + records, recordsOK := data["records"].([]any) + if !ok || !recordsOK || data["count"] != float64(0) || len(records) != 0 { + t.Fatalf("data=%#v", envelope["data"]) + } + }) + + invalid := []struct { + name string + records []map[string]any + }{ + {"missing identity outside range", []map[string]any{{"userId": "u1", "workDate": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "workDate": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "workDate": startMillis - 1}}}, + {"malformed time outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 2}, {"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if _, err := execute(t, tc.records); err == nil { + t.Fatal("invalid raw overfetch was hidden") + } + }) + } + + t.Run("downstream failure", func(t *testing.T) { + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckRecord.Execute(attendanceRuntimeForTest(t, CheckRecord, values)); err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("missing collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, CheckRecord, values, `{"success":true,"result":null}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) { detailCases := []struct { name string diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index 86e2f7e6..899c967c 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -360,6 +360,53 @@ func attendanceValidateUserAndTimeBinding(items []map[string]any, operation stri return nil } +// attendanceFilterUserAndTimeBinding validates every raw row's request +// binding before filtering service-side range overfetch. Callers must validate +// stable identities for the complete raw collection first: an out-of-range row +// is allowed to be omitted from the projection, but it must never hide a +// malformed user/time binding or a row belonging to another requested user. +func attendanceFilterUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) ([]map[string]any, error) { + if startMillis > endMillis { + return nil, responsecheck.Error(operation, "invalid_request_range", "请求开始时间不能晚于结束时间") + } + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[strings.TrimSpace(user)] = struct{}{} + } + timestamps := make([]int64, len(items)) + for index, item := range items { + rawUser, present := item[userField] + if !present { + return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + user, ok := rawUser.(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + } + if _, requested := users[user]; !requested { + return nil, responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + rawTimestamp, present := item[timeField] + if !present { + return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + } + timestamp, valid := attendancePositiveInteger(rawTimestamp) + if !valid { + return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + timestamps[index] = timestamp + } + filtered := make([]map[string]any, 0, len(items)) + for index, item := range items { + if timestamps[index] < startMillis || timestamps[index] > endMillis { + continue + } + filtered = append(filtered, item) + } + return filtered, nil +} + func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { users := make(map[string]struct{}, len(requestedUsers)) for _, user := range requestedUsers { From 5f6ca9082115b34b79c7f14f0549c2d28b501d7b Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 22:01:34 +0800 Subject: [PATCH 153/267] docs(shortcut): sync live evidence and generated lists --- .../attendance.md | 26 +++++++++---------- docs/shortcut-downstream-requirements/mail.md | 23 +++++++++++----- skills/mono/SKILL.md | 4 +-- skills/multi/dingtalk-mail/SKILL.md | 10 +++---- .../dingtalk-misc/references/attendance.md | 10 ------- 5 files changed, 36 insertions(+), 37 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 0aaabea0..56f0db47 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -1,9 +1,9 @@ # Attendance Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> Live discovery 基线:`0ad61b3a9d60adf917311987039d51b43b217315`;本轮 `+check-record` overfetch 修复后的 clean HEAD 尚待提交与完整重跑 +> Live executable 基线:`d2437b733346d8cc508c2c6b8d1714f640703440`;9 个公开入口均在该 clean HEAD 完成 exact Shortcut + owning raw 双层验证 > 对比基线:Lark CLI 1.0.87 -> 范围:Attendance Shortcut only;不修改 DWS 产品 Skill 或 multi Skill。 +> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 ## 1. 执行摘要 @@ -245,8 +245,8 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 已完成单元负向矩阵;最终 clean-HEAD 真实 nonempty/zero 双层矩阵待发布前集中执行,本文不提前泛化声称全通过 | -| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper 覆盖 missing/wrong/malformed、前后边界;Execute 覆盖 in-range + start-24h 成功、全 out-of-range 显式空、错用户/坏时间/重复 ID 不可被过滤隐藏。修复后 clean-HEAD live 待重跑 | +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已在 `d2437b73` 按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核,后续非执行提交只做最终发布复核 | +| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper/Execute 负向矩阵通过;`d2437b73` 上 raw 157 条、exact 156 条,逐对象精确等于 raw 的请求用户与本地日期范围子集,仅过滤 start-24h 一项;fresh zero 双层通过 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | | `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | @@ -256,20 +256,20 @@ ### 6.1 clean-HEAD live 发布门状态 -| 叶子 | discovery HEAD 结果 | 修复后 clean HEAD 发布状态 | +| 叶子 | clean executable HEAD 双层证据 | 发布状态 | |---|---|---| -| `+check-result` | clean HEAD 上 exact/raw 同场景 known-nonempty 与合法 guaranteed-zero 均通过;非空三页实际前进并终止,稳定 ID 集合、请求用户与每页内容一致 | `PASS@0ad61b3a`;后续任一代码修复产生新 HEAD 时仍需重跑,不跨 HEAD 继承 | -| `+check-record` | clean HEAD 的 raw 非空集合含一个 start-24h overfetch;其余行按 Asia/Shanghai 业务日一致,exact 因严格范围校验非零,未误记成功;合法 guaranteed-zero 双层通过 | `FAIL@0ad61b3a`;本轮已加入“全量先验证、再过滤 overfetch”的专用上游修复,新 clean HEAD 必须重跑 nonempty/zero | -| `+list-approve`, `+get-schedule` | clean HEAD 上 exact/raw 同场景 known-nonempty 与合法 guaranteed-zero 均通过;稳定 ID、用户/类型或用户/日期绑定及集合一致 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | clean HEAD 上 exact/raw 同场景 known-nonempty、随机唯一词 guaranteed-zero、稳定 ID 集合与分页终止通过;班次与加班规则另完成真实多页前进 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | -| `+get-overtime-rule` | clean HEAD 上使用同一次真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | -| `+get-approve-template` | clean HEAD 上 5 个 approveType 全部 exact/raw 通过;TRAVEL/OUT 多项的 `processCode` 非空唯一且集合一致,类型绑定和提交入口均有效 | `PASS@0ad61b3a`;新 HEAD 必须重跑 | +| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS@d2437b73` | +| `+check-record` | raw 157 条先全部通过集合、ID、用户与时间类型校验;exact 156 条逐对象等于 raw 的请求用户+本地日期范围子集,唯一过滤 start-24h 一项;fresh future zero 双层一致 | `PASS@d2437b73` | +| `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS@d2437b73` | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS@d2437b73` | +| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS@d2437b73` | +| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS@d2437b73` | | `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | | `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | -同轮多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合。 +`0ad61b3a` discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;`d2437b73` 最终轮次未再出现该 transient。 -以上 live 结果只用于发现问题和决定发布面;当前 `+check-record` 修复形成新的 clean commit 后,9 个 public 叶子必须从零完整重跑,任何 `PASS@0ad61b3a` 都不能直接继承。 +上述 9 个公开入口均在 `d2437b73` 从零重跑;未继承 discovery PASS。后续非执行文档/生成块提交仍需在最终 clean HEAD 做一次发布复核,最终 SHA 写入 PR 证据而不反向伪造本文自身 commit。 ## 7. 安全与脱敏声明 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index 699fcd75..a40fdcbd 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -1,14 +1,14 @@ # Mail Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7` +> Live executable 基线:`0ad61b3a9d60adf917311987039d51b43b217315`;8 个公开入口均在该 clean HEAD 完成 exact Shortcut + owning raw 双层验证 > 对比基线:lark-cli 1.0.87 -> 范围:Shortcut only;不修改 `skills/multi` 或 `skills/mono` +> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 > 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 ## 1. 执行摘要 -本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。静态发布候选为 8 个只读入口,仍须在最终 clean HEAD 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在 clean executable HEAD `0ad61b3a` 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 @@ -167,13 +167,24 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | -| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | fresh query 可构造 known-nonempty + guaranteed-zero;clean HEAD 重跑后收口 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | `0ad61b3a` 上各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | | `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | clean HEAD 待执行 exact/raw 非空与随机零命中双验 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | `0ad61b3a` 上各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | | `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | -| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | clean HEAD 待执行同 ID exact/raw 双验 | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `0ad61b3a` 上 `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | | 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | +### 6.1 clean executable HEAD 双层证据 + +| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | +|---|---|---| +| `+search-mail`, `+triage` | 各 20 条 known-nonempty 与 fresh guaranteed-zero;稳定 message ID 集合和分页状态一致 | `PASS@0ad61b3a` | +| `+user-search`, `+find-mail-user` | 各 19 条 known-nonempty 与 fresh guaranteed-zero;条件身份集合和分页状态一致 | `PASS@0ad61b3a` | +| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS@0ad61b3a` | +| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS@0ad61b3a` | + +后续 Attendance 代码和非执行文档/生成块提交不改变 Mail runtime;发布前仍在最终 clean HEAD 从零重跑 8 个入口,最终 SHA 写入 PR 证据,不反向伪造本文自身 commit。 + ## 7. 安全与脱敏声明 - 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 2fb8cb3d..51262e41 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -44,7 +44,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aitable` | 93 | `dingtalk-aitable` | -| `attendance` | 19 | `dingtalk-misc` | +| `attendance` | 9 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | @@ -52,7 +52,7 @@ cli_version: ">=1.0.15" | `ding` | 4 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | -| `mail` | 10 | `dingtalk-mail` | +| `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | | `oa` | 7 | `dingtalk-misc` | | `report` | 2 | `dingtalk-misc` | diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 19b89174..8b2580a1 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -24,15 +24,13 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws mail +contact-list` | read | 列出指定邮箱的所有邮件联系人 | | `dws mail +find-mail-user` | read | 按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等) | | `dws mail +folder-list` | read | 列出顶层文件夹或指定父文件夹下的子文件夹 | -| `dws mail +recent-mail` | read | 列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId) | +| `dws mail +message` | read | 读取一封邮件的完整正文与附件元数据 | +| `dws mail +messages` | read | 按请求顺序读取多封邮件并逐封验证身份 | | `dws mail +search-mail` | read | 按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId) | -| `dws mail +tag-list` | read | 列出指定邮箱下的所有邮件标签 | -| `dws mail +template-list` | read | 列出指定邮箱的所有邮件模板 | -| `dws mail +thread-list` | read | 列出指定邮箱文件夹下的邮件会话(thread) | -| `dws mail +unread-mail` | read | 列出未读邮件并投影列表(主题/发件人/时间/messageId) | +| `dws mail +thread` | read | 读取完整邮件会话并精确验证 conversationId | +| `dws mail +triage` | read | 列出或筛选邮件摘要,自动解析邮箱与收件箱 | | `dws mail +user-search` | read | 按关键词或工号搜索邮箱用户(仅企业邮箱) | diff --git a/skills/multi/dingtalk-misc/references/attendance.md b/skills/multi/dingtalk-misc/references/attendance.md index 00c70d03..381046dd 100644 --- a/skills/multi/dingtalk-misc/references/attendance.md +++ b/skills/multi/dingtalk-misc/references/attendance.md @@ -1417,23 +1417,13 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res |---|---|---| | `dws attendance +check-record` | read | 查询用户打卡流水(打卡时间/地点/定位方式) | | `dws attendance +check-result` | read | 查询用户打卡结果(迟到/早退/缺卡等) | -| `dws attendance +get-adjustment-rule` | read | 根据补卡规则主键 ID 查询补卡规则详情 | | `dws attendance +get-approve-template` | read | 查询补卡/请假/加班/外出/出差审批提交链接 | -| `dws attendance +get-checkin-record` | read | 查询指定员工一段时间内的签到记录 | -| `dws attendance +get-leave-records` | read | 查询指定员工的假期余额变更记录 | | `dws attendance +get-overtime-rule` | read | 根据加班规则主键 ID 查询加班规则详情 | | `dws attendance +get-schedule` | read | 获取指定用户一段时间内的排班记录 | -| `dws attendance +get-self-setting` | read | 查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等) | -| `dws attendance +get-summary` | read | 查询某个人的考勤统计摘要(周/月) | | `dws attendance +list-approve` | read | 查询用户考勤审批单(补卡/加班/请假/出差外出) | -| `dws attendance +list-leave-types` | read | 查询当前用户可用的假期规则列表 | -| `dws attendance +my-attendance` | read | 查我今天的考勤打卡记录(打卡流水,自动解析当前用户) | -| `dws attendance +query-report-data` | read | 根据字段查询考勤报表数据(仅管理员) | | `dws attendance +search-adjustment-rule` | read | 查询当前用户可管理的补卡规则列表 | | `dws attendance +search-class` | read | 查询当前用户可管理的班次详情列表 | -| `dws attendance +search-group` | read | 查询当前用户可管理的考勤组列表 | | `dws attendance +search-overtime-rule` | read | 查询当前用户可管理的加班规则列表 | -| `dws attendance +this-month` | read | 查我本月的考勤打卡记录(打卡流水,自动解析当前用户) | ## 意图表 From e217901a6bc21e76346a3025ddbd8d34a31a4dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 19 Aug 2026 14:37:22 +0800 Subject: [PATCH 154/267] fix(recruit): validate education list filter --- internal/helpers/recruit.go | 4 ++++ internal/helpers/recruit_test.go | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 62e271ff..cb2979c9 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -470,6 +470,10 @@ func validateRecruitList(cmd *cobra.Command, _ []string) error { if cmd.Flags().Changed("size") && (size < 1 || size > 100) { return apperrors.NewValidation("--size 必须在 1 到 100 之间") } + requiredEdu, _ := cmd.Flags().GetInt("required-edu") + if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) { + return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间") + } return nil } diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index da320b23..bb8879b4 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -85,7 +85,7 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) cmd.SetArgs([]string{ "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", - "--campus=false", "--cursor", "10", "--size", "30", + "--campus=false", "--required-edu", "9", "--cursor", "10", "--size", "30", }) if err := cmd.Execute(); err != nil { t.Fatal(err) @@ -100,7 +100,7 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { if !ok { t.Fatalf("param = %#v, want object", caller.args["param"]) } - if param["keyword"] != "Java" || param["campus"] != false { + if param["keyword"] != "Java" || param["campus"] != false || param["requiredEdu"] != 9 { t.Fatalf("param = %#v", param) } statuses, ok := param["statusList"].([]int) @@ -189,6 +189,20 @@ func TestRecruitJobListDefaultsAndValidation(t *testing.T) { if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { t.Fatalf("invalid size error = %v", err) } + + for _, requiredEdu := range []string{"0", "10"} { + t.Run("required edu "+requiredEdu, func(t *testing.T) { + caller := withRecruitCaller(t) + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--required-edu", requiredEdu}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 9") { + t.Fatalf("required edu %s error = %v", requiredEdu, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid required edu = %d, want 0", len(caller.calls)) + } + }) + } } func TestRecruitJobGetDispatchesJobID(t *testing.T) { From 42f54832b01b10c47fab5253e4e54b5e126d5656 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 22:15:06 +0800 Subject: [PATCH 155/267] docs(shortcut): refresh rebased evidence references --- .../attendance.md | 22 +++++++++---------- docs/shortcut-downstream-requirements/mail.md | 20 ++++++++--------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 56f0db47..2c814688 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -1,7 +1,7 @@ # Attendance Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> Live executable 基线:`d2437b733346d8cc508c2c6b8d1714f640703440`;9 个公开入口均在该 clean HEAD 完成 exact Shortcut + owning raw 双层验证 +> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 > 对比基线:Lark CLI 1.0.87 > 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 @@ -245,8 +245,8 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已在 `d2437b73` 按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核,后续非执行提交只做最终发布复核 | -| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper/Execute 负向矩阵通过;`d2437b73` 上 raw 157 条、exact 156 条,逐对象精确等于 raw 的请求用户与本地日期范围子集,仅过滤 start-24h 一项;fresh zero 双层通过 | +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核;最终 PR HEAD 仍从零复核 | +| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper/Execute 负向矩阵通过;raw 157 条、exact 156 条,逐对象精确等于 raw 的请求用户与本地日期范围子集,仅过滤 start-24h 一项;fresh zero 双层通过 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | | `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | @@ -258,18 +258,18 @@ | 叶子 | clean executable HEAD 双层证据 | 发布状态 | |---|---|---| -| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS@d2437b73` | -| `+check-record` | raw 157 条先全部通过集合、ID、用户与时间类型校验;exact 156 条逐对象等于 raw 的请求用户+本地日期范围子集,唯一过滤 start-24h 一项;fresh future zero 双层一致 | `PASS@d2437b73` | -| `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS@d2437b73` | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS@d2437b73` | -| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS@d2437b73` | -| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS@d2437b73` | +| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | raw 157 条先全部通过集合、ID、用户与时间类型校验;exact 156 条逐对象等于 raw 的请求用户+本地日期范围子集,唯一过滤 start-24h 一项;fresh future zero 双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | | `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | | `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | -`0ad61b3a` discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;`d2437b73` 最终轮次未再出现该 transient。 +pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 -上述 9 个公开入口均在 `d2437b73` 从零重跑;未继承 discovery PASS。后续非执行文档/生成块提交仍需在最终 clean HEAD 做一次发布复核,最终 SHA 写入 PR 证据而不反向伪造本文自身 commit。 +上述 9 个公开入口均在相同 runtime tree 从零重跑,未继承 discovery PASS。rebase 后的最终 clean PR HEAD 仍需做一次发布复核;最终 SHA 写入 PR 证据而不反向伪造本文自身 commit。 ## 7. 安全与脱敏声明 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index a40fdcbd..58765a97 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -1,14 +1,14 @@ # Mail Shortcut 下游业务能力需求规格 > 日期:2026-08-18 -> Live executable 基线:`0ad61b3a9d60adf917311987039d51b43b217315`;8 个公开入口均在该 clean HEAD 完成 exact Shortcut + owning raw 双层验证 +> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 > 对比基线:lark-cli 1.0.87 > 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 > 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 ## 1. 执行摘要 -本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在 clean executable HEAD `0ad61b3a` 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 @@ -167,23 +167,23 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | -| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | `0ad61b3a` 上各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | | `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | `0ad61b3a` 上各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | | `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | -| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `0ad61b3a` 上 `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | | 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | ### 6.1 clean executable HEAD 双层证据 | 公开入口 | exact Shortcut + owning raw 证据 | 状态 | |---|---|---| -| `+search-mail`, `+triage` | 各 20 条 known-nonempty 与 fresh guaranteed-zero;稳定 message ID 集合和分页状态一致 | `PASS@0ad61b3a` | -| `+user-search`, `+find-mail-user` | 各 19 条 known-nonempty 与 fresh guaranteed-zero;条件身份集合和分页状态一致 | `PASS@0ad61b3a` | -| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS@0ad61b3a` | -| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS@0ad61b3a` | +| `+search-mail`, `+triage` | 各 20 条 known-nonempty 与 fresh guaranteed-zero;稳定 message ID 集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+user-search`, `+find-mail-user` | 各 19 条 known-nonempty 与 fresh guaranteed-zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | -后续 Attendance 代码和非执行文档/生成块提交不改变 Mail runtime;发布前仍在最终 clean HEAD 从零重跑 8 个入口,最终 SHA 写入 PR 证据,不反向伪造本文自身 commit。 +后续 Attendance 代码、rebase 与非执行文档/生成块提交不改变 Mail runtime;发布前仍在最终 clean PR HEAD 从零重跑 8 个入口,最终 SHA 写入 PR 证据,不反向伪造本文自身 commit。 ## 7. 安全与脱敏声明 From 7900e27946c129e7fe5da19224392cabef598849 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 23:02:20 +0800 Subject: [PATCH 156/267] fix(shortcut): preserve CLI compatibility for unavailable leaves --- .../attendance.md | 16 +-- docs/shortcut-downstream-requirements/mail.md | 2 +- .../attendance_semantic_catalog_test.go | 27 ++++- .../builtin/chat_semantic_catalog_test.go | 15 +-- .../builtin/mail_semantic_catalog_test.go | 23 +++- internal/shortcut/mail/common.go | 17 +++ internal/shortcut/mail/mail.go | 31 +++-- ...e_contract_cross_platform_coverage_test.go | 110 ++++++++++++++++++ internal/shortcut/semantic_catalog.go | 25 ++-- .../shortcut/semantic_catalog_attendance.json | 20 ++-- .../semantic_catalog_coverage_test.go | 40 +++++++ internal/shortcut/semantic_catalog_mail.json | 12 +- internal/shortcut/smart/mail_strict.go | 16 +++ ...ail_strict_cross_platform_coverage_test.go | 76 +++++++++--- internal/shortcut/smart/search_mail.go | 11 +- internal/shortcut/smart/unread_mail.go | 12 +- internal/shortcut/types.go | 4 + internal/shortcut/usage/command.go | 72 ++++++------ internal/shortcut/usage/usage_test.go | 18 ++- 19 files changed, 428 insertions(+), 119 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 2c814688..3a968a32 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -7,7 +7,7 @@ ## 1. 执行摘要 -- Attendance 共审核 35 个源码 Shortcut;9 个具备公开条件,26 个保持 hidden/unavailable。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- Attendance 共审核 35 个源码 Shortcut;9 个具备 Agent 公开条件,26 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 10 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 - `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 - 已确认 7 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID,以及个人设置缺少逐场景权限发现与安全 fixture。 - 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 @@ -29,11 +29,11 @@ | 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | | 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | | 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | -| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均保持 hidden/unavailable | +| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | | 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | -| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture,全部 hidden | +| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | | 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | -| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 hidden/unavailable | +| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | | 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | ## 3. 下游需求明细 @@ -67,7 +67,7 @@ #### D. 临时处置 -`+get-adjustment-rule` 保持 hidden/unavailable 并从 Agent Schema 排除;`+search-adjustment-rule` 不再承诺详情入口可用。 +`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 ### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 @@ -100,7 +100,7 @@ #### D. 临时处置 -三个报表 Shortcut 均保持 hidden/unavailable;不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 +三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 ### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 @@ -173,7 +173,7 @@ 2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 -在上述证据完整前,6 个 Shortcut 均保持 hidden/unavailable;已实现的严格校验不等于已获得发布证据。 +在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 ### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 @@ -223,7 +223,7 @@ 3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 -能力发现和安全 fixture 到位前,`+get-self-setting` 保持 hidden/unavailable。 +能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 ## 4. Lark 对齐与平台差异 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index 58765a97..54ca7efe 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -8,7 +8,7 @@ ## 1. 执行摘要 -本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 `unavailable`;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go index ad297225..36d01563 100644 --- a/internal/shortcut/builtin/attendance_semantic_catalog_test.go +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -40,6 +40,19 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts)) } public := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+get-adjustment-rule": true, + "+get-checkin-record": true, + "+get-leave-records": true, + "+get-self-setting": true, + "+get-summary": true, + "+list-leave-types": true, + "+my-attendance": true, + "+query-report-data": true, + "+search-group": true, + "+this-month": true, + } var missing, stale []string for command, item := range registered { record, ok := source.Shortcuts[command] @@ -65,8 +78,14 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) } - } else if availability == shortcut.AvailabilityAvailable || !item.Hidden { - t.Errorf("%s: unavailable shortcut must stay hidden", command) + } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) } } for command := range source.Shortcuts { @@ -79,7 +98,7 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 9 { - t.Fatalf("public attendance shortcuts = %d, want 9", public) + if public != 9 || compatibilityVisible != 10 { + t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 9/10", public, compatibilityVisible) } } diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go index b83ec229..ef390ee2 100644 --- a/internal/shortcut/builtin/chat_semantic_catalog_test.go +++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go @@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct { Service string `json:"service"` Availability shortcut.Availability `json:"default_availability"` Shortcuts map[string]struct { - Disposition shortcut.SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk shortcut.Risk `json:"risk"` - Availability shortcut.Availability `json:"availability"` - Primary string `json:"primary"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition shortcut.SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk shortcut.Risk `json:"risk"` + Availability shortcut.Availability `json:"availability"` + Primary string `json:"primary"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible"` + Reviewed bool `json:"reviewed"` } `json:"shortcuts"` } diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go index 1d5676ae..1bac1b7e 100644 --- a/internal/shortcut/builtin/mail_semantic_catalog_test.go +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -38,6 +38,15 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( var missing, stale []string public := 0 unavailable := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+contact-list": true, + "+recent-mail": true, + "+tag-list": true, + "+template-list": true, + "+thread-list": true, + "+unread-mail": true, + } for command, item := range registered { record, ok := source.Shortcuts[command] if !ok { @@ -65,8 +74,8 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( } if availability == shortcut.AvailabilityUnavailable { unavailable++ - if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable { - t.Errorf("%s: unavailable shortcut remains visible", command) + if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) } if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || strings.TrimSpace(item.Contract.Interface.Reason) == "" { t.Errorf("%s: unavailable runtime interface is not explicit", command) @@ -75,6 +84,12 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) } } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -86,7 +101,7 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 8 || unavailable != 10 { - t.Fatalf("mail public/unavailable shortcuts = %d/%d, want 8/10", public, unavailable) + if public != 8 || unavailable != 10 || compatibilityVisible != 6 { + t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible) } } diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go index 1674f058..55274689 100644 --- a/internal/shortcut/mail/common.go +++ b/internal/shortcut/mail/common.go @@ -6,6 +6,7 @@ package mail import ( "encoding/json" "fmt" + "strconv" "strings" "time" @@ -100,6 +101,22 @@ func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required boo return nil } +func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) { + if !required && !rt.Changed(flag) { + return "", nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + _, err := mailStringPageSize(rt, flag, required) + return err +} + func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { if strings.TrimSpace(rt.Str(flag)) == "" { return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 9bd31f8d..186e7bc4 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -18,7 +18,6 @@ package mail import ( - "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -326,7 +325,7 @@ var UserSearch = shortcut.Shortcut{ {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, @@ -334,7 +333,7 @@ var UserSearch = shortcut.Shortcut{ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, }, Validate: func(rt *shortcut.RuntimeContext) error { - if err := mailValidatePageSize(rt, "limit", false); err != nil { + if err := mailValidateStringPageSize(rt, "limit", false); err != nil { return err } for _, name := range []string{"keyword", "employee-no"} { @@ -352,6 +351,10 @@ var UserSearch = shortcut.Shortcut{ `dws mail +user-search --email user@company.com --employee-no "E123456"`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", false) + if err != nil { + return err + } params := map[string]any{} if rt.Str("keyword") != "" { params["keyword"] = rt.Str("keyword") @@ -366,7 +369,7 @@ var UserSearch = shortcut.Shortcut{ params["cursor"] = rt.Str("cursor") } if rt.Changed("limit") { - params["size"] = fmt.Sprintf("%d", rt.Int("limit")) + params["size"] = size } data, err := rt.CallMCPData("mail", "search_mail_users", params) if err != nil { @@ -428,20 +431,24 @@ var TemplateList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, Validate: func(rt *shortcut.RuntimeContext) error { - return mailValidatePageSize(rt, "limit", true) + return mailValidateStringPageSize(rt, "limit", true) }, Tips: []string{ `dws mail +template-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": fmt.Sprintf("%d", rt.Int("limit")), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -506,20 +513,24 @@ var ContactList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量,必须在 1-100 之间", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, Validate: func(rt *shortcut.RuntimeContext) error { - return mailValidatePageSize(rt, "limit", true) + return mailValidateStringPageSize(rt, "limit", true) }, Tips: []string{ `dws mail +contact-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": fmt.Sprintf("%d", rt.Int("limit")), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go index 43693973..722ee756 100644 --- a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go +++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "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" @@ -266,6 +267,115 @@ func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T } } +func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) { + if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil { + t.Fatalf("omitted optional integer limit should remain valid: %v", err) + } + + tests := []struct { + name string + declaration *shortcut.Shortcut + args []string + tool string + response string + wantSize string + wantPresent bool + }{ + {name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true}, + {name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`}, + {name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true}, + {name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, flag := range tc.declaration.Flags { + if flag.Name != "limit" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default) + } + } + if !found { + t.Fatal("declaration missing --limit") + } + cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration)) + if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want 1", len(caller.calls)) + } + gotSize, present := caller.calls[0].args["size"] + if present != tc.wantPresent || (present && gotSize != tc.wantSize) { + t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}}, + {name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + {name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Execute(rt); err == nil { + t.Fatal("direct Execute accepted malformed string limit") + } + if len(caller.calls) != 0 { + t.Fatalf("direct Execute reached %d remote calls", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) { + for _, leaf := range []struct { + name string + args []string + }{ + {name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}}, + {name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}}, + {name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}}, + } { + for _, invalid := range []struct { + name string + value string + }{ + {name: "blank", value: " "}, + {name: "nonnumeric", value: "bad"}, + {name: "zero", value: "0"}, + {name: "negative", value: "-1"}, + {name: "too large", value: "101"}, + } { + t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + args := append(append([]string{}, leaf.args...), "--limit", invalid.value) + if err := runConfirmedMailWriteContract(t, caller, args...); err == nil { + t.Fatal("invalid legacy string limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls)) + } + }) + } + } +} + func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) { goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}` goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid") diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index b3226a80..12a00442 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -48,13 +48,14 @@ type semanticCatalogFile struct { } type semanticCatalogRecord struct { - Disposition SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk Risk `json:"risk"` - Availability Availability `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk Risk `json:"risk"` + Availability Availability `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` + Reviewed bool `json:"reviewed"` } var reviewedSemanticCatalog = mustLoadSemanticCatalogs( @@ -128,6 +129,13 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", command, record.Availability)) } + // A command that was already part of the visible CLI contract cannot be + // hidden in the same feature change merely because Agent publication is + // withdrawn. This narrow fact preserves historical discovery/argv while + // the unavailable Interface and public=false keep it out of Agent routes. + if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { + panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command)) @@ -146,6 +154,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { s.Availability = record.Availability s.PrimaryCommand = record.Primary s.SemanticReviewed = record.Reviewed - s.Hidden = !record.Public + s.CompatibilityVisible = record.CompatibilityVisible + s.Hidden = !record.Public && !record.CompatibilityVisible return s, true } diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json index a86e1603..8bd491f0 100644 --- a/internal/shortcut/semantic_catalog_attendance.json +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -13,17 +13,17 @@ "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, - "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index 61d00569..d555aced 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -121,6 +121,36 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, + "compatibility-visible public": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "compatibility_visible": true, + "reviewed": true + } + } + }`, + "compatibility-visible available": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": false, + "compatibility_visible": true, + "reviewed": true + } + } + }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -172,6 +202,16 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin } } +func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { + item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"}) + if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable { + t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok) + } + if InPublicCatalog(item.Service, item.Command) { + t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") + } +} + func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { caller := &runtimeReadCoverageCaller{} old := helpers.GetCaller() diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json index 4c1219a6..8e3b260a 100644 --- a/internal/shortcut/semantic_catalog_mail.json +++ b/internal/shortcut/semantic_catalog_mail.json @@ -5,18 +5,18 @@ "shortcuts": { "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, - "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go index cf7c3e63..538feaca 100644 --- a/internal/shortcut/smart/mail_strict.go +++ b/internal/shortcut/smart/mail_strict.go @@ -36,6 +36,22 @@ func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, require return nil } +func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) { + if !rt.Changed(flag) { + return fallback, nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error { + _, err := smartMailStringPageSize(rt, flag, "") + return err +} + func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { if strings.TrimSpace(rt.Str(flag)) == "" { return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go index e24ce128..4685f986 100644 --- a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -91,26 +91,62 @@ func TestCrossPlatformCoverageSmartMailUnavailableReadsMatchRuntimeBoundary(t *t } } -func TestCrossPlatformCoverageSmartMailDefaultsAreDeclared(t *testing.T) { - for _, tc := range []struct { - declaration *shortcut.Shortcut - flag string - }{ - {&SearchMail, "size"}, - {&UnreadMail, "size"}, - {&RecentMail, "limit"}, - } { +func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} { found := false - for _, flag := range tc.declaration.Flags { - if flag.Name == tc.flag { - found = true - if flag.Default != "20" { - t.Fatalf("%s --%s default=%q, want 20", tc.declaration.Command, tc.flag, flag.Default) - } + for _, flag := range declaration.Flags { + if flag.Name != "size" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default) } } if !found { - t.Fatalf("%s missing --%s", tc.declaration.Command, tc.flag) + t.Fatalf("%s missing --size", declaration.Command) + } + cmd := corecmd.New(shortcut.FromShortcut(*declaration)) + if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag) + } + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + wantSize string + }{ + {name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"}, + {name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"}, + {name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"}, + {name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize { + t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize) + } + }) + } + + for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} { + caller := &smartMailCursorCaller{responses: map[string]string{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "mail@example.invalid", "size": "bad"} + if declaration.Command == "+search-mail" { + values["query"] = "fixture" + } + rt := smartMailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command) + } + if len(caller.calls) != 0 { + t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls)) } } } @@ -260,7 +296,15 @@ func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) declaration shortcut.Shortcut args []string }{ + {name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}}, + {name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}}, {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}}, + {name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}}, + {name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}}, + {name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}}, + {name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}}, + {name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}}, + {name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}}, {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}}, {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}}, {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}}, diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index 9685847b..5a6a0040 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -15,7 +15,6 @@ package smart import ( "fmt" - "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -85,7 +84,7 @@ var SearchMail = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "返回条数上限(可选,默认 20,范围 1-100)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, Constraints: []shortcut.Constraint{ @@ -96,7 +95,7 @@ var SearchMail = shortcut.Shortcut{ if err := smartMailValidateRequiredText(rt, "query"); err != nil { return err } - return smartMailValidatePageSize(rt, "size", false) + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, @@ -107,6 +106,10 @@ var SearchMail = shortcut.Shortcut{ if err := rt.RequireAll("query"); err != nil { return err } + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") @@ -123,7 +126,7 @@ var SearchMail = shortcut.Shortcut{ args := map[string]any{ "email": email, "query": rt.Str("query"), - "size": strconv.Itoa(rt.Int("size")), + "size": size, } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index 51ac67cb..3208ab6c 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -14,8 +14,6 @@ package smart import ( - "strconv" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -82,12 +80,12 @@ var UnreadMail = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "返回条数上限(可选,默认 20,范围 1-100)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}}, Validate: func(rt *shortcut.RuntimeContext) error { - return smartMailValidatePageSize(rt, "size", false) + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +unread-mail`, @@ -95,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{ `dws mail +unread-mail --size 50`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") if email == "" { @@ -111,7 +113,7 @@ var UnreadMail = shortcut.Shortcut{ args := map[string]any{ "email": email, "query": "isRead:false", - "size": strconv.Itoa(rt.Int("size")), + "size": size, } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index b3545f36..9d9f5836 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -195,6 +195,10 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // CompatibilityVisible preserves a historically visible CLI command while + // keeping it out of the Agent/public Shortcut catalog. Such a command is + // shown only by `dws shortcut list --all` and must remain unavailable. + CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live // fixture evidence. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index cd59dd51..f4677bcf 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -56,7 +56,7 @@ func newListCommand() *cobra.Command { if svc != "" && s.Service != svc { continue } - if s.Hidden && !includeHidden { + if (s.Hidden || s.CompatibilityVisible) && !includeHidden { continue } rows = append(rows, newShortcutListRow(s)) @@ -75,23 +75,24 @@ func newListCommand() *cobra.Command { } type shortcutListRow struct { - Service string `json:"service"` - Command string `json:"command"` - CLIPath string `json:"cli_path"` - Product string `json:"product"` - Risk string `json:"risk"` - Confirmation string `json:"confirmation"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - Flags []shortcut.Flag `json:"flags"` - Constraints []shortcut.Constraint `json:"constraints"` - Examples []string `json:"examples"` - Public bool `json:"public"` - Disposition string `json:"disposition,omitempty"` - SemanticDelta string `json:"semantic_delta,omitempty"` - Availability string `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Reviewed bool `json:"reviewed"` + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` + Public bool `json:"public"` + Disposition string `json:"disposition,omitempty"` + SemanticDelta string `json:"semantic_delta,omitempty"` + Availability string `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Reviewed bool `json:"reviewed"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` } func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { @@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { constraints := append([]shortcut.Constraint{}, s.Constraints...) examples := append([]string{}, s.Tips...) return shortcutListRow{ - Service: s.Service, - Command: s.Command, - CLIPath: s.Service + " " + s.Command, - Product: product, - Risk: risk, - Confirmation: confirmation, - Description: s.Description, - Intent: s.Intent, - Flags: flags, - Constraints: constraints, - Examples: examples, - Public: !s.Hidden, - Disposition: string(s.Disposition), - SemanticDelta: s.SemanticDelta, - Availability: string(s.Availability), - Primary: s.PrimaryCommand, - Reviewed: s.SemanticReviewed, + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + Public: !s.Hidden && !s.CompatibilityVisible, + Disposition: string(s.Disposition), + SemanticDelta: s.SemanticDelta, + Availability: string(s.Availability), + Primary: s.PrimaryCommand, + Reviewed: s.SemanticReviewed, + CompatibilityVisible: s.CompatibilityVisible, } } diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index 3d1a103c..45040408 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -47,6 +47,11 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) Service: "coverage-usage", Command: "+hidden", }) + shortcut.Register(shortcut.Shortcut{ + Service: "coverage-usage", + Command: "+compatibility-visible", + CompatibilityVisible: true, + }) execute := func(args ...string) map[string]any { t.Helper() @@ -66,9 +71,20 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) publicRows := execute("--service", "coverage-usage") allRows := execute("--service", "coverage-usage", "--all") - if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 { + if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 2 { t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"]) } + rows := allRows["shortcuts"].([]any) + foundCompatibilityVisible := false + for _, value := range rows { + row := value.(map[string]any) + if row["command"] == "+compatibility-visible" { + foundCompatibilityVisible = row["compatibility_visible"] == true && row["public"] == false + } + } + if !foundCompatibilityVisible { + t.Fatalf("compatibility-visible shortcut row lost its non-public marker: %#v", rows) + } missing := execute("--service", "__missing__") if missing["count"].(float64) != 0 { t.Fatalf("missing service returned shortcuts: %#v", missing) From 9d43a12e08eede9eb40eecc4da0df89cc4dc71b1 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 23:34:11 +0800 Subject: [PATCH 157/267] fix(shortcut): address Attendance and Mail review findings --- .changes/1045-attendance-mail-shortcuts.md | 11 ++ .../attendance.md | 5 +- docs/shortcut-downstream-requirements/mail.md | 1 + internal/app/schema_shortcut_contract_test.go | 6 +- internal/shortcut/attendance/attendance.go | 11 +- ...ce_release_cross_platform_coverage_test.go | 140 ++++++++++++------ internal/shortcut/attendance/common.go | 58 ++------ ...anged_line_cross_platform_coverage_test.go | 77 +++++++++- internal/shortcut/smart/my_attendance.go | 2 +- internal/shortcut/smart/search_mail.go | 62 +++++++- 10 files changed, 258 insertions(+), 115 deletions(-) create mode 100644 .changes/1045-attendance-mail-shortcuts.md diff --git a/.changes/1045-attendance-mail-shortcuts.md b/.changes/1045-attendance-mail-shortcuts.md new file mode 100644 index 00000000..256619d4 --- /dev/null +++ b/.changes/1045-attendance-mail-shortcuts.md @@ -0,0 +1,11 @@ +--- +category: Changed +--- + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 3a968a32..bd97f131 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -246,7 +246,8 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核;最终 PR HEAD 仍从零复核 | -| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper/Execute 负向矩阵通过;raw 157 条、exact 156 条,逐对象精确等于 raw 的请求用户与本地日期范围子集,仅过滤 start-24h 一项;fresh zero 双层通过 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | reviewer 反馈后以脱敏 live 字段关系确认:旧轮唯一 `workDate=start-24h` 项的 `userCheckTime` 在请求范围内,属于跨午夜 OffDuty;night-shift、missing/wrong/out-of-range actual-time 与稳定 ID 负向回归通过,最终 clean HEAD 待从零复核 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日,并锁定 `request_range_mismatch` reason;最终 clean HEAD 待真实双层复核 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | | `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | @@ -259,7 +260,7 @@ | 叶子 | clean executable HEAD 双层证据 | 发布状态 | |---|---|---| | `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+check-record` | raw 157 条先全部通过集合、ID、用户与时间类型校验;exact 156 条逐对象等于 raw 的请求用户+本地日期范围子集,唯一过滤 start-24h 一项;fresh future zero 双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | 旧轮 raw 157→exact 156 的差异已被确认是错误过滤跨午夜夜班记录,不再作为 PASS 证据;修复后按 `userCheckTime` 绑定并保留该记录 | 最终 clean HEAD 必须从零复核 nonempty/zero 后才能恢复 `PASS` | | `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | | `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index 54ca7efe..f4a027cd 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -168,6 +168,7 @@ |---|---|---|---| | 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | | `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 clean HEAD 待真实自动解析复核 | | `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | | `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | | `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 7a2216d7..63f07c0d 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 422 + publicShortcutCount = 423 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 446 + schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 422 + publiclyDeliveredShortcutCount = 423 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index aeedf75a..b80e5b5c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -88,7 +88,7 @@ func flexDateTime(s string) (string, error) { } // dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date -// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +// resolves to the final millisecond before the next local calendar day. func dateToMillis(s string, endOfDay bool) (int64, error) { s = strings.TrimSpace(s) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { @@ -96,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) { } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { if endOfDay { - return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil } return t.UnixMilli(), nil } @@ -265,7 +265,7 @@ var CheckResult = shortcut.Shortcut{ return err } startMillis, _ := dayMillis(rt.Str("start")) - endMillis, _ := dayMillis(rt.Str("end")) + endMillis, _ := dateToMillis(rt.Str("end"), true) if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { return err } @@ -360,8 +360,7 @@ var CheckRecord = shortcut.Shortcut{ } startMillis, _ := dayMillis(rt.Str("start")) endMillis, _ := dateToMillis(rt.Str("end"), true) - items, err = attendanceFilterUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis) - if err != nil { + if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil { return err } return attendanceOutputCollection(rt, "records", items, true, nil, false, "") @@ -484,7 +483,7 @@ var ListApprove = shortcut.Shortcut{ requestedTypes[value] = struct{}{} } startMillis, _ := dayMillis(rt.Str("start")) - endMillis, _ := dayMillis(rt.Str("end")) + endMillis, _ := dateToMillis(rt.Str("end"), true) return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) }, "result") }, diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index df84c599..4557e9a9 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -13,6 +13,7 @@ import ( "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" @@ -71,6 +72,8 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { }{ {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false}, + {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false}, {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, {"missing time", []map[string]any{{"userId": "u1"}}, false}, {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, @@ -96,6 +99,8 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true}, + {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true}, {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, } for _, tc := range approvalCases { @@ -109,47 +114,43 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { } } -func TestCrossPlatformCoverageAttendanceCheckRecordOverfetchFilter(t *testing.T) { +func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) { const startMillis, endMillis = int64(1000), int64(2000) - items := []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 1}, - {"id": 2, "userId": "u1", "workDate": startMillis}, - {"id": 3, "userId": "u1", "workDate": endMillis}, - {"id": 4, "userId": "u1", "workDate": endMillis + 1}, - } - filtered, err := attendanceFilterUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "workDate", startMillis, endMillis) - if err != nil || len(filtered) != 2 || filtered[0]["id"] != 2 || filtered[1]["id"] != 3 { - t.Fatalf("filtered=%#v err=%v", filtered, err) + items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}} + if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil { + t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err) } invalid := []struct { - name string - items []map[string]any - start int64 - end int64 + name string + item map[string]any + reason string }{ - {"reversed request", nil, endMillis, startMillis}, - {"missing user", []map[string]any{{"workDate": startMillis}}, startMillis, endMillis}, - {"wrong user type", []map[string]any{{"userId": 1, "workDate": startMillis}}, startMillis, endMillis}, - {"empty user", []map[string]any{{"userId": " ", "workDate": startMillis}}, startMillis, endMillis}, - {"wrong user outside range", []map[string]any{{"userId": "u2", "workDate": startMillis - 1}}, startMillis, endMillis}, - {"missing time", []map[string]any{{"userId": "u1"}}, startMillis, endMillis}, - {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, startMillis, endMillis}, - {"zero time", []map[string]any{{"userId": "u1", "workDate": 0}}, startMillis, endMillis}, + {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"}, + {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"}, + {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"}, + {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"}, + {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"}, + {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"}, } for _, tc := range invalid { t.Run(tc.name, func(t *testing.T) { - if got, err := attendanceFilterUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", tc.start, tc.end); err == nil || got != nil { - t.Fatalf("filtered=%#v err=%v", got, err) + err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || !ok || typed.Reason != tc.reason { + t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason) } }) } } -func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfetch(t *testing.T) { +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) { values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} startMillis, _ := dayMillis(values["start"]) - endMillis, _ := dateToMillis(values["end"], true) execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { t.Helper() caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} @@ -182,33 +183,24 @@ func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfe return envelope, nil } - t.Run("keeps in-range and filters start minus 24 hours", func(t *testing.T) { + t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) { envelope, err := execute(t, []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, - {"id": 2, "userId": "u1", "workDate": startMillis}, + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000}, + {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis}, }) if err != nil { t.Fatal(err) } data, ok := envelope["data"].(map[string]any) records, recordsOK := data["records"].([]any) - if !ok || !recordsOK || data["count"] != float64(1) || len(records) != 1 || records[0].(map[string]any)["id"] != float64(2) { + if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) { t.Fatalf("data=%#v", envelope["data"]) } }) - t.Run("all valid overfetch becomes explicit empty", func(t *testing.T) { - envelope, err := execute(t, []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, - {"id": 2, "userId": "u1", "workDate": endMillis + 1}, - }) - if err != nil { - t.Fatal(err) - } - data, ok := envelope["data"].(map[string]any) - records, recordsOK := data["records"].([]any) - if !ok || !recordsOK || data["count"] != float64(0) || len(records) != 0 { - t.Fatalf("data=%#v", envelope["data"]) + t.Run("actual-time overfetch fails closed", func(t *testing.T) { + if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil { + t.Fatal("out-of-range actual check was silently filtered") } }) @@ -216,11 +208,12 @@ func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfe name string records []map[string]any }{ - {"missing identity outside range", []map[string]any{{"userId": "u1", "workDate": startMillis - 1}}}, - {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "workDate": startMillis - 1}}}, - {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "workDate": startMillis - 1}}}, - {"malformed time outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": "bad"}}}, - {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 2}, {"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}}, + {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}}, } for _, tc := range invalid { t.Run(tc.name, func(t *testing.T) { @@ -826,8 +819,19 @@ func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { startMillis, _ := dayMillis("2026-01-01") - endMillis, _ := dayMillis("2026-01-31") + endStartMillis, _ := dayMillis("2026-01-31") + endMillis, _ := dateToMillis("2026-01-31", true) + nextDayStartMillis, _ := dayMillis("2026-02-01") + finalDayNoon := endStartMillis + 12*60*60*1000 validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + if endMillis != nextDayStartMillis-1 { + t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1) + } + explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true) + explicitWant, _ := flexMillis("2026-01-31 12:34:56") + if err != nil || explicitEnd != explicitWant { + t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err) + } t.Run("check result bad execute bounds", func(t *testing.T) { for _, values := range []map[string]string{ @@ -874,6 +878,44 @@ func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { } }) + t.Run("end date includes the full business day", func(t *testing.T) { + checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon} + caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls) + } + + approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"} + approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000} + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + if err != nil || caller.calls != 1 { + t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls) + } + + checkRow["workDate"] = endMillis + caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls) + } + checkRow["workDate"] = nextDayStartMillis + if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 { + t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls) + } + + approval["beginTime"] = nextDayStartMillis + approval["endTime"] = nextDayStartMillis + 1000 + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" { + t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason) + } + }) + collectionCases := []struct { name string declaration shortcut.Shortcut @@ -881,7 +923,7 @@ func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { valid map[string]any invalid map[string]any }{ - {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}}, {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, } diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index 899c967c..a8f6d2fd 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -343,68 +343,32 @@ func attendanceValidateUserAndTimeBinding(items []map[string]any, operation stri for _, user := range requestedUsers { users[user] = struct{}{} } - for index, item := range items { - user, ok := item[userField].(string) - user = strings.TrimSpace(user) - if !ok || user == "" { - return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) - } - if _, requested := users[user]; !requested { - return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) - } - timestamp, ok := attendancePositiveInteger(item[timeField]) - if !ok || timestamp < startMillis || timestamp > endMillis { - return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) - } - } - return nil -} - -// attendanceFilterUserAndTimeBinding validates every raw row's request -// binding before filtering service-side range overfetch. Callers must validate -// stable identities for the complete raw collection first: an out-of-range row -// is allowed to be omitted from the projection, but it must never hide a -// malformed user/time binding or a row belonging to another requested user. -func attendanceFilterUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) ([]map[string]any, error) { - if startMillis > endMillis { - return nil, responsecheck.Error(operation, "invalid_request_range", "请求开始时间不能晚于结束时间") - } - users := make(map[string]struct{}, len(requestedUsers)) - for _, user := range requestedUsers { - users[strings.TrimSpace(user)] = struct{}{} - } - timestamps := make([]int64, len(items)) for index, item := range items { rawUser, present := item[userField] if !present { - return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) } user, ok := rawUser.(string) user = strings.TrimSpace(user) if !ok || user == "" { - return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) } if _, requested := users[user]; !requested { - return nil, responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) } rawTimestamp, present := item[timeField] if !present { - return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) } - timestamp, valid := attendancePositiveInteger(rawTimestamp) - if !valid { - return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + timestamp, ok := attendancePositiveInteger(rawTimestamp) + if !ok { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + if timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) } - timestamps[index] = timestamp } - filtered := make([]map[string]any, 0, len(items)) - for index, item := range items { - if timestamps[index] < startMillis || timestamps[index] > endMillis { - continue - } - filtered = append(filtered, item) - } - return filtered, nil + return nil } func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go index aa17517e..bd62a5f7 100644 --- a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -11,6 +11,7 @@ import ( "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -156,6 +157,39 @@ func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { } func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + want string + }{ + {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"}, + {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + } { + t.Run("mailbox "+tc.name, func(t *testing.T) { + got, err := searchMailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != tc.want { + t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err) + } + }) + } + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing": {"success": true}, + "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run("mailbox "+name, func(t *testing.T) { + if _, err := searchMailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } + for _, tc := range []struct { message map[string]any want any @@ -165,14 +199,14 @@ func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, - {message: map[string]any{}, want: ""}, + {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"}, } { got, err := searchMailFrom(tc.message) if err != nil || got != tc.want { t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) } } - for _, message := range []map[string]any{{"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} { if _, err := searchMailFrom(message); err == nil { t.Fatal("malformed sender unexpectedly succeeded") } @@ -212,6 +246,8 @@ func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { wantCalls int }{ {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1}, + {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1}, {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, @@ -286,6 +322,21 @@ func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, }, wantCalls: 3, }, + { + name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"}, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 2, + }, + { + name: "triage resolves string mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, { name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{ @@ -313,4 +364,26 @@ func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { } }) } + + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"}, + {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"}, + } { + t.Run(tc.name+" typed reason", func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}} + err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture") + typed, ok := err.(*apperrors.Error) + if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 { + reason := "" + if ok { + reason = typed.Reason + } + t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls)) + } + }) + } } diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 536da856..96eedb7d 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -117,7 +117,7 @@ var MyAttendance = shortcut.Shortcut{ }, } -// myAttendanceCurrentUserID extracts the current user's userId from a +// strictAttendanceCurrentUserID extracts the current user's userId from a // get_current_user_profile response. The gateway returns one of several shapes, // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index 5a6a0040..d0d2a22b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -171,24 +171,73 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - mailboxes, err := smartMailRows(data, "mail/list_user_mailboxes", "emailAccounts", "email") + mailboxes, err := searchMailMailboxAddresses(data) if err != nil { return "", err } if len(mailboxes) > 0 { - return searchMailFirstString(mailboxes[0], "email"), nil + return mailboxes[0], nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } +// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes: +// non-empty address strings and non-empty objects carrying an email field. +// The collection itself and every item remain fail-closed, so malformed rows +// cannot be skipped and misreported as an empty mailbox list. +func searchMailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := smartMailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + address = searchMailFirstString(typed, "email") + default: + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil +} + // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). func searchMailFrom(m map[string]any) (any, error) { raw, present := m["from"] switch v := raw.(type) { case string: - if v != "" { - return v, nil + if normalized := strings.TrimSpace(v); normalized != "" { + return normalized, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") @@ -212,7 +261,10 @@ func searchMailFrom(m map[string]any) (any, error) { if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { return s, nil } - return "", nil + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空") + } + return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段") } func searchMailFirstString(m map[string]any, keys ...string) string { From 83cfd104160a966c73f7788ae1afac9595441cfd Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 00:02:09 +0800 Subject: [PATCH 158/267] docs(shortcut): record final live review evidence --- docs/shortcut-downstream-requirements/attendance.md | 10 +++++----- docs/shortcut-downstream-requirements/mail.md | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index bd97f131..373059f4 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -245,9 +245,9 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核;最终 PR HEAD 仍从零复核 | -| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | reviewer 反馈后以脱敏 live 字段关系确认:旧轮唯一 `workDate=start-24h` 项的 `userCheckTime` 在请求范围内,属于跨午夜 OffDuty;night-shift、missing/wrong/out-of-range actual-time 与稳定 ID 负向回归通过,最终 clean HEAD 待从零复核 | -| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日,并锁定 `request_range_mismatch` reason;最终 clean HEAD 待真实双层复核 | +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 9 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | | `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | @@ -260,7 +260,7 @@ | 叶子 | clean executable HEAD 双层证据 | 发布状态 | |---|---|---| | `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+check-record` | 旧轮 raw 157→exact 156 的差异已被确认是错误过滤跨午夜夜班记录,不再作为 PASS 证据;修复后按 `userCheckTime` 绑定并保留该记录 | 最终 clean HEAD 必须从零复核 nonempty/zero 后才能恢复 `PASS` | +| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | | `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | | `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | @@ -270,7 +270,7 @@ pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 -上述 9 个公开入口均在相同 runtime tree 从零重跑,未继承 discovery PASS。rebase 后的最终 clean PR HEAD 仍需做一次发布复核;最终 SHA 写入 PR 证据而不反向伪造本文自身 commit。 +上述 9 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 ## 7. 安全与脱敏声明 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index f4a027cd..a5691179 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -167,10 +167,10 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | -| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | -| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 clean HEAD 待真实自动解析复核 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | | `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | | `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | | `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | | 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | @@ -179,12 +179,12 @@ | 公开入口 | exact Shortcut + owning raw 证据 | 状态 | |---|---|---| -| `+search-mail`, `+triage` | 各 20 条 known-nonempty 与 fresh guaranteed-zero;稳定 message ID 集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+user-search`, `+find-mail-user` | 各 19 条 known-nonempty 与 fresh guaranteed-zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | +| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | | `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | | `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | -后续 Attendance 代码、rebase 与非执行文档/生成块提交不改变 Mail runtime;发布前仍在最终 clean PR HEAD 从零重跑 8 个入口,最终 SHA 写入 PR 证据,不反向伪造本文自身 commit。 +8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 ## 7. 安全与脱敏声明 From c3328411c9f44802a1ab9a9e72c7d91157bdec03 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 10:12:00 +0800 Subject: [PATCH 159/267] fix(shortcut): close mail review and schema compatibility --- .../attendance.md | 1 + docs/shortcut-downstream-requirements/mail.md | 2 + internal/shortcut/attendance/attendance.go | 18 ++--- ...ce_release_cross_platform_coverage_test.go | 18 ++--- ...nce_strict_cross_platform_coverage_test.go | 10 ++- internal/shortcut/attendance/common.go | 56 +++++++++------- .../attendance_semantic_catalog_test.go | 9 +++ .../builtin/mail_semantic_catalog_test.go | 8 ++- internal/shortcut/mail/common.go | 21 ++++-- internal/shortcut/mail/mail.go | 18 ++--- ...anged_line_cross_platform_coverage_test.go | 59 +++++++++++++++++ ...ail_strict_cross_platform_coverage_test.go | 7 +- internal/shortcut/mail/reads.go | 65 +++++++++++++++++-- ...nce_strict_cross_platform_coverage_test.go | 5 +- internal/shortcut/smart/find_mail_user.go | 2 +- internal/shortcut/smart/mail_strict.go | 6 +- ...ail_strict_cross_platform_coverage_test.go | 7 +- internal/shortcut/smart/my_attendance.go | 6 +- internal/shortcut/smart/recent_mail.go | 14 ++-- .../shortcut/smart/this_month_attendance.go | 6 +- internal/shortcut/smart/unread_mail.go | 2 +- 21 files changed, 252 insertions(+), 88 deletions(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 373059f4..e5052697 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -8,6 +8,7 @@ ## 1. 执行摘要 - Attendance 共审核 35 个源码 Shortcut;9 个具备 Agent 公开条件,26 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 10 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- 这 10 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 - `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 - 已确认 7 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID,以及个人设置缺少逐场景权限发现与安全 fixture。 - 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index a5691179..a9fede56 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -10,6 +10,8 @@ 本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 +上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 + 仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 | ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index b80e5b5c..5d62242c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -987,9 +987,9 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Description: "根据补卡规则主键 ID 查询补卡规则详情", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", - Reason: "The detail service returns success=true with result=null for IDs obtained from the live adjustment-rule search; the command remains hidden until the downstream ID/result contract is repaired.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情", @@ -1293,9 +1293,9 @@ var SearchGroup = shortcut.Shortcut{ }, Description: "查询当前用户可管理的考勤组列表", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", - Reason: "Strict projection is implemented, but the current safe tenant has no known non-empty attendance-group fixture; live empty-only evidence is insufficient for publication.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的考勤组列表", @@ -1816,9 +1816,9 @@ var QueryReportData = shortcut.Shortcut{ }, Description: "根据字段查询考勤报表数据(仅管理员)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", - Reason: "The downstream report-column discovery returns null, so no verified column ID exists for an exact non-empty report-data E2E; the command remains hidden until that contract is repaired.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据字段查询考勤报表数据(仅管理员)", diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index 4557e9a9..29f1169b 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -345,17 +345,17 @@ func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { expected := map[*shortcut.Shortcut]map[string]string{ - &CheckResult: {"users": "userIds", "start": "workDateFrom", "end": "workDateTo", "offset": "offset", "limit": "limit"}, - &CheckRecord: {"users": "userIds", "start": "checkDateFrom", "end": "checkDateTo"}, - &ListApprove: {"users": "userIds", "types": "bizTypes", "start": "fromDate", "end": "toDate"}, - &GetApproveTemplate: {"type": "approveType"}, - &GetSchedule: {"users": "userIdList", "start": "workDateBegin", "end": "workDateEnd"}, - &SearchClass: {"query": "searchName", "filter-type": "filterType", "page": "pageIndex", "limit": "pageSize"}, + &CheckResult: {"users": "users", "start": "start", "end": "end", "offset": "offset", "limit": "limit"}, + &CheckRecord: {"users": "users", "start": "start", "end": "end"}, + &ListApprove: {"users": "users", "types": "types", "start": "start", "end": "end"}, + &GetApproveTemplate: {"type": "type"}, + &GetSchedule: {"users": "users", "start": "start", "end": "end"}, + &SearchClass: {"query": "query", "filter-type": "filterType", "page": "page", "limit": "limit"}, &GetClass: {"class-id": "classId"}, - &SearchAdjustmentRule: {"query": "name", "page": "currentPage", "limit": "pageSize"}, + &SearchAdjustmentRule: {"query": "query", "page": "page", "limit": "limit"}, &GetOvertimeRule: {"overtime-id": "overtimeId"}, - &SearchOvertimeRule: {"query": "name", "page": "currentPage", "limit": "pageSize"}, - &GetSelfSetting: {"setting-scene": "settingScene", "user": "userId"}, + &SearchOvertimeRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetSelfSetting: {"setting-scene": "settingScene", "user": "user"}, } for declaration, want := range expected { got := make(map[string]string, len(declaration.Contract.Parameters)) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go index 8da8915a..4ef13968 100644 --- a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -67,14 +68,17 @@ func attendanceDeclarationByName(name string) *shortcut.Shortcut { } func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { - for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetClass, &GetSelfSetting} { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSelfSetting} { if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) } - if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || strings.TrimSpace(declaration.Contract.Interface.Reason) == "" { - t.Errorf("%s must publish a precise unavailable Interface", declaration.Command) + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason { + t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command) } } + if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" { + t.Error("get-class must publish a precise unavailable Interface") + } } func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) { diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index a8f6d2fd..611c414f 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -16,6 +16,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) +const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed." + func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec { return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, @@ -58,17 +60,21 @@ func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec } func hardenPublicAttendanceContracts() { - CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "start", Property: "workDateFrom"}, {Name: "end", Property: "workDateTo"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} - CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "start", Property: "checkDateFrom"}, {Name: "end", Property: "checkDateTo"}} - ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIds"}, {Name: "types", Property: "bizTypes"}, {Name: "start", Property: "fromDate"}, {Name: "end", Property: "toDate"}} - GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "approveType"}} - GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "userIdList"}, {Name: "start", Property: "workDateBegin"}, {Name: "end", Property: "workDateEnd"}} - SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "searchName"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "pageIndex"}, {Name: "limit", Property: "pageSize"}} + // Composite Shortcut properties are the stable published workflow inputs. + // Execute owns the explicit adapters to the differently named MCP fields; + // redirecting an existing non-empty Schema property requires a versioned + // migration and cannot be folded into this hardening change. + CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} + CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}} + GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}} - SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "name"}, {Name: "page", Property: "currentPage"}, {Name: "limit", Property: "pageSize"}} + SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}} - SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "name"}, {Name: "page", Property: "currentPage"}, {Name: "limit", Property: "pageSize"}} - GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "userId"}} + SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}} collections := []struct { declaration *shortcut.Shortcut @@ -106,18 +112,22 @@ func hardenPublicAttendanceContracts() { GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} - for _, item := range []struct { - declaration *shortcut.Shortcut - reason string - }{ - {&GetSummary, "真实详情响应不回显请求用户、统计周期或统计类型,无法把结果精确绑定到请求。"}, - {&ListLeaveTypes, "当前安全身份只有非空假期类型列表,且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。"}, - {&GetLeaveRecords, "使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。"}, - {&GetCheckinRecord, "当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。"}, - {&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。"}, - {&GetSelfSetting, "query_self_setting 的 bossAttendStatNotify 场景在当前安全身份下返回 NO_PERMISSION,且缺少 capability discovery、逐场景权限合同与安全权限 fixture,无法完成全部公开场景的真实验证。"}, - } { - markAttendanceUnavailable(item.declaration, item.reason) + markAttendanceCompatibilityOnly(&GetSummary) + markAttendanceCompatibilityOnly(&ListLeaveTypes) + markAttendanceCompatibilityOnly(&GetLeaveRecords) + markAttendanceCompatibilityOnly(&GetCheckinRecord) + markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。") + markAttendanceCompatibilityOnly(&GetSelfSetting) +} + +func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, } } @@ -126,8 +136,8 @@ func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) { declaration.Contract.Result = nil declaration.Contract.Pagination = nil declaration.Contract.Interface = &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, Reason: reason, } } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go index 36d01563..5de9407f 100644 --- a/internal/shortcut/builtin/attendance_semantic_catalog_test.go +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -81,6 +81,15 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible { t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) } + if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil { + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + } if record.CompatibilityVisible { compatibilityVisible++ } diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go index 1bac1b7e..63e3dd03 100644 --- a/internal/shortcut/builtin/mail_semantic_catalog_test.go +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -77,8 +77,12 @@ func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface( if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable { t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) } - if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || strings.TrimSpace(item.Contract.Interface.Reason) == "" { - t.Errorf("%s: unavailable runtime interface is not explicit", command) + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) } if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly { t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go index 55274689..6ac9c5f9 100644 --- a/internal/shortcut/mail/common.go +++ b/internal/shortcut/mail/common.go @@ -19,6 +19,8 @@ import ( const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." +const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed." + func mailCollectionResult(collection, description string) *contract.ResultSpec { identity := mailCollectionIdentitySchema(collection) return &contract.ResultSpec{ @@ -496,10 +498,21 @@ func hardenPublicMailContracts() { } item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} } - markMailUnavailable(&ThreadList, "当前租户全部可见文件夹均有会话,且时间过滤未提供可靠零命中;没有已验证空文件夹 fixture,无法完成 known-nonempty + guaranteed-zero 双态发布审计。") - markMailUnavailable(&TagList, "标签列表没有 query/parent 过滤;当前没有专用空邮箱 fixture,不能用删除后的列表或越界状态冒充 guaranteed-zero。") - markMailUnavailable(&TemplateList, "模板列表没有 query 过滤;当前没有专用空邮箱 fixture,且模板删除后缺少 typed nonfound 清理证据,无法证明 guaranteed-zero。") - markMailUnavailable(&ContactList, "联系人列表没有 query 过滤;当前没有专用空邮箱 fixture,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。") + markMailCompatibilityOnly(&ThreadList) + markMailCompatibilityOnly(&TagList) + markMailCompatibilityOnly(&TemplateList) + markMailCompatibilityOnly(&ContactList) +} + +func markMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: mailCompatibilityInterfaceReason, + } } func markMailUnavailable(declaration *shortcut.Shortcut, reason string) { diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 186e7bc4..2aeab198 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -68,11 +68,11 @@ var ThreadList = shortcut.Shortcut{ }, Description: "列出指定邮箱文件夹下的邮件会话(thread)", Parameters: []contract.ParamDecl{ - {Name: "folder", Property: "folderId"}, - {Name: "limit", Property: "size"}, - {Name: "start", Property: "startTime"}, - {Name: "end", Property: "endTime"}, - {Name: "ascending", Property: "isAscending"}, + {Name: "folder", Property: "folder"}, + {Name: "limit", Property: "limit"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "ascending", Property: "ascending"}, }, Interface: &contract.InterfaceSpec{ Mode: "composite", @@ -169,7 +169,7 @@ var FolderList = shortcut.Shortcut{ PrimaryCLIPath: "mail +folder-list", }, Description: "列出顶层文件夹或指定父文件夹下的子文件夹", - Parameters: []contract.ParamDecl{{Name: "folder", Property: "folderId"}}, + Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -303,7 +303,7 @@ var UserSearch = shortcut.Shortcut{ Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", Parameters: []contract.ParamDecl{ {Name: "employee-no", Property: "employeeNo"}, - {Name: "limit", Property: "size"}, + {Name: "limit", Property: "limit"}, }, Interface: &contract.InterfaceSpec{ Mode: "composite", @@ -416,7 +416,7 @@ var TemplateList = shortcut.Shortcut{ PrimaryCLIPath: "mail +template-list", }, Description: "列出指定邮箱的所有邮件模板", - Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}}, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -498,7 +498,7 @@ var ContactList = shortcut.Shortcut{ PrimaryCLIPath: "mail +contact-list", }, Description: "列出指定邮箱的所有邮件联系人", - Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}}, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go index e91ef9bb..248ecd6c 100644 --- a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go +++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go @@ -49,6 +49,7 @@ func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranc }{ {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1}, {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1}, @@ -57,9 +58,11 @@ func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranc {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1}, {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2}, + {name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2}, {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1}, + {name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2}, {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1}, {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, @@ -100,6 +103,62 @@ func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranc } } +func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + { + name: "top string", + data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, + }, + { + name: "top object", + data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, + }, + { + name: "result string", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "result object", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + { + name: "data string", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "data object", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := mailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" { + t.Fatalf("addresses=%#v err=%v", got, err) + } + }) + } + + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing collection": {"success": true}, + "malformed collection": {"success": true, "emailAccounts": map[string]any{}}, + "blank string": {"success": true, "emailAccounts": []any{" "}}, + "missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}}, + "wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } +} + func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go index d9a39dff..4134fc99 100644 --- a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -46,7 +47,7 @@ func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testi } } -func TestCrossPlatformCoverageMailUnavailableReadsMatchRuntimeBoundary(t *testing.T) { +func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} { if declaration.OutputRollout != output.RolloutLegacyOnly { t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) @@ -54,8 +55,8 @@ func TestCrossPlatformCoverageMailUnavailableReadsMatchRuntimeBoundary(t *testin if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) } - if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { - t.Errorf("%s missing precise unavailable interface", declaration.Command) + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) } } } diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go index e41cd2f1..21feb47c 100644 --- a/internal/shortcut/mail/reads.go +++ b/internal/shortcut/mail/reads.go @@ -21,18 +21,73 @@ func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - mailboxes, err := mailRequireCollection(data, "mail/list_user_mailboxes", "emailAccounts") + mailboxes, err := mailMailboxAddresses(data) if err != nil { return "", err } if len(mailboxes) == 0 { return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定") } - email := mailFirstString(mailboxes[0], "email") - if email == "" { - return "", mailResponseError("mail/list_user_mailboxes", "missing_mailbox_identity", "首个邮箱对象缺少 email") + return mailboxes[0], nil +} + +// mailMailboxAddresses accepts the reviewed mailbox collection paths and item +// shapes used by the Mail gateway. The complete selected array is validated +// before the first address is returned so a malformed later row cannot be +// hidden behind an otherwise usable first mailbox. +func mailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err } - return email, nil + + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := mailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + rawEmail, present := typed["email"] + if present && rawEmail != nil { + var valid bool + address, valid = rawEmail.(string) + if !valid { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index)) + } + address = strings.TrimSpace(address) + } + default: + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil } func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go index c942339b..cb50b30c 100644 --- a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "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" @@ -31,8 +32,8 @@ func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *te if declaration.rollout != output.RolloutLegacyOnly { t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout) } - if declaration.hasResult || declaration.availability != "unavailable" { - t.Errorf("%s must omit Result and publish unavailable", declaration.name) + if declaration.hasResult || declaration.availability != contract.InterfaceAvailable { + t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name) } } } diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 038f470e..db2fe0d2 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -75,7 +75,7 @@ var FindMailUser = shortcut.Shortcut{ // Keep the published composite Shortcut property stable. Execute owns // the explicit query -> keyword adapter for search_mail_users. {Name: "query", Property: "query"}, - {Name: "limit", Property: "size"}, + {Name: "limit", Property: "limit"}, {Name: "cursor", Property: "cursor"}, }, Interface: &contract.InterfaceSpec{ diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go index 538feaca..0240bbff 100644 --- a/internal/shortcut/smart/mail_strict.go +++ b/internal/shortcut/smart/mail_strict.go @@ -306,14 +306,14 @@ func hardenSmartMail(declaration *shortcut.Shortcut, collection, description str } } -func markSmartMailUnavailable(declaration *shortcut.Shortcut, reason string) { +func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) { declaration.OutputRollout = output.RolloutLegacyOnly declaration.Contract.Result = nil declaration.Contract.Pagination = nil declaration.Contract.Interface = &contract.InterfaceSpec{ Mode: contract.InterfaceModeComposite, - Availability: contract.InterfaceUnavailable, - Reason: reason, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.", } } diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go index 4685f986..14022542 100644 --- a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "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" @@ -80,13 +81,13 @@ func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) } } -func TestCrossPlatformCoverageSmartMailUnavailableReadsMatchRuntimeBoundary(t *testing.T) { +func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} { if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command) } - if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { - t.Errorf("%s missing precise unavailable interface", declaration.Command) + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) } } } diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 96eedb7d..1cbc99a5 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -66,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{ }, Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", - Reason: "当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法排除 exact Shortcut 的空结果假阳性。", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index dbcab65f..5651fdc8 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -75,9 +75,9 @@ var RecentMail = shortcut.Shortcut{ }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", Parameters: []contract.ParamDecl{ - {Name: "limit", Property: "size"}, + {Name: "limit", Property: "limit"}, {Name: "email", Property: "email"}, - {Name: "folder", Property: "folderId"}, + {Name: "folder", Property: "folder"}, {Name: "cursor", Property: "cursor"}, }, Interface: &contract.InterfaceSpec{ @@ -96,7 +96,7 @@ var RecentMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, @@ -133,10 +133,14 @@ var RecentMail = shortcut.Shortcut{ // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") + } args := map[string]any{ "email": email, "folderId": folderID, - "size": rt.Int("limit"), + "size": limit, } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") @@ -239,6 +243,6 @@ func recentMailSenders(t map[string]any) (any, error) { func init() { hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") - markSmartMailUnavailable(&RecentMail, "当前租户全部可见文件夹均有会话,且本入口没有可控零命中筛选;缺少已验证空文件夹 fixture。") + markSmartMailCompatibilityOnly(&RecentMail) shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index e3c37b38..641c5576 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -65,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{ }, Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "unavailable", - Reason: "当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法排除 exact Shortcut 的空结果假阳性。", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index 3208ab6c..8299663d 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -151,6 +151,6 @@ var UnreadMail = shortcut.Shortcut{ func init() { hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") - markSmartMailUnavailable(&UnreadMail, "固定 isRead:false 查询没有可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态来制造零命中。") + markSmartMailCompatibilityOnly(&UnreadMail) shortcut.Register(UnreadMail) } From 272b6b8a709f79cc73f4b0e37d6dd28dd7bc1eeb Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 10:26:08 +0800 Subject: [PATCH 160/267] test(shortcut): lock public catalog count --- docs/shortcut-public-catalog.json | 4 +- .../public_catalog_consistency_test.go | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 internal/shortcut/public_catalog_consistency_test.go diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 0df657e8..5effbd9e 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T20:52:37.246788", - "count": 423, + "generated_at": "2026-08-19T10:24:45.967709", + "count": 424, "results": [ { "suite": "semantic", diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go new file mode 100644 index 00000000..afd3a2a1 --- /dev/null +++ b/internal/shortcut/public_catalog_consistency_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package shortcut + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate public catalog test file") + } + catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json") + raw, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("read public catalog: %v", err) + } + var catalog struct { + Count int `json:"count"` + Results []struct { + Service string `json:"service"` + Command string `json:"command"` + } `json:"results"` + } + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatalf("decode public catalog: %v", err) + } + if catalog.Count != len(catalog.Results) { + t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results)) + } + if catalog.Count != len(publicShortcutCatalog) { + t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog)) + } + seen := make(map[string]struct{}, len(catalog.Results)) + for i, row := range catalog.Results { + key := publicCatalogKey(row.Service, row.Command) + if row.Service == "" || row.Command == "" { + t.Fatalf("public catalog result %d has an empty identity", i) + } + if _, duplicate := seen[key]; duplicate { + t.Fatalf("public catalog result %d duplicates %q", i, key) + } + seen[key] = struct{}{} + if _, generated := publicShortcutCatalog[key]; !generated { + t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key) + } + } + for key := range publicShortcutCatalog { + if _, documented := seen[key]; !documented { + t.Fatalf("generated Go key is missing from public catalog JSON: %q", key) + } + } +} From 36c61fd8aca2d10e6bde681fcfa7616caf9f236c Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 10:38:36 +0800 Subject: [PATCH 161/267] fix(attendance): withhold unverifiable schedule query --- .changes/1045-attendance-mail-shortcuts.md | 5 ++- .../attendance.md | 41 ++++++++++++++++--- docs/shortcut-public-catalog.json | 14 +------ internal/app/schema_shortcut_contract_test.go | 4 +- ...ce_release_cross_platform_coverage_test.go | 23 +++++++++++ ...nce_strict_cross_platform_coverage_test.go | 5 +-- internal/shortcut/attendance/common.go | 3 +- .../attendance_semantic_catalog_test.go | 5 ++- internal/shortcut/public_catalog_generated.go | 1 - .../shortcut/semantic_catalog_attendance.json | 2 +- skills/mono/SKILL.md | 2 +- .../dingtalk-misc/references/attendance.md | 1 - 12 files changed, 74 insertions(+), 32 deletions(-) diff --git a/.changes/1045-attendance-mail-shortcuts.md b/.changes/1045-attendance-mail-shortcuts.md index 256619d4..e6b650d2 100644 --- a/.changes/1045-attendance-mail-shortcuts.md +++ b/.changes/1045-attendance-mail-shortcuts.md @@ -8,4 +8,7 @@ category: Changed that remain unavailable to agents. Mailbox auto-resolution now accepts both reviewed string and object response shapes, and Attendance date ranges cover the complete requested end date without dropping cross-midnight punches whose - actual check time is inside the requested range. + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index e5052697..e1fb245c 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -7,10 +7,10 @@ ## 1. 执行摘要 -- Attendance 共审核 35 个源码 Shortcut;9 个具备 Agent 公开条件,26 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 10 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 -- 这 10 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 +- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 - `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 -- 已确认 7 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID,以及个人设置缺少逐场景权限发现与安全 fixture。 +- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 - 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 | ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | @@ -22,6 +22,7 @@ | `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | | `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | | `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | +| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | ## 2. 用户任务与能力缺口总览 @@ -35,6 +36,7 @@ | 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | | 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | | 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | +| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | | 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | ## 3. 下游需求明细 @@ -226,6 +228,32 @@ 能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 +### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 + +#### A. 用户任务与现状 + +- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 +- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 +- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 +- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 +- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 + +#### B. 需要下游提供的合同 + +- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 +- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 +- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 +- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 + +#### C. 验收标准与临时处置 + +1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 +2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 +3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 +4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 + +下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 + ## 4. Lark 对齐与平台差异 | Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | @@ -246,7 +274,7 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 9 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | | `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | | `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | @@ -262,7 +290,8 @@ |---|---|---| | `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | | `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | -| `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | | `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | | `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | @@ -271,7 +300,7 @@ pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 -上述 9 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 +上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 ## 7. 安全与脱敏声明 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 5effbd9e..a13ab19a 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-19T10:24:45.967709", - "count": 424, + "generated_at": "2026-08-19T10:35:58.304269", + "count": 423, "results": [ { "suite": "semantic", @@ -972,16 +972,6 @@ "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。", "availability": "available" }, - { - "suite": "semantic", - "service": "attendance", - "command": "+get-schedule", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。", - "availability": "available" - }, { "suite": "semantic", "service": "attendance", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 63f07c0d..bb9127c4 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 423 + publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 423 + publiclyDeliveredShortcutCount = 422 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index 29f1169b..53d19d38 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -927,6 +927,29 @@ func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, } + t.Run("schedule null responses fail closed", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "literal null", response: `null`, reason: "empty_tool_response"}, + {name: "null collection", response: `{"success":true,"result":null}`, reason: "malformed_collection"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSchedule, values, tc.response) + typed, ok := err.(*apperrors.Error) + actualReason := "" + if ok { + actualReason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || actualReason != tc.reason { + t.Fatalf("err=%v calls=%d reason=%q want=%q", err, caller.calls, actualReason, tc.reason) + } + }) + } + }) for _, tc := range collectionCases { t.Run(tc.name+" valid", func(t *testing.T) { caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}})) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go index 4ef13968..900f2ea2 100644 --- a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -26,7 +26,6 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""}, - {GetSchedule.Command, GetSchedule.OutputRollout, GetSchedule.Contract.Result != nil, string(GetSchedule.Contract.Result.DataSchema), `"id"`, ""}, {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, @@ -59,7 +58,7 @@ func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *tes } func attendanceDeclarationByName(name string) *shortcut.Shortcut { - for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &GetSchedule, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { if declaration.Command == name { return declaration } @@ -68,7 +67,7 @@ func attendanceDeclarationByName(name string) *shortcut.Shortcut { } func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { - for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSelfSetting} { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} { if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) } diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index 611c414f..1b1a15e5 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -87,7 +87,6 @@ func hardenPublicAttendanceContracts() { {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, - {&GetSchedule, "schedules", "严格校验的员工排班记录", "id", "integer", ""}, {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"}, @@ -105,7 +104,6 @@ func hardenPublicAttendanceContracts() { GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult() GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} - GetSchedule.Contract.Result.SensitivePaths = []string{"schedules.className", "schedules.corpId", "schedules.userId"} SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} @@ -116,6 +114,7 @@ func hardenPublicAttendanceContracts() { markAttendanceCompatibilityOnly(&ListLeaveTypes) markAttendanceCompatibilityOnly(&GetLeaveRecords) markAttendanceCompatibilityOnly(&GetCheckinRecord) + markAttendanceCompatibilityOnly(&GetSchedule) markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。") markAttendanceCompatibilityOnly(&GetSelfSetting) } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go index 5de9407f..9a8b0f65 100644 --- a/internal/shortcut/builtin/attendance_semantic_catalog_test.go +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -46,6 +46,7 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu "+get-checkin-record": true, "+get-leave-records": true, "+get-self-setting": true, + "+get-schedule": true, "+get-summary": true, "+list-leave-types": true, "+my-attendance": true, @@ -107,7 +108,7 @@ func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSu if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 9 || compatibilityVisible != 10 { - t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 9/10", public, compatibilityVisible) + if public != 8 || compatibilityVisible != 11 { + t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible) } } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index d51bff57..51dd4a94 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -103,7 +103,6 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "attendance\u0000+check-result": {}, "attendance\u0000+get-approve-template": {}, "attendance\u0000+get-overtime-rule": {}, - "attendance\u0000+get-schedule": {}, "attendance\u0000+list-approve": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json index 8bd491f0..ab478fcf 100644 --- a/internal/shortcut/semantic_catalog_attendance.json +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -7,7 +7,7 @@ "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true}, - "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"规范日期范围并严格验证排班数组;每条要求唯一正整数 ID 且用户/工作日绑定请求,缺失或畸形响应失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 51262e41..cd21e292 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -44,7 +44,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aitable` | 93 | `dingtalk-aitable` | -| `attendance` | 9 | `dingtalk-misc` | +| `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | diff --git a/skills/multi/dingtalk-misc/references/attendance.md b/skills/multi/dingtalk-misc/references/attendance.md index 381046dd..994a6c4a 100644 --- a/skills/multi/dingtalk-misc/references/attendance.md +++ b/skills/multi/dingtalk-misc/references/attendance.md @@ -1419,7 +1419,6 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res | `dws attendance +check-result` | read | 查询用户打卡结果(迟到/早退/缺卡等) | | `dws attendance +get-approve-template` | read | 查询补卡/请假/加班/外出/出差审批提交链接 | | `dws attendance +get-overtime-rule` | read | 根据加班规则主键 ID 查询加班规则详情 | -| `dws attendance +get-schedule` | read | 获取指定用户一段时间内的排班记录 | | `dws attendance +list-approve` | read | 查询用户考勤审批单(补卡/加班/请假/出差外出) | | `dws attendance +search-adjustment-rule` | read | 查询当前用户可管理的补卡规则列表 | | `dws attendance +search-class` | read | 查询当前用户可管理的班次详情列表 | From 6d88c9968ea6b1180d0e38c7d14fd22562046837 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 10:47:59 +0800 Subject: [PATCH 162/267] docs(attendance): clarify schedule availability --- docs/shortcut-downstream-requirements/attendance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index e1fb245c..064abb6b 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -9,7 +9,7 @@ - Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 - 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 -- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`,并提供打卡流水、审批、排班、班次、规则、设置、假期和个人视图等更宽能力。 +- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 - 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 - 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 From 6dbc7ed82b7c2d15e08a9ad39939acd270ea2f6a Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 10:47:24 +0800 Subject: [PATCH 163/267] fix: harden shortcut functional workflows --- internal/helpers/aitable.go | 360 +++++++++++++++++- .../helpers/aitable_command_edges_test.go | 109 ++++++ internal/helpers/aitable_core_test.go | 6 +- .../helpers/aitable_workflow_write_test.go | 63 ++- internal/helpers/drive_folder_contract.go | 4 +- .../helpers/drive_folder_contract_test.go | 23 ++ internal/helpers/drive_push.go | 40 +- .../drive_push_sync_final_coverage_test.go | 23 ++ internal/helpers/drive_sync.go | 118 +++++- .../helpers/drive_sync_confirmation_test.go | 84 +++- internal/helpers/wiki.go | 7 +- internal/helpers/wiki_coverage_more_test.go | 27 +- internal/shortcut/aitable/aitable.go | 18 +- .../aitable/base_composite_e2e_test.go | 81 ++++ internal/shortcut/aitable/base_copy.go | 143 +++++++ internal/shortcut/aitable/primary_doc.go | 133 +++++++ internal/shortcut/aitable/record_batch.go | 84 +++- .../aitable/record_coverage_gap_test.go | 63 +++ .../aitable/record_upsert_by_key_e2e_test.go | 142 +++++-- .../aitable/remaining_coverage_gap_test.go | 5 + internal/shortcut/aitable/view_preset.go | 122 +++++- .../view_workflow_composite_e2e_test.go | 53 +++ internal/shortcut/aitabletarget/resolver.go | 3 + .../shortcut/aitabletarget/resolver_test.go | 18 + internal/shortcut/contact/contact.go | 9 +- .../contact/search_mobile_coverage_test.go | 33 ++ internal/shortcut/minutesdata/workflow.go | 3 +- .../shortcut/minutesdata/workflow_test.go | 20 + .../shortcut/todo/page_size_coverage_test.go | 51 +++ internal/shortcut/wiki/common.go | 24 ++ internal/shortcut/wiki/nodes.go | 2 +- .../shortcut/wiki/workflow_coverage_test.go | 23 ++ 32 files changed, 1783 insertions(+), 111 deletions(-) create mode 100644 internal/shortcut/aitable/base_copy.go create mode 100644 internal/shortcut/aitable/primary_doc.go create mode 100644 internal/shortcut/contact/search_mobile_coverage_test.go create mode 100644 internal/shortcut/todo/page_size_coverage_test.go diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index f981d7cd..d31b1f7e 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -7,6 +7,7 @@ import ( "io" "math" "os" + "reflect" "strconv" "strings" "time" @@ -126,6 +127,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) { return dsl, nil } +// executeAitableWorkflowPublish executes a publish exactly once, then requires +// the reviewed valid/flowId envelope before rendering any success output. +// create_workflow is non-idempotent, so transport uncertainty is never retried. +func executeAitableWorkflowPublish(toolName string, args map[string]any) error { + if deps.Caller.DryRun() { + return callMCPToolOnServer("aitable", toolName, args) + } + raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args) + if err != nil { + return err + } + var envelope map[string]any + if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil { + return fmt.Errorf("%s response is not a JSON object", toolName) + } + valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope) + if err != nil { + return fmt.Errorf("%s response validation failed: %w", toolName, err) + } + if !validFound { + return fmt.Errorf("%s response is missing valid", toolName) + } + if !valid { + return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName) + } + if flowID == "" { + return fmt.Errorf("%s response is missing a non-empty flowId", toolName) + } + return RenderLegacyMCPText(toolName, raw) +} + +func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) { + var visit func(map[string]any) error + visit = func(object map[string]any) error { + if raw, exists := object["valid"]; exists { + value, ok := raw.(bool) + if !ok { + return fmt.Errorf("valid must be boolean, got %T", raw) + } + if validFound && valid != value { + return fmt.Errorf("conflicting valid values") + } + valid, validFound = value, true + } + for _, key := range []string{"flowId", "workflowId"} { + raw, exists := object[key] + if !exists { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return fmt.Errorf("%s must be a non-empty string", key) + } + if flowID != "" && flowID != value { + return fmt.Errorf("conflicting workflow IDs") + } + flowID = value + } + for _, key := range []string{"data", "result", "response"} { + if nested, ok := object[key].(map[string]any); ok { + if err := visit(nested); err != nil { + return err + } + } + } + return nil + } + if envelope == nil { + return false, false, "", fmt.Errorf("empty response") + } + if err := visit(envelope); err != nil { + return false, false, "", err + } + return valid, validFound, flowID, nil +} + func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error { tableID, _ := cmd.Flags().GetString("table-id") tableID = strings.TrimSpace(tableID) @@ -1184,6 +1262,282 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error { return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil) } +func runAitableViewUpdateFilter(cmd *cobra.Command) error { + baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false) + if err != nil { + return err + } + jsonStr, _ := cmd.Flags().GetString("json") + if jsonStr == "" { + return fmt.Errorf("必须指定 --json 传入 filter JSON 数组") + } + var parsed any + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + return fmt.Errorf("--json 解析失败: %v", err) + } + cfgMap := map[string]any{"filter": parsed} + if err := normalizeViewConfigBlock(cfgMap); err != nil { + return err + } + filter, _ := cfgMap["filter"].([]any) + fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID) + if err != nil { + return err + } + if err := validateAitableViewFilter(filter, fieldTypes); err != nil { + return err + } + toolArgs := map[string]any{ + "baseId": baseID, "tableId": tableID, "viewId": viewID, + "config": map[string]any{"filter": filter}, + } + if deps.Caller.DryRun() { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs, + }) + } + if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil { + return err + } + var actual any + var readBackErr error + for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<<(attempt-1)) * time.Second + if backoff > 8*time.Second { + backoff = 8 * time.Second + } + aitableViewFilterReadbackSleep(backoff) + } + view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID) + if err != nil { + readBackErr = err + continue + } + actualViewID, _ := view["viewId"].(string) + if actualViewID != viewID { + readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID) + continue + } + actual = walkViewPath(view, "filter") + if persistedViewFilterMatches(actual, filter) { + readBackErr = nil + break + } + readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter)) + } + if readBackErr != nil { + return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"} + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true}, + }) +} + +const aitableViewFilterReadbackAttempts = 6 + +var aitableViewFilterReadbackSleep = time.Sleep + +func persistedViewFilterMatches(actual any, expected []any) bool { + if reflect.DeepEqual(actual, expected) { + return true + } + root, ok := actual.(map[string]any) + if !ok || root["operator"] != "and" { + return false + } + operands, ok := root["operands"].([]any) + return ok && reflect.DeepEqual(operands, expected) +} + +func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) { + raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID}) + if err != nil { + return nil, err + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err) + } + fields, ok := findAitableObjectList(payload, "fields", "fieldList") + if !ok { + return nil, fmt.Errorf("get_fields response is missing the fields collection") + } + types := make(map[string]string, len(fields)) + for index, field := range fields { + fieldID, _ := field["fieldId"].(string) + if strings.TrimSpace(fieldID) == "" { + fieldID, _ = field["id"].(string) + } + fieldType, _ := field["type"].(string) + if strings.TrimSpace(fieldType) == "" { + fieldType, _ = field["fieldType"].(string) + } + if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" { + return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index) + } + types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType) + } + return types, nil +} + +func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) { + wanted := make(map[string]bool, len(names)) + for _, name := range names { + wanted[strings.ToLower(name)] = true + } + var walk func(any) ([]map[string]any, bool) + walk = func(current any) ([]map[string]any, bool) { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if !wanted[strings.ToLower(key)] { + continue + } + items, ok := child.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + return nil, false + } + out = append(out, object) + } + return out, true + } + for _, child := range typed { + if found, ok := walk(child); ok { + return found, true + } + } + case []any: + for _, child := range typed { + if found, ok := walk(child); ok { + return found, true + } + } + } + return nil, false + } + return walk(value) +} + +func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error { + for index, raw := range filter { + condition, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("filter[%d] must be an object", index) + } + if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil { + return fmt.Errorf("filter[%d]: %w", index, err) + } + } + return nil +} + +func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error { + operator, _ := condition["operator"].(string) + operator = strings.TrimSpace(operator) + if operator == "" || !validFilterOperators[operator] { + return fmt.Errorf("unsupported operator %q", operator) + } + operands, ok := condition["operands"].([]any) + if !ok { + return fmt.Errorf("operator %s requires an operands array", operator) + } + if operator == "and" || operator == "or" { + return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator) + } + wantOperands := 2 + if operator == "exist" || operator == "un_exist" { + wantOperands = 1 + } + if len(operands) != wantOperands { + return fmt.Errorf("operator %s requires %d operands", operator, wantOperands) + } + fieldID, ok := operands[0].(string) + fieldID = strings.TrimSpace(fieldID) + if !ok || fieldID == "" { + return fmt.Errorf("operator %s requires a fieldId as its first operand", operator) + } + fieldType, exists := fieldTypes[fieldID] + if !exists { + return fmt.Errorf("filter references unknown fieldId %q", fieldID) + } + if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) { + return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID) + } + if operator == "any_of" || operator == "all_of" || operator == "none_of" { + if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") { + return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType) + } + if operator == "any_of" { + if values, ok := operands[1].([]any); ok { + if _, err := expandAitableMultiSelectAnyOf(fieldID, values); err != nil { + return err + } + return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views") + } + } + if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil { + return err + } + } + return nil +} + +func expandAitableMultiSelectAnyOf(fieldID string, values []any) ([]any, error) { + if len(values) == 0 { + return nil, fmt.Errorf("multipleSelect any_of array must not be empty") + } + expanded := make([]any, 0, len(values)) + for index, value := range values { + text, ok := value.(string) + text = strings.TrimSpace(text) + if !ok || text == "" { + return nil, fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index) + } + expanded = append(expanded, map[string]any{ + "operator": "eq", "operands": []any{fieldID, text}, + }) + } + return expanded, nil +} + +func isAitableDateFieldType(fieldType string) bool { + return strings.EqualFold(fieldType, "date") || + strings.EqualFold(fieldType, "createdTime") || + strings.EqualFold(fieldType, "lastModifiedTime") +} + +func isAitableDateFilterOperator(operator string) bool { + switch operator { + case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist": + return true + default: + return false + } +} + +func validateAitableMultiSelectFilterValue(value any) error { + if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" { + return nil + } + return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression") +} + +func compactJSON(value any) string { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%#v", value) + } + return string(raw) +} + func newAitableCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/aitable.json // products.aitable). Catalog assembly stamps provenance contract_final. @@ -4076,7 +4430,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`, 若传对象会自动 wrap 为数组;其他非法格式拒绝。`, Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`, RunE: func(cmd *cobra.Command, args []string) error { - return runAitableViewUpdateArray(cmd, "filter") + return runAitableViewUpdateFilter(cmd) }, } DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{ @@ -5282,7 +5636,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 } // create_workflow is non-idempotent. Bypass the retry wrapper to // prevent an uncertain first response from creating a duplicate. - return callMCPToolOnServer("aitable", "create_workflow", toolArgs) + return executeAitableWorkflowPublish("create_workflow", toolArgs) }, } DeclareLeafMetadata(workflowCreateCmd, LeafSpec{ @@ -5376,7 +5730,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" { toolArgs["locale"] = locale } - return callAitableTool("update_workflow", toolArgs) + return executeAitableWorkflowPublish("update_workflow", toolArgs) }, } DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{ diff --git a/internal/helpers/aitable_command_edges_test.go b/internal/helpers/aitable_command_edges_test.go index e821023c..262b0f09 100644 --- a/internal/helpers/aitable_command_edges_test.go +++ b/internal/helpers/aitable_command_edges_test.go @@ -361,3 +361,112 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) { _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...) } } + +func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) { + testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {}) + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"dws", "aitable"} + filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]` + fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}` + readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}` + + t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter) + if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" { + t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls) + } + config, _ := caller.calls[1].args["config"].(map[string]any) + if _, ok := config["filter"].([]any); !ok { + t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args) + } + }) + + t.Run("logical groups fail closed before write", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`) + if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" { + t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("unknown field is rejected before write", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`) + if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 { + t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) { + input := `[{"operator":"any_of","operands":["fldMulti","A"]}]` + stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}` + terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}` + caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input) + if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" { + t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("multi-select operator rejects text field", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`) + if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 { + t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("multi-select array fails closed before write", func(t *testing.T) { + input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]` + caller := &aitableTestCaller{responses: []string{fields}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input) + if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" { + t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("multi-select invalid array value fails before write", func(t *testing.T) { + input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]` + caller := &aitableTestCaller{responses: []string{fields}} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input) + if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 { + t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("date and system-time fields require date operators", func(t *testing.T) { + fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"} + for fieldID := range fieldTypes { + valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}} + if err := validateAitableViewFilter(valid, fieldTypes); err != nil { + t.Fatalf("date_eq for %s: %v", fieldID, err) + } + invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}} + if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") { + t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err) + } + } + }) + + t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields}, dryRun: true} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run") + if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" { + t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("read-back mismatch is not success", func(t *testing.T) { + responses := []string{fields, `{"success":true}`} + for range aitableViewFilterReadbackAttempts { + responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`) + } + caller := &aitableTestCaller{responses: responses} + err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter) + if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts { + t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls) + } + }) +} diff --git a/internal/helpers/aitable_core_test.go b/internal/helpers/aitable_core_test.go index 66f97454..774008b8 100644 --- a/internal/helpers/aitable_core_test.go +++ b/internal/helpers/aitable_core_test.go @@ -24,6 +24,7 @@ type aitableTestCaller struct { responses []string errors []error calls []aitableTestCall + dryRun bool } func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg } return textToolResult(response), nil } +func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + return c.CallTool(ctx, server, tool, args) +} func (*aitableTestCaller) Format() string { return "json" } -func (*aitableTestCaller) DryRun() bool { return false } +func (c *aitableTestCaller) DryRun() bool { return c.dryRun } func (*aitableTestCaller) Fields() string { return "" } func (*aitableTestCaller) JQ() string { return "" } diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index 1feff897..db95925a 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -23,27 +23,38 @@ type aitableWorkflowCall struct { } type aitableWorkflowCaller struct { - calls []aitableWorkflowCall + calls []aitableWorkflowCall + response string + dryRun bool } func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args}) + response := c.response + if response == "" { + response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}` + } return &edition.ToolResult{Content: []edition.ContentBlock{{ Type: "text", - Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`, + Text: response, }}}, nil } func (*aitableWorkflowCaller) Format() string { return "json" } -func (*aitableWorkflowCaller) DryRun() bool { return false } +func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun } func (*aitableWorkflowCaller) Fields() string { return "" } func (*aitableWorkflowCaller) JQ() string { return "" } func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) { + t.Helper() + caller := &aitableWorkflowCaller{} + return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...) +} + +func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error { t.Helper() testseam.Protect(t, &os.Args) - caller := &aitableWorkflowCaller{} InitDepsForTest(t, caller) deps.Out.w = io.Discard os.Args = append([]string{"dws", "aitable", "workflow"}, args...) @@ -51,6 +62,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (* cmd := newAitableCommand() cmd.PersistentFlags().String("format", "json", "output format") cmd.PersistentFlags().Bool("yes", false, "skip confirmation") + cmd.PersistentFlags().Bool("dry-run", false, "preview only") cmd.SilenceErrors = true cmd.SilenceUsage = true cmd.SetArgs(append([]string{"workflow"}, args...)) @@ -58,7 +70,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (* stdin = strings.NewReader("") } cmd.SetIn(stdin) - return caller, cmd.Execute() + return cmd.Execute() } func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) { @@ -92,6 +104,47 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) { } } +func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) { + tests := []struct { + name string + response string + want string + }{ + {name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"}, + {name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"}, + {name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"}, + {name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &aitableWorkflowCaller{response: tc.response} + err := runAitableWorkflowCommandWithCaller(t, caller, nil, + "create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`) + if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 { + t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls) + } + }) + } + + t.Run("update uses the same strict contract", func(t *testing.T) { + caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`} + err := runAitableWorkflowCommandWithCaller(t, caller, nil, + "update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`) + if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" { + t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls) + } + }) + + t.Run("dry-run does not call publish tool", func(t *testing.T) { + caller := &aitableWorkflowCaller{dryRun: true} + err := runAitableWorkflowCommandWithCaller(t, caller, nil, + "create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run") + if err != nil || len(caller.calls) != 0 { + t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls) + } + }) +} + func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) { caller, err := runAitableWorkflowCommand(t, nil, "edit-example") if err != nil { diff --git a/internal/helpers/drive_folder_contract.go b/internal/helpers/drive_folder_contract.go index 624db528..57d90d72 100644 --- a/internal/helpers/drive_folder_contract.go +++ b/internal/helpers/drive_folder_contract.go @@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec { "preview_kind":{"type":"string","description":"预览类型","enum":["plan"]}, "operation":{"type":"string","description":"预览的命令"}, "if_exists":{"type":"string","description":"远端同名文件处理策略"}, - "plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false} + "plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false} }, "additionalProperties":false }`), @@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec { "executed":{"type":"boolean","description":"是否执行了写操作","const":false}, "preview_kind":{"type":"string","description":"预览类型","enum":["plan"]}, "operation":{"type":"string","description":"预览的命令","const":"drive sync"}, - "plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false} + "plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false} }, "oneOf":[ {"required":["detection","diff","summary","items"]}, diff --git a/internal/helpers/drive_folder_contract_test.go b/internal/helpers/drive_folder_contract_test.go index 6495708d..3898cb81 100644 --- a/internal/helpers/drive_folder_contract_test.go +++ b/internal/helpers/drive_folder_contract_test.go @@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test if name == "sync" { assertDriveSyncResultSchema(t, final.Result.DataSchema) } + if name == "push" { + assertDrivePushResultSchema(t, final.Result.DataSchema) + } }) } } +func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) { + t.Helper() + properties := resultSchemaProperties(t, raw) + plan := schemaObjectProperty(t, properties, "plan") + summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary") + summaryProperties := schemaProperties(t, summary) + for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} { + if _, ok := summaryProperties[name]; !ok { + t.Fatalf("push plan summary schema is missing %s", name) + } + } +} + func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any { t.Helper() var schema map[string]any @@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) { planProperties := schemaProperties(t, plan) planDiff := schemaObjectProperty(t, planProperties, "diff") assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown") + planSummary := schemaObjectProperty(t, planProperties, "summary") + planSummaryProperties := schemaProperties(t, planSummary) + for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} { + if _, ok := planSummaryProperties[name]; !ok { + t.Fatalf("sync plan summary schema is missing %s", name) + } + } var schema map[string]any if err := json.Unmarshal(raw, &schema); err != nil { diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index e5e64e25..31431440 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -39,6 +39,11 @@ const ( pushActionSkipped = "skipped" // 按 --if-exists 跳过 pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded) pushActionFailed = "failed" + + pushActionPlannedUpload = "planned_upload" + pushActionPlannedOverwrite = "planned_overwrite" + pushActionPlannedSkip = "planned_skip" + pushActionPlannedFolderCreate = "planned_folder_create" ) // localPushFile 描述一个待推送的本地常规文件。 @@ -79,10 +84,13 @@ type drivePushItem struct { // drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。 type drivePushSummary struct { - Uploaded int `json:"uploaded"` - Skipped int `json:"skipped"` - Failed int `json:"failed"` - Aborted bool `json:"aborted"` + Uploaded int `json:"uploaded"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Aborted bool `json:"aborted"` + PlannedUploads int `json:"planned_uploads,omitempty"` + PlannedSkips int `json:"planned_skips,omitempty"` + PlannedFolders int `json:"planned_folders,omitempty"` } type drivePushResult struct { @@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]* continue } plannedFolders[dir] = "dry-run-planned-folder" - plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated}) + plan.Summary.PlannedFolders++ + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate}) } for _, lf := range localFiles { size := lf.Size @@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]* plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"}) continue } - action := pushActionUploaded + action := pushActionPlannedUpload if rf, exists := remoteFiles[lf.RelPath]; exists { switch ifExists { case ifExistsSkip: - action = pushActionSkipped + action = pushActionPlannedSkip case ifExistsSmart: if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis { - action = pushActionSkipped + action = pushActionPlannedSkip } else { - action = pushActionOverwritten + action = pushActionPlannedOverwrite } case ifExistsOverwrite: - action = pushActionOverwritten + action = pushActionPlannedOverwrite } } - if action == pushActionSkipped { - plan.Summary.Skipped++ + if action == pushActionPlannedSkip { + plan.Summary.PlannedSkips++ } else { - plan.Summary.Uploaded++ + plan.Summary.PlannedUploads++ } plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size}) } @@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin if _, err := file.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("failed to seek upload file: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file) + // net/http owns and closes Request.Body after RoundTrip. The *os.File belongs + // to pushUploadFilePinned, which must stat it after PUT before commit_upload; + // wrap it in a no-op closer so HTTP completion cannot close that shared handle. + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file)) if err != nil { return fmt.Errorf("failed to create upload request: %w", err) } diff --git a/internal/helpers/drive_push_sync_final_coverage_test.go b/internal/helpers/drive_push_sync_final_coverage_test.go index 715e4362..199b4261 100644 --- a/internal/helpers/drive_push_sync_final_coverage_test.go +++ b/internal/helpers/drive_push_sync_final_coverage_test.go @@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing } } +func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + _, _ = io.Copy(io.Discard, request.Body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "payload") + mustWrite(t, path, "payload") + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil { + t.Fatalf("PUT failed: %v", err) + } + if _, err := file.Stat(); err != nil { + t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err) + } +} + func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) { t.Run("local target preflight lstat error and directory", func(t *testing.T) { _, root, _ := openDriveFinalCoverageRoot(t) diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index dad77166..2bf8b8eb 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -65,6 +65,14 @@ const ( syncActionRenamedLocal = "renamed_local" syncActionSkipped = "skipped" syncActionFailed = "failed" + + syncActionPlannedDownload = "planned_download" + syncActionPlannedUpload = "planned_upload" + syncActionPlannedOverwrite = "planned_overwrite" + syncActionPlannedFolderCreate = "planned_folder_create" + syncActionPlannedRenameLocal = "planned_rename_local" + syncActionPlannedSkip = "planned_skip" + syncActionDecisionRequired = "decision_required" ) // driveSyncItem 是输出 items[] 中每条操作的明细。 @@ -77,10 +85,14 @@ type driveSyncItem struct { // driveSyncSummary 是各方向的计数汇总。 type driveSyncSummary struct { - Pulled int `json:"pulled"` - Pushed int `json:"pushed"` - Skipped int `json:"skipped"` - Failed int `json:"failed"` + Pulled int `json:"pulled"` + Pushed int `json:"pushed"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + PlannedPulls int `json:"planned_pulls,omitempty"` + PlannedPushes int `json:"planned_pushes,omitempty"` + PlannedSkips int `json:"planned_skips,omitempty"` + PlannedFolders int `json:"planned_folders,omitempty"` } // driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。 @@ -267,6 +279,10 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { // --dry-run:只算差异、不执行任何同步动作。 if deps.Caller.DryRun() { + if len(preflight) == 0 && res.Summary.Failed == 0 { + appendDriveSyncDryRunPlan(&res, localDirs, localByRel, remoteFiles, remoteFolders, + newLocal, newRemote, modified, unknown, onConflict) + } return deps.Out.PrintJSON(driveSyncDryRunResult{ DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res, }) @@ -394,6 +410,100 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { return nil } +func appendDriveSyncDryRunPlan( + res *driveSyncResult, + localDirs []string, + localByRel map[string]localPushFile, + remoteFiles map[string]*remoteFile, + remoteFolders map[string]string, + newLocal, newRemote, modified, unknown []string, + onConflict string, +) { + for _, rel := range unknown { + res.Summary.PlannedSkips++ + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict, + Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)", + }) + } + + plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs)) + for rel, fileID := range remoteFolders { + plannedFolders[rel] = fileID + } + for _, dir := range localDirs { + if _, ok := plannedFolders[dir]; ok { + continue + } + parentRel, _ := splitRel(dir) + if plannedFolders[parentRel] == "" { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) + continue + } + plannedFolders[dir] = "dry-run-planned-folder" + res.Summary.PlannedFolders++ + res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush}) + } + + for _, rel := range newRemote { + res.Summary.PlannedPulls++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) + } + for _, rel := range newLocal { + parentRel, _ := splitRel(rel) + if plannedFolders[parentRel] == "" { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) + continue + } + res.Summary.PlannedPushes++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush}) + } + + occupied := make(map[string]bool, len(localByRel)+len(localDirs)+len(remoteFiles)+len(remoteFolders)) + for rel := range localByRel { + occupied[rel] = true + } + for _, rel := range localDirs { + occupied[rel] = true + } + for rel := range remoteFiles { + occupied[rel] = true + } + for rel := range remoteFolders { + if rel != "" { + occupied[rel] = true + } + } + for _, rel := range modified { + switch onConflict { + case syncConflictRemoteWins: + res.Summary.PlannedPulls++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) + case syncConflictLocalWins: + res.Summary.PlannedPushes++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict}) + case syncConflictKeepBoth: + candidate := driveSyncSuffixedRel(rel, remoteFiles[rel].FileID, occupied) + occupied[candidate] = true + res.Summary.PlannedPulls++ + res.Items = append(res.Items, + driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict}, + driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}, + ) + case syncConflictAsk: + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict, + Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip", + }) + default: + res.Summary.PlannedSkips++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict}) + } + } +} + // syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。 // 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。 func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool { diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 8c22e83b..dec9633f 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -3,6 +3,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "os" "path/filepath" "strings" @@ -189,6 +190,83 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin } } +func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "local") + caller := syncCaller(nil) + caller.dryRun = true + + var out bytes.Buffer + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"}) + cmd := findDriveSubcommand(t, "push") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run push: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("decode dry-run push: %v\n%s", err, out.String()) + } + plan := payload["plan"].(map[string]any) + summary := plan["summary"].(map[string]any) + if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) { + t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary) + } + items := plan["items"].([]any) + if got := items[0].(map[string]any)["action"]; got != "planned_upload" { + t.Fatalf("dry-run action = %v, want planned_upload", got) + } +} + +func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "local") + mustWrite(t, filepath.Join(dir, "shared.txt"), "shared") + if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil { + t.Fatal(err) + } + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`, + }) + caller.dryRun = true + + var out bytes.Buffer + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"}) + cmd := findDriveSubcommand(t, "sync") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run sync: %v", err) + } + + var payload driveSyncDryRunResult + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("decode dry-run sync: %v\n%s", err, out.String()) + } + if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 { + t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed) + } + if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 || + payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 { + t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary) + } + actions := map[string]int{} + for _, item := range payload.Plan.Items { + actions[item.Action]++ + } + if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 || + actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 { + t.Fatalf("dry-run action plan = %#v", payload.Plan.Items) + } + for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} { + if calls := caller.callsFor(tool); len(calls) != 0 { + t.Fatalf("dry-run called %s: %#v", tool, calls) + } + } +} + func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "skip.txt"), "skip") @@ -242,7 +320,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { []string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil { t.Fatalf("skip plan: %v", err) } - if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) { + if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) { t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text) } out.Reset() @@ -250,14 +328,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { files("smart-skip.txt", "smart-overwrite.txt")); err != nil { t.Fatalf("smart plan: %v", err) } - if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) { + if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) { t.Fatalf("smart plan did not cover skip/overwrite: %s", text) } out.Reset() if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil { t.Fatalf("overwrite plan: %v", err) } - if !strings.Contains(out.String(), pushActionOverwritten) { + if !strings.Contains(out.String(), pushActionPlannedOverwrite) { t.Fatalf("overwrite plan must overwrite existing file: %s", out.String()) } } diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 3c8dc9cf..8d3bac3e 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -10,6 +10,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "role"); err != nil { return err } + role := normalizePermissionRole(mustGetFlag(cmd, "role")) + if role == "OWNER" { + return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加") + } userIds, err := collectUserIDs(cmd) if err != nil { return err } return callMCPTool("add_member", map[string]any{ "workspaceId": workspaceID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), + "roleId": role, "userIds": userIds, }) }, diff --git a/internal/helpers/wiki_coverage_more_test.go b/internal/helpers/wiki_coverage_more_test.go index 9ebd7561..446008f2 100644 --- a/internal/helpers/wiki_coverage_more_test.go +++ b/internal/helpers/wiki_coverage_more_test.go @@ -1,11 +1,13 @@ package helpers import ( + stderrors "errors" "io" "os" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) { } func executeWikiEdge(t *testing.T, args ...string) error { + t.Helper() + _, err := executeWikiEdgeWithCaller(t, args...) + return err +} + +func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) { t.Helper() oldDeps := deps oldArgs := os.Args - InitDeps(&scriptedToolCaller{}) + caller := &scriptedToolCaller{} + InitDeps(caller) deps.Out.w = io.Discard deps.Out.errW = io.Discard t.Cleanup(func() { @@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error { root.SetIn(os.Stdin) root.SetArgs(args) os.Args = append([]string{"dws", "wiki"}, args...) - return root.Execute() + return caller, root.Execute() } func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) { @@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) { } } +func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) { + caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER") + if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") { + t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err) + } + var appErr *apperrors.Error + if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("member add OWNER error = %#v, want validation category", err) + } + if caller.calls != 0 { + t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls) + } +} + func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) { oldStdin := os.Stdin t.Cleanup(func() { os.Stdin = oldStdin }) diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index ac7443da..c5c20534 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{ }, Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("copy_base", map[string]any{ - "baseId": rt.Str("base-id"), - "targetFolderId": rt.Str("target-folder-id"), - "onlyCopyMeta": rt.Bool("only-struct"), - }) + return executeBaseCopy(rt) }, } @@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{ Command: "+record-query", Product: serverMain, Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)", - Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。", + Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)", - UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"}, + UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{ "dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50", @@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{ }, Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_primary_doc", map[string]any{ - "baseId": rt.Str("base-id"), - "tableId": rt.Str("table-id"), - "recordId": rt.Str("record-id"), - }) + return executeRecordPrimaryDocGet(rt) }, } @@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) { return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item) } row := map[string]any{} - if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok { + if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok { row["workflowId"] = v } if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok { diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index 20c6965d..0bd20ea5 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -79,6 +79,87 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) { }) } +func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) { + t.Run("verified copy", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"fileId":"folder","type":"FOLDER"}}`}, + {text: `{"data":{"newBaseId":"new-base"}}`}, + {text: `{"data":{"baseId":"new-base","tables":[]}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" { + t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("missing new ID is unknown without read-back", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"fileId":"folder","type":"folder"}}`}, + {text: `{"success":true}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"fileId":"folder","type":"folder"}}`}, + {err: errors.New("Invalid target folder ID")}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable { + t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("rejects a non-folder target before write", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("rejects target metadata without a supported type", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable { + t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("rejects URL target before transport", func(t *testing.T) { + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes") + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) { + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes") + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable { + t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls) + } + }) +} + func bootstrapFields(count int) []any { fields := make([]any, 0, count) for index := 0; index < count; index++ { diff --git a/internal/shortcut/aitable/base_copy.go b/internal/shortcut/aitable/base_copy.go new file mode 100644 index 00000000..c37f3a1e --- /dev/null +++ b/internal/shortcut/aitable/base_copy.go @@ -0,0 +1,143 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + "unicode" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func executeBaseCopy(rt *shortcut.RuntimeContext) error { + baseID := strings.TrimSpace(rt.Str("base-id")) + targetFolderID := strings.TrimSpace(rt.Str("target-folder-id")) + if !validCompositeOpaqueID(baseID) { + return apperrors.NewValidation("--base-id 不是合法的 Base ID") + } + if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) { + return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值") + } + params := map[string]any{ + "baseId": baseID, + "targetFolderId": targetFolderID, + "onlyCopyMeta": rt.Bool("only-struct"), + } + result := newCompositeResult("base_copy") + result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID} + result.Plan = []compositeStep{ + {Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}}, + {Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params}, + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + + targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID}) + if err != nil { + return err + } + actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid") + if actualTargetID == "" || actualTargetID != targetFolderID { + return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid", + apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"), + apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false)) + } + targetType := findStringByKeys(targetData, "type", "dentryType", "fileType") + if targetType == "" { + return apperrors.NewAPI("drive/get_file_info response is missing the target node type", + apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"), + apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false)) + } + if !strings.EqualFold(targetType, "folder") { + return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType), + apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"), + apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false)) + } + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}}) + + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params) + newBaseID := findStringByKeys(writeData, "newBaseId") + if newBaseID == "" && copyBaseRejectedTarget(writeErr) { + return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid", + apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"), + apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"), + apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr), + apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe")) + } + if newBaseID == "" || !validCompositeOpaqueID(newBaseID) { + cause := fmt.Errorf("copy_base response is missing a valid newBaseId") + if writeErr != nil { + cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr) + } + result.Status = "unknown" + return compositeError(result, cause, false) + } + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID}) + + readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID}) + if verifyErr == nil { + actualID := findStringByKeys(readBack, "baseId") + if actualID == "" { + verifyErr = fmt.Errorf("get_base read-back is missing baseId") + } else if actualID != newBaseID { + verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID) + } + } + if verifyErr != nil { + result.Status = "partial_success" + result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()} + if writeErr != nil { + result.Warnings = append(result.Warnings, "copy response error: "+writeErr.Error()) + } + return compositeError(result, verifyErr, false) + } + + result.CompletedCount = 2 + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}}) + result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID} + result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack} + if writeErr != nil { + result.Status = "recovered" + result.Warnings = append(result.Warnings, "copy response was an error, but the new Base was proven by exact read-back") + } + return rt.Output(result) +} + +func copyBaseRejectedTarget(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "invalid target folder") || + strings.Contains(message, "invalid targetfolder") +} + +func validCompositeOpaqueID(value string) bool { + if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") { + return false + } + for _, r := range value { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return false + } + } + return true +} + +func pureNumericID(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/shortcut/aitable/primary_doc.go b/internal/shortcut/aitable/primary_doc.go new file mode 100644 index 00000000..0e2f8eab --- /dev/null +++ b/internal/shortcut/aitable/primary_doc.go @@ -0,0 +1,133 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error { + baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id") + fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID}) + if err != nil { + return err + } + fields, found := findNamedObjectList(fieldsData, "fields", "fieldList") + if !found { + return apperrors.NewAPI("get_fields response is missing the fields collection", + apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"), + apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false)) + } + primaryFieldIDs := make([]string, 0, 1) + for index, field := range fields { + fieldType := stringValue(field, "type", "fieldType") + if !strings.EqualFold(fieldType, "primaryDoc") { + continue + } + fieldID := stringValue(field, "fieldId", "id") + if fieldID == "" { + return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index), + apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"), + apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false)) + } + primaryFieldIDs = append(primaryFieldIDs, fieldID) + } + + recordParams := map[string]any{ + "baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID}, + } + if len(primaryFieldIDs) > 0 { + recordParams["fieldIds"] = primaryFieldIDs + } + window, err := queryRecordWindow(rt, recordParams, 1) + if err != nil { + return err + } + records := window.Records + if len(records) == 0 { + return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID), + apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"), + apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false)) + } + if len(records) != 1 || recordID(records[0]) != requestedRecordID { + return apperrors.NewAPI("record preflight did not return the exact requested record", + apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"), + apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false)) + } + if len(primaryFieldIDs) == 0 { + return rt.Output(map[string]any{ + "status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil, + }) + } + data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{ + "baseId": baseID, "tableId": tableID, "recordId": requestedRecordID, + }) + if err != nil { + if knownPrimaryDocUnassociatedError(err) { + return rt.Output(map[string]any{ + "status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil, + }) + } + return err + } + nodeID := findStringByKeys(data, "nodeId", "dentryUuid") + if nodeID == "" { + if knownPrimaryDocUnassociatedData(data) { + return rt.Output(map[string]any{ + "status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil, + }) + } + return apperrors.NewAPI("get_primary_doc response is missing nodeId", + apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"), + apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false)) + } + return rt.Output(map[string]any{ + "status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID, + }) +} + +func knownPrimaryDocUnassociatedError(err error) bool { + message := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(message, "no record") || strings.Contains(message, "unassociated") +} + +func knownPrimaryDocUnassociatedData(value any) bool { + var walk func(any) bool + walk = func(current any) bool { + switch typed := current.(type) { + case map[string]any: + if exists, ok := typed["exists"].(bool); ok && !exists { + return true + } + for _, key := range []string{"nodeId", "dentryUuid"} { + if nodeID, exists := typed[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") { + return true + } + } + if status, ok := typed["status"].(string); ok { + status = strings.ToLower(strings.TrimSpace(status)) + if status == "unassociated" || status == "no_record" { + return true + } + } + for _, child := range typed { + if walk(child) { + return true + } + } + case []any: + for _, child := range typed { + if walk(child) { + return true + } + } + } + return false + } + return walk(value) +} diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 63f7d92f..7ff35ebd 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { return rt.Output(result) } - for offset := 0; offset < len(ids); offset += recordBatchSize { - end := minInt(offset+recordBatchSize, len(ids)) - batch := ids[offset:end] + existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids) + if err != nil { + return err + } + existingSet := make(map[string]bool, len(existingIDs)) + for _, id := range existingIDs { + existingSet[id] = true + } + missingIDs := make([]string, 0, len(ids)-len(existingIDs)) + for _, id := range ids { + if !existingSet[id] { + missingIDs = append(missingIDs, id) + } + } + result.Resolved = map[string]any{ + "existingRecordIds": existingIDs, + "missingRecordIds": missingIDs, + } + result.Plan = nil + for offset := 0; offset < len(existingIDs); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(existingIDs)) + result.Plan = append(result.Plan, compositeStep{ + Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records", + Status: "planned", Offset: offset, Count: end - offset, + }) + } + if len(existingIDs) == 0 { + result.Status = "unchanged" + result.Executed = false + result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)} + result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0} + return rt.Output(result) + } + + for offset := 0; offset < len(existingIDs); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(existingIDs)) + batch := existingIDs[offset:end] writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{ "baseId": baseID, "tableId": tableID, "recordIds": batch, }) @@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { step.Status = "unknown" result.CompletedSteps = append(result.CompletedSteps, step) result.CompletedCount = offset - result.FailedCount = len(ids) - offset + result.FailedCount = len(existingIDs) - offset result.Status = "unknown" if offset > 0 { result.Status = "partial_success" @@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { result.CompletedCount = end result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch}) } - result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)} - result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)} + result.Verification = map[string]any{ + "status": "verified_absent", "verifiedCount": len(existingIDs), + "missingBeforeWriteCount": len(missingIDs), + } + result.Result = map[string]any{ + "deletedCount": len(existingIDs), "missingCount": len(missingIDs), + "batchCount": len(result.CompletedSteps), + } return rt.Output(result) } +func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) { + records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids) + if err != nil { + return nil, err + } + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + present := make(map[string]bool, len(records)) + for index, record := range records { + id := recordID(record) + if id == "" { + return nil, fmt.Errorf("delete preflight record %d is missing recordId", index) + } + if !wanted[id] { + return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id) + } + if present[id] { + return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id) + } + present[id] = true + } + existing := make([]string, 0, len(present)) + for _, id := range ids { + if present[id] { + existing = append(existing, id) + } + } + return existing, nil +} + func parseRecordIDs(values []string) ([]string, error) { seen := map[string]bool{} ids := make([]string, 0, len(values)) diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index 00d89975..ed41a74d 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) { @@ -90,6 +92,67 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T) } } +func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) { + t.Run("record not found is classified", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`}, + {text: `{"records":[]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing") + var typed *apperrors.Error + if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" { + t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err) + } + }) + + t.Run("table without primary doc field is normalized", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"fields":[{"fieldId":"text","type":"text"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1") + if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 { + t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("empty primary doc cell is unassociated", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{}}]}`}, + {text: `{"data":{"nodeId":null}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1") + if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" { + t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("known helper no-record error is unassociated", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{}}]}`}, + {err: errors.New("no record")}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1") + if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 { + t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("associated doc is resolved through helper", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`}, + {text: `{"data":{"nodeId":"node-1"}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1") + if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" { + t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) +} + func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) { record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}} diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 2304cf6c..653a80b5 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi }) t.Run("delete readback follows active continuation to absence", func(t *testing.T) { - caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) { + written := false + verificationPage := 0 + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) { if tool != "query_records" { + written = true return `{"deletedCount":1}`, nil } - if call == 1 { + if !written { + return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil + } + verificationPage++ + if verificationPage == 1 { if _, exists := args["cursor"]; exists { t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args) } @@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil }} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) - if err != nil || out == "" || len(caller.calls) != 3 { + if err != nil || out == "" || len(caller.calls) != 4 { t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls) } }) t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) { - caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + written := false + verificationPage := 0 + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) { if tool != "query_records" { + written = true return `{"deletedCount":1}`, nil } - if call == 1 { + if !written { + return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil + } + verificationPage++ + if verificationPage == 1 { return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil } return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil }} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) - if err == nil || out != "" || len(caller.calls) != 3 { + if err == nil || out != "" || len(caller.calls) != 4 { t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls) } var typed *apperrors.Error @@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi }) t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) { - caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + written := false + verificationPage := 0 + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) { if tool != "query_records" { + written = true return `{"deletedCount":1}`, nil } - return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil + if !written { + return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil + } + verificationPage++ + return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil }} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) - if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 { + if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 { t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls) } var typed *apperrors.Error @@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te records := updateFixtureRecords(0, size, "完成") caller := &upsertByKeyCaller{} queryCalls := 0 + writeSeen := false caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) { if tool != "query_records" { + writeSeen = true return `{"success":true}`, nil } queryCalls++ if operation == "delete" { ids := args["recordIds"].([]string) - empty := make([]map[string]any, len(ids)) - response := pagedRecordQueryResponse(t, empty, args) + requested := make([]map[string]any, 0, len(ids)) + if !writeSeen { + for _, id := range ids { + requested = append(requested, map[string]any{"recordId": id}) + } + } + response := pagedRecordQueryResponse(t, requested, args) var payload map[string]any if err := json.Unmarshal([]byte(response), &payload); err != nil { t.Fatal(err) } - page := payload["data"].(map[string]any) - page["records"] = []any{} raw, _ := json.Marshal(payload) return string(raw), nil } @@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err) } wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize + if operation == "delete" { + wantQueries *= 2 + } if queryCalls != wantQueries { t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries) } @@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string { func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) { ids := recordIDFixtures(101) - caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ - {text: `{"deletedCount":100}`}, - {text: `{"data":{"records":[]}}`}, - {text: `{"data":{"records":[]}}`}, - {text: `{"data":{"records":[]}}`}, - {text: `{"data":{"records":[]}}`}, - {text: `{"data":{"records":[]}}`}, - {text: `{"deletedCount":1}`}, - {text: `{"data":{"records":[]}}`}, + writes := 0 + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) { + if tool == "delete_records" { + writes++ + return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil + } + requested := args["recordIds"].([]string) + records := make([]map[string]any, 0, len(requested)) + if writes == 0 { + for _, id := range requested { + records = append(records, map[string]any{"recordId": id}) + } + } + return recordListJSON(t, records), nil }} out, err := runRecordDeleteCLI(t, caller, ids) if err != nil { @@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi t.Fatalf("delete output missing %s: %s", want, out) } } - if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" { + if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" { t.Fatalf("delete call sequence = %#v", caller.calls) } } +func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) { + t.Run("all requested IDs were already absent", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}} + out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"}) + if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" || + !strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) { + t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("only records proven present are counted", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`}, + {text: `{"deletedCount":1}`}, + {text: `{"success":true,"data":{"records":[],"hasMore":false}}`}, + }} + out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"}) + if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) { + t.Fatalf("mixed delete = output:%q err:%v", out, err) + } + deleted := caller.calls[1].args["recordIds"].([]string) + if !slices.Equal(deleted, []string{"r1"}) { + t.Fatalf("delete target IDs = %#v", deleted) + } + }) +} + func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) { t.Run("legal empty collection proves deletion", func(t *testing.T) { - caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}} + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`}, + }} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) if err != nil || !strings.Contains(out, `"status": "recovered"`) { t.Fatalf("delete recovered = output:%q err:%v", out, err) @@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t t.Run("explicit service success with empty data proves deletion", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"success":true,"status":"success","error":{},"data":{}}`}, }} @@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t }) t.Run("missing records contract is unknown", func(t *testing.T) { - caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}} + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`}, + }} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) if err == nil || out != "" { t.Fatalf("delete missing records = output:%q err:%v", out, err) @@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t t.Run("remaining record is not success", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":0}`}, {text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`}, }} @@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) { ids := recordIDFixtures(101) - caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ - {text: `{"deletedCount":100}`}, - {text: `{"records":[]}`}, - {text: `{"records":[]}`}, - {text: `{"records":[]}`}, - {text: `{"records":[]}`}, - {text: `{"records":[]}`}, - {text: `{"deletedCount":0}`}, - {text: `{"records":[{"recordId":"r100","cells":{}}]}`}, + writes := 0 + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) { + if tool == "delete_records" { + writes++ + return `{"deletedCount":0}`, nil + } + requested := args["recordIds"].([]string) + records := make([]map[string]any, 0, len(requested)) + if writes == 0 { + for _, id := range requested { + records = append(records, map[string]any{"recordId": id}) + } + } else if writes == 2 { + records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}}) + } + return recordListJSON(t, records), nil }} out, err := runRecordDeleteCLI(t, caller, ids) if err == nil || out != "" { diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go index 2b834f68..b50d5588 100644 --- a/internal/shortcut/aitable/remaining_coverage_gap_test.go +++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go @@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t * if err != nil || len(workflows) != 1 { t.Fatalf("workflow projection = %#v, %v", workflows, err) } + workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}}) + if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" { + t.Fatalf("workflow flowId projection = %#v, %v", workflows, err) + } } func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) { @@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) { } func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) { + disableViewPresetSleep(t) preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}` cases := []struct { name string diff --git a/internal/shortcut/aitable/view_preset.go b/internal/shortcut/aitable/view_preset.go index 6a16aebc..66ea741f 100644 --- a/internal/shortcut/aitable/view_preset.go +++ b/internal/shortcut/aitable/view_preset.go @@ -7,12 +7,17 @@ import ( "fmt" "reflect" "strings" + "time" "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/shortcut" ) +const viewPresetReadbackAttempts = 8 + +var viewPresetSleep = time.Sleep + var ViewPresetApply = shortcut.Shortcut{ Service: "aitable", Command: "+view-preset-apply", @@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error { if action == "create" { viewID = findStringByKeys(writeData, "viewId") } - readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID}) - verifiedViews, found := findNamedObjectList(readBack, "views", "viewList") - if verifyErr == nil && !found { - verifyErr = fmt.Errorf("get_views read-back is missing the views collection") - } - verifiedMatches := viewsByExactName(verifiedViews, name) - if verifyErr == nil && len(verifiedMatches) != 1 { - verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches)) - } - if verifyErr == nil { - actualID := stringValue(verifiedMatches[0], "viewId", "id") - if actualID == "" || (viewID != "" && actualID != viewID) { - verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID) - } else if !presetViewMatches(verifiedMatches[0], viewType, config) { - verifyErr = fmt.Errorf("view read-back does not contain the declared type/config") - } else { - viewID = actualID + var verifiedMatches []map[string]any + var verifyErr error + for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<<(attempt-1)) * time.Second + if backoff > 12*time.Second { + backoff = 12 * time.Second + } + viewPresetSleep(backoff) + } + readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID}) + verifyErr = readErr + verifiedViews, found := findNamedObjectList(readBack, "views", "viewList") + if verifyErr == nil && !found { + verifyErr = fmt.Errorf("get_views read-back is missing the views collection") + } + verifiedMatches = viewsByExactName(verifiedViews, name) + if verifyErr == nil && len(verifiedMatches) != 1 { + verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches)) + if len(verifiedMatches) > 1 { + break + } + } + if verifyErr == nil { + actualID := stringValue(verifiedMatches[0], "viewId", "id") + if actualID == "" || (viewID != "" && actualID != viewID) { + verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID) + } else if !presetViewMatches(verifiedMatches[0], viewType, config) { + verifyErr = fmt.Errorf("view read-back does not contain the declared type/config") + } else { + viewID = actualID + break + } } } if verifyErr != nil { @@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a if actualType != "" && actualType != viewType { return false } - actualConfig, ok := view["config"].(map[string]any) - return ok && mapContains(actualConfig, config) + actualConfig := make(map[string]any, len(config)) + if nested, ok := view["config"].(map[string]any); ok { + for key, value := range nested { + actualConfig[key] = value + } + } + for key := range config { + if value, ok := view[key]; ok { + actualConfig[key] = value + } + } + if _, wanted := config["visibleFieldIds"]; wanted { + if visible, ok := projectedVisibleFieldIDs(view); ok { + actualConfig["visibleFieldIds"] = visible + } + } + return mapContains(actualConfig, config) +} + +// get_views projects visible fields as columns plus hiddenFields instead of +// echoing create_view's config.visibleFieldIds input. Different deployments +// return hiddenFields either as a fieldId-keyed object or a parallel array. +func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) { + columns, columnsOK := view["columns"].([]any) + custom, customOK := view["custom"].(map[string]any) + if !columnsOK || !customOK { + return nil, false + } + if hidden, ok := custom["hiddenFields"].(map[string]any); ok { + visible := make([]any, 0, len(columns)) + for _, column := range columns { + fieldID, fieldOK := column.(string) + if !fieldOK { + return nil, false + } + isHidden, exists := hidden[fieldID] + if !exists { + return nil, false + } + hiddenFlag, hiddenTypeOK := isHidden.(bool) + if !hiddenTypeOK { + return nil, false + } + if !hiddenFlag { + visible = append(visible, fieldID) + } + } + return visible, true + } + hidden, hiddenOK := custom["hiddenFields"].([]any) + if !hiddenOK || len(columns) != len(hidden) { + return nil, false + } + visible := make([]any, 0, len(columns)) + for index, column := range columns { + fieldID, fieldOK := column.(string) + isHidden, hiddenTypeOK := hidden[index].(bool) + if !fieldOK || !hiddenTypeOK { + return nil, false + } + if !isHidden { + visible = append(visible, fieldID) + } + } + return visible, true } func mapContains(actual, expected map[string]any) bool { diff --git a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go index ee437148..450964cd 100644 --- a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go +++ b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go @@ -7,13 +7,21 @@ import ( "errors" "strings" "testing" + "time" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}` +func disableViewPresetSleep(t *testing.T) { + t.Helper() + testseam.Swap(t, &viewPresetSleep, func(time.Duration) {}) +} + func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) { + disableViewPresetSleep(t) t.Run("create and verify exact config", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ {text: `{"views":[]}`}, @@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin } }) + t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, + {text: `{"data":{"viewId":"v1"}}`}, + {text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes") + if err != nil || !strings.Contains(out, `"status": "verified"`) { + t.Fatalf("projected view preset = output:%q err:%v", out, err) + } + }) + + t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, + {text: `{"data":{"viewId":"v1"}}`}, + {text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes") + if err != nil || !strings.Contains(out, `"status": "verified"`) { + t.Fatalf("keyed projected view preset = output:%q err:%v", out, err) + } + }) + t.Run("unchanged preset makes no write", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}} out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", @@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin }) } +func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) { + disableViewPresetSleep(t) + steps := []upsertByKeyStep{ + {text: `{"views":[]}`}, + {text: `{"data":{"viewId":"v1"}}`}, + } + for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ { + steps = append(steps, upsertByKeyStep{text: `{"views":[]}`}) + } + steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}) + caller := &upsertByKeyCaller{steps: steps} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes") + if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 { + t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls) + } +} + func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) { + disableViewPresetSleep(t) t.Run("duplicate name stops before write", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}} out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", diff --git a/internal/shortcut/aitabletarget/resolver.go b/internal/shortcut/aitabletarget/resolver.go index 25baf098..882d4e3f 100644 --- a/internal/shortcut/aitabletarget/resolver.go +++ b/internal/shortcut/aitabletarget/resolver.go @@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e all = append(all, Candidate{ID: id, Name: candidateName}) } next, hasMore, hasMoreKnown := pagination(data) + if hasMoreKnown && !hasMore { + return selectCandidate("base", query, dedupe(all), allowFuzzy) + } if next == "" { if hasMoreKnown && hasMore { return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor") diff --git a/internal/shortcut/aitabletarget/resolver_test.go b/internal/shortcut/aitabletarget/resolver_test.go index 8a011a2a..68c989b5 100644 --- a/internal/shortcut/aitabletarget/resolver_test.go +++ b/internal/shortcut/aitabletarget/resolver_test.go @@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) { } } +func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{ + {data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}}, + {data: map[string]any{ + "bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}}, + "hasMore": false, + "nextCursor": "next", + }}, + }} + got, err := ResolveBaseName(reader, "项目管理", false) + if err != nil || got.Selected.ID != "b1" { + t.Fatalf("terminal cursor resolution = %#v, %v", got, err) + } + if len(reader.calls) != 2 { + t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls)) + } +} + func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) { data := map[string]any{"bases": []any{ map[string]any{"baseId": "b1", "baseName": "项目管理归档"}, diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go index df7ad96e..d84b2819 100644 --- a/internal/shortcut/contact/contact.go +++ b/internal/shortcut/contact/contact.go @@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{ // the clean output projection applied to every list command. // Field names are probed defensively across candidate keys. func searchUserProject(data map[string]any) []map[string]any { - raw, ok := data["result"].([]any) - if !ok { + var raw []any + switch result := data["result"].(type) { + case []any: + raw = result + case map[string]any: + raw = []any{result} + default: return []map[string]any{} } out := make([]map[string]any, 0, len(raw)) diff --git a/internal/shortcut/contact/search_mobile_coverage_test.go b/internal/shortcut/contact/search_mobile_coverage_test.go new file mode 100644 index 00000000..fcadbb2a --- /dev/null +++ b/internal/shortcut/contact/search_mobile_coverage_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package contact + +import "testing" + +func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) { + users := searchUserProject(map[string]any{ + "success": true, + "result": map[string]any{ + "name": "Alice", + "userId": "u1", + "flowerName": "A", + "openDingTalkId": "open-1", + "title": "Engineer", + "unrelatedPrivate": "drop-me", + }, + }) + if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" { + t.Fatalf("object envelope projection = %#v, want one user", users) + } + if _, exists := users[0]["unrelatedPrivate"]; exists { + t.Fatalf("projection leaked unrelated field: %#v", users[0]) + } +} + +func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) { + users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}}) + if len(users) != 0 { + t.Fatalf("empty result projected users: %#v", users) + } +} diff --git a/internal/shortcut/minutesdata/workflow.go b/internal/shortcut/minutesdata/workflow.go index 4ffc9a83..236f77b7 100644 --- a/internal/shortcut/minutesdata/workflow.go +++ b/internal/shortcut/minutesdata/workflow.go @@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error { // RequirePermissionMutationAcknowledgement validates the target-level payload // returned by add/remove_member_permission. The backend's top-level // success=true is not sufficient: historically it also accompanied a -// resultMap that merely echoed nonexistent task UUIDs. +// resultMap that merely echoed nonexistent task UUIDs, and it does not prove +// that every requested task/member pair was changed. func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error { if err := validateEnvelope(data); err != nil { return err diff --git a/internal/shortcut/minutesdata/workflow_test.go b/internal/shortcut/minutesdata/workflow_test.go index b0bccb58..e566b645 100644 --- a/internal/shortcut/minutesdata/workflow_test.go +++ b/internal/shortcut/minutesdata/workflow_test.go @@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T } } +func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}}, + } { + if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil { + t.Fatalf("incomplete permission acknowledgement accepted: %#v", data) + } + } + valid := map[string]any{ + "success": true, + "result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}}, + } + if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil { + t.Fatalf("valid permission acknowledgement rejected: %v", err) + } +} + func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) { record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}}) if err != nil || record["cmd"] != "pause" { diff --git a/internal/shortcut/todo/page_size_coverage_test.go b/internal/shortcut/todo/page_size_coverage_test.go new file mode 100644 index 00000000..c2deb5ba --- /dev/null +++ b/internal/shortcut/todo/page_size_coverage_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package todo + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "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 todoPageSizeCaller struct { + calls int +} + +func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + c.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil +} + +func (*todoPageSizeCaller) Format() string { return "json" } +func (*todoPageSizeCaller) DryRun() bool { return false } +func (*todoPageSizeCaller) Fields() string { return "" } +func (*todoPageSizeCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) { + caller := &todoPageSizeCaller{} + helpers.InitDepsForTest(t, 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", "") + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--size") { + t.Fatalf("size 21 error = %v, want local --size validation", err) + } + if caller.calls != 0 { + t.Fatalf("size 21 made %d remote calls, want 0", caller.calls) + } +} diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go index ab57746d..94603853 100644 --- a/internal/shortcut/wiki/common.go +++ b/internal/shortcut/wiki/common.go @@ -154,6 +154,30 @@ func projectWikiRows(items []any, aliases map[string][]string) []map[string]any return rows } +func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any { + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + source := item.(map[string]any) + row := make(map[string]any, len(source)+len(aliases)) + for key, value := range source { + row[key] = value + } + for canonical, candidates := range aliases { + if value, ok := row[canonical]; ok && value != nil { + continue + } + for _, candidate := range candidates { + if value, ok := source[candidate]; ok && value != nil { + row[canonical] = value + break + } + } + } + rows = append(rows, row) + } + return rows +} + func addWikiPagination(out, page map[string]any) { for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} { if value, ok := page[pair[0]]; ok && value != nil { diff --git a/internal/shortcut/wiki/nodes.go b/internal/shortcut/wiki/nodes.go index aa893c87..7011e00a 100644 --- a/internal/shortcut/wiki/nodes.go +++ b/internal/shortcut/wiki/nodes.go @@ -237,7 +237,7 @@ var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", " if err != nil { return err } - feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}}) + feeds := projectWikiRowsPreservingSource(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}}) out := map[string]any{"count": len(feeds), "feeds": feeds} addWikiPagination(out, page) return rt.Output(out) diff --git a/internal/shortcut/wiki/workflow_coverage_test.go b/internal/shortcut/wiki/workflow_coverage_test.go index 457ceec9..b66490cc 100644 --- a/internal/shortcut/wiki/workflow_coverage_test.go +++ b/internal/shortcut/wiki/workflow_coverage_test.go @@ -294,6 +294,29 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) { }) } +func TestCrossPlatformCoverageWikiFeedListPreservesBusinessPayload(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"type":1,"time":1750067400000,"content":{"doc":{"name":"Important","docKey":"dk1"}},"users":[{"nick":"Alice","userId":"u1"}],"futureField":{"value":"keep"}}],"hasMore":false}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+feed-list", "--workspace", "w") + if err != nil { + t.Fatal(err) + } + feeds := out["feeds"].([]any) + feed := feeds[0].(map[string]any) + content, contentOK := feed["content"].(map[string]any) + users, usersOK := feed["users"].([]any) + if !contentOK || content["doc"].(map[string]any)["name"] != "Important" { + t.Fatalf("content was lost or changed: %#v", feed) + } + if !usersOK || users[0].(map[string]any)["nick"] != "Alice" { + t.Fatalf("users were lost or changed: %#v", feed) + } + if _, ok := feed["futureField"]; !ok { + t.Fatalf("unknown feed business fields must be preserved: %#v", feed) + } +} + func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) { t.Run("confirmation gates precede every remote call", func(t *testing.T) { for _, tc := range []struct { From ae1edefee6cf5514ed48509b1eb204d5bee89a05 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 12:17:08 +0800 Subject: [PATCH 164/267] test: cover shortcut hardening branches --- .../1050-shortcut-functional-hardening.md | 5 + .../helpers/aitable_command_edges_test.go | 107 ++++++++++++++++++ .../helpers/aitable_workflow_write_test.go | 22 ++++ .../helpers/drive_sync_confirmation_test.go | 30 +++++ .../aitable/base_composite_e2e_test.go | 67 +++++++++++ internal/shortcut/aitable/base_copy.go | 7 -- .../aitable/record_coverage_gap_test.go | 57 ++++++++++ .../aitable/remaining_coverage_gap_test.go | 34 ++++++ 8 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 .changes/1050-shortcut-functional-hardening.md diff --git a/.changes/1050-shortcut-functional-hardening.md b/.changes/1050-shortcut-functional-hardening.md new file mode 100644 index 00000000..1d28f673 --- /dev/null +++ b/.changes/1050-shortcut-functional-hardening.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations. diff --git a/internal/helpers/aitable_command_edges_test.go b/internal/helpers/aitable_command_edges_test.go index 262b0f09..367b133b 100644 --- a/internal/helpers/aitable_command_edges_test.go +++ b/internal/helpers/aitable_command_edges_test.go @@ -470,3 +470,110 @@ func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing. } }) } + +func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) { + testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {}) + testseam.Protect(t, &os.Args) + os.Args = []string{"dws", "aitable"} + fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}` + filter := `[{"operator":"eq","operands":["fldA","x"]}]` + args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter} + + t.Run("update transport error", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}} + if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 { + t.Fatalf("update error = %v, calls=%#v", err, caller.calls) + } + }) + + t.Run("readback transport errors exhaust", func(t *testing.T) { + errs := []error{nil, nil} + for range aitableViewFilterReadbackAttempts { + errs = append(errs, context.DeadlineExceeded) + } + caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs} + if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts { + t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls)) + } + }) + + t.Run("readback wrong identity exhausts", func(t *testing.T) { + responses := []string{fields, `{"success":true}`} + for range aitableViewFilterReadbackAttempts { + responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`) + } + caller := &aitableTestCaller{responses: responses} + if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") { + t.Fatalf("wrong readback identity = %v", err) + } + }) + + loadCases := []struct { + name string + response string + callErr error + wantError string + }{ + {name: "transport", callErr: context.Canceled, wantError: "context canceled"}, + {name: "invalid json", response: `{`, wantError: "not valid JSON"}, + {name: "missing collection", response: `{}`, wantError: "missing the fields collection"}, + {name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"}, + } + for _, tc := range loadCases { + t.Run("load fields "+tc.name, func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}} + installAitableDeps(t, caller) + if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("load fields error = %v, want %q", err, tc.wantError) + } + }) + } + t.Run("load legacy field keys", func(t *testing.T) { + caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}} + installAitableDeps(t, caller) + got, err := loadAitableFieldTypes(context.Background(), "b", "t") + if err != nil || got["legacy"] != "text" { + t.Fatalf("legacy field keys = %#v, %v", got, err) + } + }) + + if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok { + t.Fatal("scalar fields collection must fail") + } + if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok { + t.Fatal("scalar field item must fail") + } + if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 { + t.Fatalf("recursive fields = %#v, %v", got, ok) + } + + invalidFilters := []struct { + filter []any + want string + }{ + {filter: []any{"bad"}, want: "must be an object"}, + {filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"}, + {filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"}, + {filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"}, + {filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"}, + {filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"}, + {filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"}, + } + for _, tc := range invalidFilters { + if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want) + } + } + if _, err := expandAitableMultiSelectAnyOf("multi", nil); err == nil { + t.Fatal("empty any_of array must fail") + } + if _, err := expandAitableMultiSelectAnyOf("multi", []any{"ok", 1}); err == nil { + t.Fatal("non-string any_of option must fail") + } + if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") { + t.Fatalf("compactJSON fallback = %q", got) + } + if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) { + t.Fatal("invalid persisted wrapper must not match") + } +} diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index db95925a..562e1a08 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -25,11 +25,15 @@ type aitableWorkflowCall struct { type aitableWorkflowCaller struct { calls []aitableWorkflowCall response string + err error dryRun bool } func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args}) + if c.err != nil { + return nil, c.err + } response := c.response if response == "" { response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}` @@ -114,6 +118,11 @@ func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testi {name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"}, {name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"}, {name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"}, + {name: "malformed response", response: `{`, want: "not a JSON object"}, + {name: "null response", response: `null`, want: "not a JSON object"}, + {name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"}, + {name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"}, + {name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -143,6 +152,19 @@ func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testi t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls) } }) + + t.Run("transport error", func(t *testing.T) { + caller := &aitableWorkflowCaller{err: context.Canceled} + err := runAitableWorkflowCommandWithCaller(t, caller, nil, + "create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 { + t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls) + } + }) + + if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") { + t.Fatalf("nil workflow envelope = %v", err) + } } func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) { diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index dec9633f..70a303d7 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -267,6 +267,36 @@ func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) } } +func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) { + remoteFiles := map[string]*remoteFile{ + "conflict.txt": {FileID: "remote-file"}, + } + remoteFolders := map[string]string{"": "root", "existing": "existing-id"} + localDirs := []string{"existing", "new", "missing/child"} + localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}} + + for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} { + t.Run(policy, func(t *testing.T) { + res := &driveSyncResult{} + appendDriveSyncDryRunPlan(res, localDirs, localFiles, remoteFiles, remoteFolders, + []string{"new/file.txt", "missing/file.txt"}, []string{"remote.txt"}, []string{"conflict.txt"}, []string{"unknown.txt"}, policy) + if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 { + t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items) + } + switch policy { + case syncConflictRemoteWins, syncConflictKeepBoth: + if res.Summary.PlannedPulls < 2 { + t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls) + } + case syncConflictLocalWins: + if res.Summary.PlannedPushes < 2 { + t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes) + } + } + }) + } +} + func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "skip.txt"), "skip") diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index 0bd20ea5..9df47ae6 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -160,6 +160,73 @@ func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testin }) } +func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) { + t.Run("invalid source", func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes") + if err == nil || out != "" { + t.Fatalf("invalid source = output:%q err:%v", out, err) + } + }) + + t.Run("dry run", func(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run") + if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) { + t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("target lookup error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 { + t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("generic copy error is unknown", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"fileId":"folder","type":"folder"}}`}, + {err: errors.New("transport failed")}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + if err == nil || out != "" || len(caller.calls) != 2 { + t.Fatalf("copy error = output:%q err:%v", out, err) + } + }) + + readBackCases := []struct { + name string + step upsertByKeyStep + }{ + {name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}}, + {name: "missing id", step: upsertByKeyStep{text: `{}`}}, + {name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}}, + } + for _, tc := range readBackCases { + t.Run("readback "+tc.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"fileId":"folder","type":"folder"}}`}, + {text: `{"newBaseId":"new-base"}`}, + tc.step, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes") + if err == nil || out != "" || len(caller.calls) != 3 { + t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err) + } + }) + } + + for _, value := range []string{"", "white space", "control\x00"} { + if validCompositeOpaqueID(value) { + t.Errorf("validCompositeOpaqueID(%q) = true", value) + } + } + if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") { + t.Fatal("pureNumericID edge mismatch") + } +} + func bootstrapFields(count int) []any { fields := make([]any, 0, count) for index := 0; index < count; index++ { diff --git a/internal/shortcut/aitable/base_copy.go b/internal/shortcut/aitable/base_copy.go index c37f3a1e..bc569a68 100644 --- a/internal/shortcut/aitable/base_copy.go +++ b/internal/shortcut/aitable/base_copy.go @@ -92,9 +92,6 @@ func executeBaseCopy(rt *shortcut.RuntimeContext) error { if verifyErr != nil { result.Status = "partial_success" result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()} - if writeErr != nil { - result.Warnings = append(result.Warnings, "copy response error: "+writeErr.Error()) - } return compositeError(result, verifyErr, false) } @@ -102,10 +99,6 @@ func executeBaseCopy(rt *shortcut.RuntimeContext) error { result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}}) result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID} result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack} - if writeErr != nil { - result.Status = "recovered" - result.Warnings = append(result.Warnings, "copy response was an error, but the new Base was proven by exact read-back") - } return rt.Output(result) } diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index ed41a74d..64a128c0 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -4,6 +4,7 @@ package aitable import ( + "context" "errors" "fmt" "strconv" @@ -153,6 +154,62 @@ func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *te }) } +func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) { + cases := []struct { + name string + steps []upsertByKeyStep + }{ + {name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}}, + {name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}}, + {name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}}, + {name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}}, + {name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}}, + {name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}}, + {name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1") + if err == nil || out != "" { + t.Fatalf("primary doc failure = output:%q err:%v", out, err) + } + }) + } + + for _, value := range []any{ + map[string]any{"exists": false}, + map[string]any{"dentryUuid": ""}, + map[string]any{"status": "NO_RECORD"}, + map[string]any{"nested": []any{map[string]any{"status": "unassociated"}}}, + } { + if !knownPrimaryDocUnassociatedData(value) { + t.Errorf("known unassociated shape not recognized: %#v", value) + } + } + if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) { + t.Fatal("unrelated shapes must not be unassociated") + } +} + +func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) { + for _, tc := range []struct { + name string + records string + }{ + {name: "missing id", records: `[{"cells":{}}]`}, + {name: "unexpected id", records: `[{"recordId":"other"}]`}, + {name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } +} + func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) { record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}} diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go index b50d5588..3f95b40e 100644 --- a/internal/shortcut/aitable/remaining_coverage_gap_test.go +++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go @@ -242,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) { } } +func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) { + if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) { + t.Fatal("top-level projected config must match") + } + invalid := []map[string]any{ + {"columns": "bad", "custom": map[string]any{}}, + {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}}, + {"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}}, + {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}}, + {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}}, + {"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}}, + {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}}, + } + for _, view := range invalid { + if got, ok := projectedVisibleFieldIDs(view); ok || got != nil { + t.Errorf("invalid projection %#v = %#v, %v", view, got, ok) + } + } +} + +func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) { + disableViewPresetSleep(t) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, + {text: `{"viewId":"v1"}`}, + {text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes") + if err == nil || out != "" || len(caller.calls) != 3 { + t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls) + } +} + func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) { cases := []struct { name string From 0ed05a2c5d6b171079d62d906de622dc8b563ba7 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 14:44:04 +0800 Subject: [PATCH 165/267] fix: address shortcut review edge cases --- internal/helpers/aitable.go | 61 +++++++++------- .../helpers/aitable_command_edges_test.go | 13 +++- internal/helpers/drive_sync.go | 56 +++++++------- .../helpers/drive_sync_confirmation_test.go | 7 +- internal/shortcut/aitable/primary_doc.go | 73 +++++++++++-------- .../aitable/record_coverage_gap_test.go | 16 +++- 6 files changed, 138 insertions(+), 88 deletions(-) diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index d31b1f7e..2f72dcd0 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -8,6 +8,7 @@ import ( "math" "os" "reflect" + "sort" "strconv" "strings" "time" @@ -1383,34 +1384,46 @@ func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[str } func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) { - wanted := make(map[string]bool, len(names)) + wanted := make([]string, 0, len(names)) + seen := make(map[string]bool, len(names)) for _, name := range names { - wanted[strings.ToLower(name)] = true + name = strings.ToLower(name) + if !seen[name] { + wanted = append(wanted, name) + seen[name] = true + } } var walk func(any) ([]map[string]any, bool) walk = func(current any) ([]map[string]any, bool) { switch typed := current.(type) { case map[string]any: - for key, child := range typed { - if !wanted[strings.ToLower(key)] { - continue - } - items, ok := child.([]any) - if !ok { - return nil, false - } - out := make([]map[string]any, 0, len(items)) - for _, item := range items { - object, ok := item.(map[string]any) + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range wanted { + for _, key := range keys { + if !strings.EqualFold(key, name) { + continue + } + items, ok := typed[key].([]any) if !ok { return nil, false } - out = append(out, object) + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + return nil, false + } + out = append(out, object) + } + return out, true } - return out, true } - for _, child := range typed { - if found, ok := walk(child); ok { + for _, key := range keys { + if found, ok := walk(typed[key]); ok { return found, true } } @@ -1477,7 +1490,7 @@ func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map } if operator == "any_of" { if values, ok := operands[1].([]any); ok { - if _, err := expandAitableMultiSelectAnyOf(fieldID, values); err != nil { + if err := validateAitableMultiSelectOptionNames(values); err != nil { return err } return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views") @@ -1490,22 +1503,18 @@ func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map return nil } -func expandAitableMultiSelectAnyOf(fieldID string, values []any) ([]any, error) { +func validateAitableMultiSelectOptionNames(values []any) error { if len(values) == 0 { - return nil, fmt.Errorf("multipleSelect any_of array must not be empty") + return fmt.Errorf("multipleSelect any_of array must not be empty") } - expanded := make([]any, 0, len(values)) for index, value := range values { text, ok := value.(string) text = strings.TrimSpace(text) if !ok || text == "" { - return nil, fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index) + return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index) } - expanded = append(expanded, map[string]any{ - "operator": "eq", "operands": []any{fieldID, text}, - }) } - return expanded, nil + return nil } func isAitableDateFieldType(fieldType string) bool { diff --git a/internal/helpers/aitable_command_edges_test.go b/internal/helpers/aitable_command_edges_test.go index 367b133b..738059ea 100644 --- a/internal/helpers/aitable_command_edges_test.go +++ b/internal/helpers/aitable_command_edges_test.go @@ -546,6 +546,12 @@ func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 { t.Fatalf("recursive fields = %#v, %v", got, ok) } + if got, ok := findAitableObjectList(map[string]any{ + "fieldList": []any{map[string]any{"fieldId": "legacy"}}, + "fields": []any{map[string]any{"fieldId": "canonical"}}, + }, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" { + t.Fatalf("declared collection priority = %#v, %v", got, ok) + } invalidFilters := []struct { filter []any @@ -564,12 +570,15 @@ func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want) } } - if _, err := expandAitableMultiSelectAnyOf("multi", nil); err == nil { + if err := validateAitableMultiSelectOptionNames(nil); err == nil { t.Fatal("empty any_of array must fail") } - if _, err := expandAitableMultiSelectAnyOf("multi", []any{"ok", 1}); err == nil { + if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil { t.Fatal("non-string any_of option must fail") } + if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil { + t.Fatalf("valid any_of option names = %v", err) + } if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") { t.Fatalf("compactJSON fallback = %q", got) } diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index 2bf8b8eb..b7dbad34 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -280,8 +280,10 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { // --dry-run:只算差异、不执行任何同步动作。 if deps.Caller.DryRun() { if len(preflight) == 0 && res.Summary.Failed == 0 { - appendDriveSyncDryRunPlan(&res, localDirs, localByRel, remoteFiles, remoteFolders, - newLocal, newRemote, modified, unknown, onConflict) + appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{ + LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders, + NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict, + }) } return deps.Out.PrintJSON(driveSyncDryRunResult{ DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res, @@ -410,16 +412,20 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { return nil } -func appendDriveSyncDryRunPlan( - res *driveSyncResult, - localDirs []string, - localByRel map[string]localPushFile, - remoteFiles map[string]*remoteFile, - remoteFolders map[string]string, - newLocal, newRemote, modified, unknown []string, - onConflict string, -) { - for _, rel := range unknown { +type driveSyncDryRunPlanInput struct { + LocalDirs []string + LocalByRel map[string]localPushFile + RemoteFiles map[string]*remoteFile + RemoteFolders map[string]string + NewLocal []string + NewRemote []string + Modified []string + Unknown []string + OnConflict string +} + +func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) { + for _, rel := range input.Unknown { res.Summary.PlannedSkips++ res.Items = append(res.Items, driveSyncItem{ RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict, @@ -427,11 +433,11 @@ func appendDriveSyncDryRunPlan( }) } - plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs)) - for rel, fileID := range remoteFolders { + plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs)) + for rel, fileID := range input.RemoteFolders { plannedFolders[rel] = fileID } - for _, dir := range localDirs { + for _, dir := range input.LocalDirs { if _, ok := plannedFolders[dir]; ok { continue } @@ -446,11 +452,11 @@ func appendDriveSyncDryRunPlan( res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush}) } - for _, rel := range newRemote { + for _, rel := range input.NewRemote { res.Summary.PlannedPulls++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) } - for _, rel := range newLocal { + for _, rel := range input.NewLocal { parentRel, _ := splitRel(rel) if plannedFolders[parentRel] == "" { res.Summary.Failed++ @@ -461,23 +467,23 @@ func appendDriveSyncDryRunPlan( res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush}) } - occupied := make(map[string]bool, len(localByRel)+len(localDirs)+len(remoteFiles)+len(remoteFolders)) - for rel := range localByRel { + occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders)) + for rel := range input.LocalByRel { occupied[rel] = true } - for _, rel := range localDirs { + for _, rel := range input.LocalDirs { occupied[rel] = true } - for rel := range remoteFiles { + for rel := range input.RemoteFiles { occupied[rel] = true } - for rel := range remoteFolders { + for rel := range input.RemoteFolders { if rel != "" { occupied[rel] = true } } - for _, rel := range modified { - switch onConflict { + for _, rel := range input.Modified { + switch input.OnConflict { case syncConflictRemoteWins: res.Summary.PlannedPulls++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull}) @@ -485,7 +491,7 @@ func appendDriveSyncDryRunPlan( res.Summary.PlannedPushes++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict}) case syncConflictKeepBoth: - candidate := driveSyncSuffixedRel(rel, remoteFiles[rel].FileID, occupied) + candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied) occupied[candidate] = true res.Summary.PlannedPulls++ res.Items = append(res.Items, diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 70a303d7..0c8974d0 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -278,8 +278,11 @@ func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) { for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} { t.Run(policy, func(t *testing.T) { res := &driveSyncResult{} - appendDriveSyncDryRunPlan(res, localDirs, localFiles, remoteFiles, remoteFolders, - []string{"new/file.txt", "missing/file.txt"}, []string{"remote.txt"}, []string{"conflict.txt"}, []string{"unknown.txt"}, policy) + appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{ + LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders, + NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"}, + Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy, + }) if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 { t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items) } diff --git a/internal/shortcut/aitable/primary_doc.go b/internal/shortcut/aitable/primary_doc.go index 0e2f8eab..52d2a662 100644 --- a/internal/shortcut/aitable/primary_doc.go +++ b/internal/shortcut/aitable/primary_doc.go @@ -75,7 +75,7 @@ func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error { } return err } - nodeID := findStringByKeys(data, "nodeId", "dentryUuid") + nodeID := primaryDocNodeID(data) if nodeID == "" { if knownPrimaryDocUnassociatedData(data) { return rt.Output(map[string]any{ @@ -96,38 +96,47 @@ func knownPrimaryDocUnassociatedError(err error) bool { return strings.Contains(message, "no record") || strings.Contains(message, "unassociated") } -func knownPrimaryDocUnassociatedData(value any) bool { - var walk func(any) bool - walk = func(current any) bool { - switch typed := current.(type) { - case map[string]any: - if exists, ok := typed["exists"].(bool); ok && !exists { - return true - } - for _, key := range []string{"nodeId", "dentryUuid"} { - if nodeID, exists := typed[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") { - return true - } - } - if status, ok := typed["status"].(string); ok { - status = strings.ToLower(strings.TrimSpace(status)) - if status == "unassociated" || status == "no_record" { - return true - } - } - for _, child := range typed { - if walk(child) { - return true - } - } - case []any: - for _, child := range typed { - if walk(child) { - return true - } - } +func primaryDocNodeID(value any) string { + object, ok := value.(map[string]any) + if !ok { + return "" + } + for _, key := range []string{"nodeId", "dentryUuid"} { + if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" { + return strings.TrimSpace(nodeID) } + } + for _, envelopeKey := range []string{"data", "result", "response"} { + if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" { + return nodeID + } + } + return "" +} + +func knownPrimaryDocUnassociatedData(value any) bool { + object, ok := value.(map[string]any) + if !ok { return false } - return walk(value) + if exists, ok := object["exists"].(bool); ok && !exists { + return true + } + for _, key := range []string{"nodeId", "dentryUuid"} { + if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") { + return true + } + } + if status, ok := object["status"].(string); ok { + status = strings.ToLower(strings.TrimSpace(status)) + if status == "unassociated" || status == "no_record" { + return true + } + } + for _, envelopeKey := range []string{"data", "result", "response"} { + if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) { + return true + } + } + return false } diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index 64a128c0..253e2e90 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -180,7 +180,7 @@ func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing map[string]any{"exists": false}, map[string]any{"dentryUuid": ""}, map[string]any{"status": "NO_RECORD"}, - map[string]any{"nested": []any{map[string]any{"status": "unassociated"}}}, + map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}}, } { if !knownPrimaryDocUnassociatedData(value) { t.Errorf("known unassociated shape not recognized: %#v", value) @@ -189,6 +189,20 @@ func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) { t.Fatal("unrelated shapes must not be unassociated") } + for _, value := range []any{ + map[string]any{"metadata": map[string]any{"exists": false}}, + map[string]any{"items": []any{map[string]any{"nodeId": nil}}}, + } { + if knownPrimaryDocUnassociatedData(value) { + t.Errorf("unrelated nested marker must not be unassociated: %#v", value) + } + } + if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" { + t.Fatalf("unrelated nested nodeId = %q, want empty", got) + } + if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" { + t.Fatalf("known envelope nodeId = %q, want node-1", got) + } } func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) { From d10446bea7e2044383975b8a3f0a9ae86d53a8a3 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 15:50:03 +0800 Subject: [PATCH 166/267] ci: reuse preinstalled archive tooling --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc437165..4b5384b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,7 +611,13 @@ jobs: - name: Install archive tooling if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }} - run: sudo apt-get update && sudo apt-get install -y zip unzip + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi - name: Test shard with Race Detection if: ${{ steps.select.outputs.affected == 'true' }} @@ -751,7 +757,13 @@ jobs: go-version-file: go.mod - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi - name: Test release scripts shell: bash @@ -1129,7 +1141,13 @@ jobs: go-version-file: go.mod - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi - name: Build run: make build @@ -1180,7 +1198,13 @@ jobs: go-version-file: go.mod - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi - name: Run policy and shortcut coverage run: | @@ -1261,7 +1285,13 @@ jobs: - name: Install archive tooling if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' - run: sudo apt-get update && sudo apt-get install -y zip unzip + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi - name: Run baseline unit tests with coverage if: steps.baseline-cache.outputs.cache-hit != 'true' From ed1ebe5d03a5650f4aa2cb7c3622e4d661f32a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 19 Aug 2026 16:11:24 +0800 Subject: [PATCH 167/267] chore: retrigger CI From 17741851f5cd7e0e25e77697075cd1b2ed78f386 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 19 Aug 2026 16:20:04 +0800 Subject: [PATCH 168/267] test: satisfy native shortcut coverage gate --- internal/helpers/aitable_workflow_write_test.go | 2 +- .../shortcut/contact/search_mobile_coverage_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index 562e1a08..3709277c 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -77,7 +77,7 @@ func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCa return cmd.Execute() } -func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) { +func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) { wantDSL := map[string]any{ "version": "workflow-dsl/v1", "name": "create test", diff --git a/internal/shortcut/contact/search_mobile_coverage_test.go b/internal/shortcut/contact/search_mobile_coverage_test.go index fcadbb2a..06e392d5 100644 --- a/internal/shortcut/contact/search_mobile_coverage_test.go +++ b/internal/shortcut/contact/search_mobile_coverage_test.go @@ -31,3 +31,15 @@ func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testi t.Fatalf("empty result projected users: %#v", users) } } + +func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) { + users := searchUserProject(map[string]any{ + "result": []any{map[string]any{"name": "Alice", "userId": "u1"}}, + }) + if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" { + t.Fatalf("array envelope projection = %#v, want one user", users) + } + if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 { + t.Fatalf("missing result projected users: %#v", users) + } +} From ce57cdf2604b3a45247e3968dc62c8e120d6a9c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 19 Aug 2026 16:26:32 +0800 Subject: [PATCH 169/267] chore(ci): retrigger pr checks From e51ecc04f162e88ff5f4950424e9fd912f3a14e9 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Wed, 19 Aug 2026 17:09:14 +0800 Subject: [PATCH 170/267] ci: trigger workflow rerun From d5031a89f0782c3f433a66bc4485965e2dffa681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 19 Aug 2026 17:13:48 +0800 Subject: [PATCH 171/267] fix(chat): reject multiple public group role ids --- internal/helpers/chat.go | 9 ++++++--- internal/helpers/chat_command_edges_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 043418f3..977b4d9c 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -35,11 +35,14 @@ func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { case roleIDChanged && roleIDsChanged: return nil, apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") case roleIDChanged: - roleID := mustGetFlag(cmd, "role-id") - if roleID == "" { + roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) + if len(roleIDs) == 0 { return nil, apperrors.NewValidation("--role-id 不能为空") } - return []string{roleID}, nil + if len(roleIDs) > 1 { + return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份") + } + return roleIDs, nil case roleIDsChanged: return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil default: diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index d0a3f8e5..405ac9ef 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -304,6 +304,25 @@ func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) { } } +func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2") + if err == nil { + t.Fatal("set-user accepted multiple --role-id values, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} From 27aca3ccc3c037842ef1323bab7d5f14a6c42144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 17:42:20 +0800 Subject: [PATCH 172/267] fix(skills): close no-replace fallback TOCTOU and surface uncertain publications The mkdir->rename->remove->rename directory fallback had a TOCTOU window between the second remove and the second rename: a concurrent writer creating an entry at the destination was silently clobbered. The fallback now claims the destination once with mkdir and never unlinks it: the fast-path rename publishes over the claim (Linux), and platforms that refuse directory renames (macOS, Windows) move the staged children into the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink), consuming the emptied source shell on success. renameSkillPathNoReplace now returns the mkdir-claim identity captured by the child-move path. PublishSkillPathNoReplace uses it as a three-state ownership witness: the atomic/fast paths keep the staged-inode proof, the child-move path proves dest is still the mkdir claim, and a mismatch reports the new ErrSkillPathPublicationUncertain sentinel with the destination retained. The witness is real on POSIX now: darwin and linux report the dev:ino file identity instead of the empty no-op. Upstream consumers honor the sentinel: the mono/multi upgrade copy fallbacks no longer retry over an uncertain destination (the retry would displace the concurrent writer's object), and skill setup reports the retained destination instead of claiming a rollback. Rewrites the fallback tests that pinned the removed remove-and-retry flow and adds regression coverage: concurrent claim entries abort with the destination retained, wholesale replacement after child-move reports the uncertain sentinel, staged-set transactions pass the sentinel through, and both copy fallbacks short-circuit (ablation-verified). --- internal/app/skill_setup.go | 11 +- .../skill_setup_canonical_failures_test.go | 19 ++ internal/upgrade/canonical_skills_test.go | 4 +- internal/upgrade/paths.go | 14 + internal/upgrade/paths_multi_test.go | 16 +- internal/upgrade/skill_path_move.go | 9 +- internal/upgrade/skill_path_move_test.go | 12 +- internal/upgrade/skill_publication.go | 50 +++- .../skill_publication_identity_darwin.go | 24 +- .../skill_publication_identity_linux.go | 24 +- .../skill_publication_identity_other.go | 3 + internal/upgrade/skill_publication_test.go | 32 +-- internal/upgrade/skill_publish_rename.go | 251 ++++++++++++++++-- internal/upgrade/skill_publish_rename_test.go | 71 +++-- .../upgrade/skill_publish_transaction_test.go | 16 +- .../upgrade/skill_publish_uncertain_test.go | 176 ++++++++++++ 16 files changed, 614 insertions(+), 118 deletions(-) create mode 100644 internal/upgrade/skill_publish_uncertain_test.go diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 344809c7..873ca5a7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -2120,9 +2120,18 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } if isCanonical && hasCanonicalDependents { + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr) + } return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) } - fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + // The destination may belong to a concurrent writer and was + // deliberately retained; the rollback refuses to displace it. + fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr) + } else { + fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + } skipped += perTarget continue } diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go index c5d6b7fd..14887dc6 100644 --- a/internal/app/skill_setup_canonical_failures_test.go +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -3,6 +3,7 @@ package app import ( "bytes" "errors" + "fmt" "os" "path/filepath" "strings" @@ -70,6 +71,24 @@ func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t t.Fatalf("publish failure = %v", err) } }) + + t.Run("uncertain-publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + }) + errOut := &bytes.Buffer{} + _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") { + t.Fatalf("uncertain publish = %v", err) + } + // The destination was deliberately retained, so the canonical error + // must not claim a rollback happened. + if strings.Contains(err.Error(), "回滚") { + t.Fatalf("uncertain error must not claim a rollback: %v", err) + } + }) } func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 174b776e..9b019a89 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -417,9 +417,9 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { useUpgradeManagedNames(t, "dingtalk-chat") } origRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if src == victim { - return errors.New("retire denied") + return "", errors.New("retire denied") } return origRename(src, dst) }) diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 9ef878c0..f38d8f18 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -884,6 +884,14 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e continue } if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil { + // An uncertain destination may belong to a concurrent writer. + // The copy fallback below would back that object aside and + // replace it — exactly what the sentinel forbids — so surface + // the uncertain state instead of retrying over it. + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + continue + } // Match the upstream installer: platforms/filesystems that reject // links receive a direct copy rather than losing Agent support. if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil { @@ -958,6 +966,12 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk continue } if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil { + // An uncertain destination may belong to a concurrent writer; + // the copy fallback must not displace it (see the mono branch). + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + continue + } if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 3a56594c..82322520 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -626,9 +626,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t useUpgradeManagedNames(t, filepath.Base(staleDir)) origRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, "dingtalk-stale") { - return errors.New("injected backup failure") + return "", errors.New("injected backup failure") } return origRename(src, dst) }) @@ -814,7 +814,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { if err := os.MkdirAll(victim4, 0o755); err != nil { t.Fatal(err) } - testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("rename denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") }) if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { t.Fatalf("rename error = %v", err) } @@ -1003,9 +1003,9 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, ".agents") { - return errors.New("backup denied") + return "", errors.New("backup denied") } return renameSkillPathNoReplace(src, dst) }) @@ -1092,7 +1092,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Backup failure of a multi leftover aborts cleanupMultiLeftovers. os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") - testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") }) if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } @@ -1105,9 +1105,9 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Backup failure of a stale (non-mono) skill aborts with its own message. - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, "dingtalk-stale") { - return errors.New("backup denied") + return "", errors.New("backup denied") } return renameSkillPathNoReplace(src, dst) }) diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 0f35467c..3f41e6c2 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -50,7 +50,10 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) } - if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { + // moveSkillPathRecoverably is not a publication path; ignore any + // claim identity returned by the rename — no ownership check follows + // on this destination. + if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { return nil } else if !isCrossDeviceError(renameErr) { return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) @@ -91,7 +94,9 @@ func moveSkillPathRecoverably(src, dst string) (err error) { return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) } } - if err := skillPathRenameNoReplace(stage, dst); err != nil { + // Cross-device staging publishes via a self-owned staging directory, + // so the claim identity is not consulted; discard it. + if _, err := skillPathRenameNoReplace(stage, dst); err != nil { return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) } if stageInfo.IsDir() { diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go index 6d598590..1963a257 100644 --- a/internal/upgrade/skill_path_move_test.go +++ b/internal/upgrade/skill_path_move_test.go @@ -140,7 +140,7 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("non cross-device rename never copies", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) copied := false - testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission }) testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { t.Fatalf("error = %v", err) @@ -182,12 +182,12 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { t.Run("staging publish failure", func(t *testing.T) { src, dst := makeSkillMoveFixture(t) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { if from == src && to == dst { - return testCrossDeviceError() + return "", testCrossDeviceError() } if to == dst { - return errors.New("publish failed") + return "", errors.New("publish failed") } return originalRename(from, to) }) @@ -403,9 +403,9 @@ func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { func forceCrossDeviceRename(t *testing.T, src, dst string) { t.Helper() originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { if from == src && to == dst { - return testCrossDeviceError() + return "", testCrossDeviceError() } return originalRename(from, to) }) diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index 6926279c..59a3af89 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -10,6 +10,17 @@ import ( "sort" ) +// ErrSkillPathPublicationUncertain marks a publication whose transactional +// outcome cannot be determined safely: this transaction did occupy the +// destination at some point, but a stronger witness — the fresh mkdir-claim +// identity captured by the child-move fallback — now indicates that the +// object currently at the destination may belong to a concurrent writer. +// Callers must not automatically retract, replace, or retry-copy over the +// destination; instead they should surface the state so the user or a +// higher layer can inspect it. Errors wrapping this sentinel are safe to +// test with errors.Is. +var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定") + // SkillPathPublication records enough immutable identity to prove that a // destination still belongs to the transaction that published it. The // fingerprint is intentionally private so callers cannot forge records. @@ -23,6 +34,15 @@ type SkillPathPublication struct { // PublishSkillPathNoReplace atomically publishes a staged path without ever // replacing a destination created after the backup phase. +// +// The rename returns a mkdir-claim identity when (and only when) the +// child-move fallback published dest. That identity is the tamperproof +// witness that dest is still the mkdir claim this transaction created +// (unlike the staged shell, whose presence alone is compatible with a +// wholesale replacement of dest by a concurrent writer). When the claim +// identity no longer matches dest, publication reports uncertain state +// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or +// overwriting would delete the concurrent writer's data. func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) { identity, err := skillPathLstat(staged) if err != nil { @@ -33,9 +53,22 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) } stagedFileID := skillPathFileIdentity(staged) - if err := skillPathRenameNoReplace(staged, destination); err != nil { + claimIdentity, err := skillPathRenameNoReplace(staged, destination) + if err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } + // If the child-move fallback published dest, the claim identity must + // still describe the object at destination. A wholesale replacement of + // dest between the mkdir claim and this check produces a different + // identity — dest is a foreign object and must not be treated as this + // transaction's publication. An unreadable live identity (dest already + // vanished, or the platform cannot report one) defers to the standard + // confirmation checks below instead of guessing. + if claimIdentity != "" { + if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity { + return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination) + } + } publishedIdentity, err := skillPathLstat(destination) if err != nil { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) @@ -45,9 +78,18 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) } publishedFileID := skillPathFileIdentity(destination) - if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint { + // When claim identity is available (child-move path), it is the + // authoritative witness of "dest is our object"; the staged inode + // necessarily differs from the mkdir-claim inode. Only enforce the + // staged-inode identity proof when there is no claim identity — that + // is, when the atomic primitive or the fast-path rename consumed the + // staged inode into dest. + if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) { return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) } + if publishedFingerprint != fingerprint { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination) + } return SkillPathPublication{ Destination: destination, fingerprint: fingerprint, @@ -130,7 +172,9 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) if verificationErr == nil { verificationErr = errors.New("发布对象身份已变化") } - if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil { + // Rollback restore is not a publication path; ignore any claim + // identity returned by the rename — no ownership check follows. + if _, restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil { return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr) } return errors.Join( diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index b7eeead7..6f78205e 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -21,10 +21,30 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } -func skillPathFileIdentityImpl(_ string) string { - return "" +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems, which the +// publication fingerprint backstop still catches. An unreadable or +// unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := os.Lstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) } +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { return skillPathSameFileIdentity(staged, published) } diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index 88c88f35..f227d214 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -20,10 +20,30 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } -func skillPathFileIdentityImpl(_ string) string { - return "" +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems (notably tmpfs), +// which the publication fingerprint backstop still catches. An unreadable +// or unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := os.Lstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) } +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { return skillPathSameFileIdentity(staged, published) } diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go index 859c0288..59bfd256 100644 --- a/internal/upgrade/skill_publication_identity_other.go +++ b/internal/upgrade/skill_publication_identity_other.go @@ -15,6 +15,9 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { return os.SameFile(left, right) } +// skillPathFileIdentityImpl reports no identity on platforms whose stat +// layout we do not decode; the child-move claim witness degrades gracefully +// to the publication fingerprint backstop. func skillPathFileIdentityImpl(_ string) string { return "" } diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 2748f8ea..12d922f4 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -103,14 +103,14 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { failure := errors.New("injected publication failure") t.Run("platform no-replace path encoding", func(t *testing.T) { - if err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { + if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { t.Fatal("expected invalid source path failure") } source := filepath.Join(t.TempDir(), "source") if err := os.WriteFile(source, []byte("value"), 0o600); err != nil { t.Fatal(err) } - if err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { + if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { t.Fatal("expected invalid destination path failure") } }) @@ -145,11 +145,11 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { destination := filepath.Join(root, "destination") seedUpgradeSkill(t, staged, "value", false) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { - if err := originalRename(source, target); err != nil { - return err + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err } - return os.RemoveAll(target) + return "", os.RemoveAll(target) }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { t.Fatalf("publish confirmation error = %v", err) @@ -162,15 +162,15 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { destination := filepath.Join(root, "destination") seedUpgradeSkill(t, staged, "value", false) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { - if err := originalRename(source, target); err != nil { - return err + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err } if err := os.RemoveAll(target); err != nil { - return err + return "", err } seedUpgradeSkill(t, target, "replacement", false) - return nil + return "", nil }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) @@ -184,12 +184,12 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { seedUpgradeSkill(t, staged, "value", false) originalRename := skillPathRenameNoReplace published := false - testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error { - if err := originalRename(source, target); err != nil { - return err + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err } published = true - return nil + return "", nil }) originalReadDir := skillPathReadDir testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } return originalRename(source, target) }) - testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure }) if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { t.Fatalf("mismatch restore error = %v", err) } diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go index ba6cb925..ae1742c5 100644 --- a/internal/upgrade/skill_publish_rename.go +++ b/internal/upgrade/skill_publish_rename.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" ) // errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace @@ -13,7 +14,13 @@ var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic // renameSkillPathNoReplace moves source onto destination and never replaces an -// object that already occupies destination. +// object that already occupies destination. On success it also returns the +// fresh mkdir-claim identity captured by the child-move directory fallback +// (empty when the atomic primitive or the empty-target fast path published +// dest, or when the platform cannot report a stable file identity). The +// publication path uses that identity as a tamperproof witness that dest is +// still the mkdir claim this transaction created; every other caller ignores +// it. // // The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require // support from the underlying filesystem and report EINVAL/ENOTSUP when it is @@ -21,32 +28,154 @@ var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic // overlayfs homes reject the flag. Failing there would abort the whole install // on those filesystems, so the fallback uses truly atomic no-clobber primitives // that work on every POSIX filesystem: os.Mkdir for directories (fails with -// EEXIST if the path is occupied) and os.Link for regular files (fails with -// EEXIST if the path is occupied). Neither primitive has a TOCTOU window. -func renameSkillPathNoReplace(source, destination string) error { +// EEXIST if the path is occupied), os.Link for regular files (fails with +// EEXIST if the path is occupied), and os.Symlink for symbolic links (same +// EEXIST guarantee). Neither primitive has a TOCTOU window. +func renameSkillPathNoReplace(source, destination string) (string, error) { err := skillPathRenameNoReplaceAtomic(source, destination) if err == nil { - return nil + return "", nil } if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { - return err + return "", err } return renameSkillPathNoReplaceFallback(source, destination) } // renameSkillPathNoReplaceFallback is used when the kernel no-replace rename // flag is unavailable. It dispatches on the source type to pick an atomic -// no-clobber primitive that does not require filesystem support. -func renameSkillPathNoReplaceFallback(source, destination string) error { +// no-clobber primitive that does not require filesystem support. When the +// directory slow path publishes via child-move, the returned claim identity +// is the destination file identity captured immediately after mkdir; every +// other outcome returns an empty claim identity. +func renameSkillPathNoReplaceFallback(source, destination string) (string, error) { info, err := skillPathLstat(source) if err != nil { - return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) } switch { case info.IsDir(): return renameSkillDirNoReplaceFallback(source, destination) + case info.Mode().IsRegular(): + return "", renameSkillFileNoReplaceFallback(source, destination) + default: + return "", &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillDirNoReplaceFallback claims the destination once with mkdir and +// never unlinks it for the remainder of the transaction. The fast path +// renames the source over the claim; on platforms that permit replacing an +// empty directory (Linux) that rename only ever touches the claim we own. +// Platforms that refuse to rename over any directory (macOS returns EEXIST +// even for an empty target) take the slow path instead — but only after +// proving the claim is still empty: a rename that failed because a +// concurrent writer landed inside the claim must abort with the destination +// retained, never fall through to child moves whose per-entry primitives +// would collide with (and rollback would delete) the foreign data. The slow +// path moves the source children into the claim one by one, each through an +// atomic no-clobber primitive (mkdir claim for directories, os.Link for +// files, os.Symlink for links), so a same-name concurrent entry aborts the +// move and a foreign entry of any name — detected by re-reading the claim — +// retains the destination instead of letting the rollback delete it. The +// destination claim is removed only while still empty, so a foreign entry +// always survives a failed publication. On success the source is fully +// consumed either way: the fast-path rename moves it onto the claim, and +// the child-move slow path removes the emptied source shell after the last +// child relocates. +// +// The returned claim identity is non-empty only when the child-move slow +// path published dest and the platform can report a stable file identity; +// the fast-path rename consumes the mkdir claim (dest inherits the source +// inode) and returns "", as does an unsuccessful publish. Callers that need +// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare +// the returned identity against skillPathFileIdentity(destination) at the +// moment of the ownership check — a wholesale replacement of dest after +// the claim produces a different identity. +// +// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window +// between the second remove and the second rename during which a concurrent +// writer could create a file or empty directory at dest that the second +// os.Rename would silently overwrite. This implementation removes that +// window: the claim is never unlinked between the fast-path rename attempt +// and publication, and child-move relies exclusively on atomic no-clobber +// primitives that fail with EEXIST rather than replace. +func renameSkillDirNoReplaceFallback(source, destination string) (string, error) { + sourceInfo, err := skillPathLstat(source) + if err != nil { + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + if err := skillPathMkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + } + // Capture the claim identity immediately after mkdir but before any + // children move in. A concurrent replacement of destination (unlink + + // recreate) after this point produces a different identity. + claimID := skillPathFileIdentity(destination) + if err := skillPathRename(source, destination); err == nil { + // Fast path consumed the source: destination inode is now the + // staged inode, not the mkdir claim. Return no claim identity — + // the caller falls back to the standard staged-identity proof. + return "", nil + } + claimEntries, claimErr := skillPathReadDir(destination) + if claimErr != nil { + return "", errors.Join( + fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr), + removeClaimIfEmpty(destination), + ) + } + if len(claimEntries) > 0 { + // A concurrent writer populated the empty claim before we could + // take the slow path. Falling through to child-move would let + // its per-entry no-clobber primitives collide with the foreign + // data; abort with the destination retained instead. + return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist) + } + if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil { + return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err) + } + // Child-move consumed every source entry, so the emptied source shell is + // ours by construction. os.Remove refuses a non-empty directory, so a + // concurrent writer inside the staging tree fails the publication loudly + // instead of being silently discarded with the shell. + if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err) + } + // Child-move succeeded: dest is still the mkdir claim we created. + return claimID, nil +} + +// moveSkillDirChildNoReplace moves one child of a staged directory into the +// claimed destination through an atomic no-clobber primitive: directories +// recursively claim with mkdir, regular files publish via os.Link, and +// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST +// when the destination entry was taken concurrently, so no step can replace +// a foreign object. +func moveSkillDirChildNoReplace(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + // The nested fallback consumes the nested source on success — the + // fast-path rename moves it away and the child-move slow path + // removes the emptied shell. The nested claim identity is not a + // witness for this transaction (only the top-level destination's + // identity is), so it is discarded here. + _, err := renameSkillDirNoReplaceFallback(source, destination) + return err case info.Mode().IsRegular(): return renameSkillFileNoReplaceFallback(source, destination) + case info.Mode()&os.ModeSymlink != 0: + return renameSkillSymlinkNoReplaceFallback(source, destination) default: return &os.LinkError{ Op: "rename", Old: source, New: destination, @@ -55,31 +184,79 @@ func renameSkillPathNoReplaceFallback(source, destination string) error { } } -// renameSkillDirNoReplaceFallback atomically claims the destination with -// mkdir (which fails with EEXIST if the path is already occupied) and then -// replaces the empty, self-owned directory with a plain rename. On Linux -// rename(2) replaces an empty directory directly. On Darwin and Windows -// rename(2) refuses to replace any existing directory, so the empty dir is -// removed and the rename retried. If someone populated the claimed directory -// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost. -// If someone creates a new path at the destination between remove and rename, -// the second Rename detects it and fails. Neither step has a TOCTOU window -// that could silently clobber data. -func renameSkillDirNoReplaceFallback(source, destination string) error { - if err := skillPathMkdir(destination, 0o700); err != nil { - if os.IsExist(err) { - return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} +// removeClaimIfEmpty deletes the claimed destination only while it still +// holds no entries. A foreign entry that appeared after the claim must never +// be removed: the rollback has no ownership proof for it. +func removeClaimIfEmpty(destination string) error { + entries, err := skillPathReadDir(destination) + if err != nil { + if os.IsNotExist(err) { + return nil } - return fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err) } - if err := skillPathRename(source, destination); err == nil { + if len(entries) > 0 { return nil } - if rmErr := skillPathRemove(destination); rmErr != nil { - return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr) + if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err) } - if err := skillPathRename(source, destination); err != nil { - return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err) + return nil +} + +// moveSkillDirChildrenIntoClaim moves every top-level child of source into +// the claimed destination — each through moveSkillDirChildNoReplace, so a +// concurrently created same-name entry fails atomically — and restores the +// source mode on the claim. The claim is re-read after the moves: an entry +// count above the source's means a different-named foreign object landed +// mid-move, and the publication aborts with the destination retained. Any +// failure moves the already relocated children back and removes the claim +// only while it is empty, leaving the source intact and foreign entries in +// place. +func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error { + entries, err := skillPathReadDir(source) + if err != nil { + return errors.Join( + fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err), + removeClaimIfEmpty(destination), + ) + } + moved := make([]string, 0, len(entries)) + rollback := func() error { + var rollbackErr error + for i := len(moved) - 1; i >= 0; i-- { + if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination)) + return rollbackErr + } + for _, entry := range entries { + name := entry.Name() + if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback()) + } + moved = append(moved, name) + } + live, liveErr := skillPathReadDir(destination) + if liveErr != nil { + return errors.Join( + fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr), + rollback(), + ) + } + if len(live) > len(entries) { + return errors.Join( + fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist), + rollback(), + ) + } + if err := skillPathChmod(destination, sourceMode); err != nil { + return errors.Join( + fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err), + rollback(), + ) } return nil } @@ -95,3 +272,19 @@ func renameSkillFileNoReplaceFallback(source, destination string) error { } return skillPathRemove(source) } + +// renameSkillSymlinkNoReplaceFallback recreates the link at the destination +// with os.Symlink — atomic, EEXIST when taken — and then removes the source. +func renameSkillSymlinkNoReplaceFallback(source, destination string) error { + target, err := skillPathReadlink(source) + if err != nil { + return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err) + } + if err := skillPathSymlink(target, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err) + } + return skillPathRemove(source) +} diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index 120255a3..6e068eaa 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -37,7 +38,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatal(err) } testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) - if err := renameSkillPathNoReplace(source, destination); err != nil { + if _, err := renameSkillPathNoReplace(source, destination); err != nil { t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) } if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" { @@ -56,7 +57,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) - if err := renameSkillPathNoReplace(source, destination); err != nil { + if _, err := renameSkillPathNoReplace(source, destination); err != nil { t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) } assertUpgradeSkillContent(t, destination, "payload") @@ -76,7 +77,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } } testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrExist) { t.Fatalf("occupied destination must report ErrExist, got %v", err) } @@ -97,7 +98,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrPermission) { t.Fatalf("link error must surface, got %v", err) } @@ -113,7 +114,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { seedUpgradeSkill(t, source, "payload", false) seedUpgradeSkill(t, destination, "existing", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrExist) { t.Fatalf("occupied destination must report ErrExist, got %v", err) } @@ -121,30 +122,27 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { assertUpgradeSkillContent(t, source, "payload") }) - t.Run("concurrent creation between mkdir and rename is detected", func(t *testing.T) { + t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process") - testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr }) - var removeCalled bool - testseam.Swap(t, &skillPathRemove, func(path string) error { - if path == destination { - removeCalled = true + testseam.Swap(t, &skillPathRename, func(_, dst string) error { + // A concurrent writer lands inside the empty claim before the + // slow path can take it; the fast-path rename then fails the + // way it would against a populated target. + if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil { + return err } - return os.Remove(path) + return os.ErrExist }) - err := renameSkillPathNoReplace(source, destination) - if !errors.Is(err, renameErr) { - t.Fatalf("rename failure must surface, got %v", err) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") { + t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err) } - if !removeCalled { - t.Fatal("empty destination must be cleaned up after rename failure") - } - if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { - t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr) + if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" { + t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr) } assertUpgradeSkillContent(t, source, "payload") }) @@ -160,7 +158,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { calls++ return os.ErrPermission }) - if err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { + if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { t.Fatalf("permission error must surface, got %v", err) } if calls != 1 { @@ -177,7 +175,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { destination := filepath.Join(dir, "destination") testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrPermission) { t.Fatalf("stat error must surface, got %v", err) } @@ -222,7 +220,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrPermission) { t.Fatalf("mkdir error must surface, got %v", err) } @@ -238,7 +236,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) removeErr := errors.New("simulated remove failure") testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, removeErr) { t.Fatalf("remove error must surface, got %v", err) } @@ -249,23 +247,18 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { _ = os.RemoveAll(destination) }) - t.Run("directory retry rename after removing claimed empty dir", func(t *testing.T) { + t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "destination") seedUpgradeSkill(t, source, "payload", false) testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) - calls := 0 - originalRename := skillPathRename - testseam.Swap(t, &skillPathRename, func(src, dst string) error { - calls++ - if calls == 1 { - return os.ErrExist - } - return originalRename(src, dst) - }) - if err := renameSkillPathNoReplace(source, destination); err != nil { - t.Fatalf("retry must publish, got %v", err) + // macOS refuses to rename over any directory, empty or not. The + // claim's verified emptiness — not the rename error — decides + // whether the child-move slow path may proceed. + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("child-move must publish despite rename failure, got %v", err) } assertUpgradeSkillContent(t, destination, "payload") if _, err := os.Lstat(source); !os.IsNotExist(err) { @@ -284,7 +277,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { _ = os.Remove(dst) return originalRename(src, dst) }) - if err := renameSkillPathNoReplace(source, destination); err != nil { + if _, err := renameSkillPathNoReplace(source, destination); err != nil { t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err) } assertUpgradeSkillContent(t, destination, "payload") @@ -299,7 +292,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { } testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil }) - err := renameSkillPathNoReplace(source, destination) + _, err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, errNoReplaceRenameUnsupported) { t.Fatalf("unsupported error must surface for special files, got %v", err) } diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go index 0e372b28..dcf1792e 100644 --- a/internal/upgrade/skill_publish_transaction_test.go +++ b/internal/upgrade/skill_publish_transaction_test.go @@ -93,9 +93,9 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("backup failure restores earlier victims", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { if src == filepath.Join(base, "dingtalk-b") { - return failure + return "", failure } return originalRename(src, dest) }) @@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports rename failure", func(t *testing.T) { - testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return restoreFailure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", restoreFailure }) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) if !errors.Is(err, restoreFailure) { t.Fatalf("restore rename error = %v", err) @@ -237,12 +237,12 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, second, "second", false) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { switch { case src == second: - return failure + return "", failure case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): - return restoreFailure + return "", restoreFailure default: return originalRename(src, dest) } @@ -263,9 +263,9 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) return SkillPathPublication{}, failure }) originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) { - return restoreFailure + return "", restoreFailure } return originalRename(src, dest) }) diff --git a/internal/upgrade/skill_publish_uncertain_test.go b/internal/upgrade/skill_publish_uncertain_test.go new file mode 100644 index 00000000..f32c2707 --- /dev/null +++ b/internal/upgrade/skill_publish_uncertain_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageUncertainPublicationState pins the contract behind +// ErrSkillPathPublicationUncertain: a child-move publication whose mkdir +// claim no longer describes the destination reports the uncertain sentinel +// instead of guessing, keeps the destination in place, and stops upstream +// retry paths (the linked-copy fallback) from displacing the concurrent +// writer's object. +func TestCrossPlatformCoverageUncertainPublicationState(t *testing.T) { + + t.Run("destination replaced after child-move reports uncertain and is retained", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // Force the child-move slow path on every platform (Linux would + // otherwise publish through the fast-path rename over the claim). + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + // The identity seam simulates the wholesale replacement: the mkdir + // claim captures "claim", every later read of the destination + // reports the replacement's "foreign" identity. This keeps the + // test deterministic on platforms without a stable file identity. + destinationReads := 0 + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + if path != destination { + return "" + } + destinationReads++ + if destinationReads == 1 { + return "claim" + } + return "foreign" + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("replacement after child-move = %v, want uncertain sentinel", err) + } + if destinationReads != 2 { + t.Fatalf("destination identity reads = %d, want 2 (claim capture + ownership check)", destinationReads) + } + // Nothing was rolled back over the retained destination. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("child-move reports the real claim identity where the platform supports it", func(t *testing.T) { + dir := t.TempDir() + if id := skillPathFileIdentity(dir); id == "" { + t.Skip("platform reports no stable file identity; the claim witness degrades to the fingerprint backstop") + } + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the mkdir-claim identity on platforms that report one") + } + if live := skillPathFileIdentity(destination); live == "" || live != claim { + t.Fatalf("destination identity = %q, want the published claim %q", live, claim) + } + }) + + t.Run("uncertain publication passes through the staged-set transaction", func(t *testing.T) { + home := t.TempDir() + old := filepath.Join(home, "skills", "dws") + staged := filepath.Join(home, "skills", ".stage", "dws") + seedUpgradeSkill(t, old, "old", false) + seedUpgradeSkill(t, staged, "new", false) + testseam.Swap(t, &upgradePublishSkillPath, func(string, string) (SkillPathPublication, error) { + return SkillPathPublication{}, fmt.Errorf("并发写入: %w", ErrSkillPathPublicationUncertain) + }) + err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("staged-set publish = %v, want uncertain passthrough", err) + } + // The pre-publish victim is restored; the uncertain destination is + // never retried or overwritten. + assertUpgradeSkillContent(t, old, "old") + }) + + t.Run("mono copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + monoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil { + t.Fatal(err) + } + linkedDest := filepath.Join(home, ".claude", "skills", "dws") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if dest == linkedDest { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMonoSkillLocations(home, monoRoot) + if err != nil { + t.Fatalf("mono upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedDest && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedDest) + } + }) + + t.Run("multi copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-a") + linkedBase := filepath.Join(home, ".claude", "skills") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if strings.HasPrefix(dest, linkedBase) { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMultiSkillLocations(home, multiRoot, []string{"dingtalk-a"}) + if err != nil { + t.Fatalf("multi upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedBase && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedBase) + } + }) +} From 20c901d7ae38df39f6fff3c3c66ac96ea4f99d29 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Wed, 19 Aug 2026 18:06:27 +0800 Subject: [PATCH 173/267] fix(oa): require processCode in list-by-admin --request payloads - Reject --request payloads with a missing, empty, or non-string processCode; the backend answers a bad processCode with success:true and an empty list, so validate client-side like startTime - Add regression cases to keep changed-code coverage at 100% --- internal/helpers/oa.go | 18 ++++++++++++++++++ internal/helpers/oa_coverage_more_test.go | 3 +++ 2 files changed, 21 insertions(+) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index f2ddefb5..9e3393f9 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -327,6 +327,21 @@ func newOAAttachmentCommand() *cobra.Command { // get_process_instances_by_admin. const oaAdminQueryMaxPageSize = float64(20) +// validateOARequestProcessCode checks the processCode field of a decoded +// --request payload: the tool requires it, and the backend answers a bad +// processCode with success:true and an empty list, so reject it client-side. +func validateOARequestProcessCode(request map[string]any) error { + v, ok := request["processCode"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 processCode") + } + s, ok := v.(string) + if !ok || s == "" { + return fmt.Errorf("--request processCode 必须为非空字符串") + } + return nil +} + // validateOARequestPageSize checks the pageSize field of a decoded // --request payload (json.Number values from decodeOARequest). func validateOARequestPageSize(request map[string]any) error { @@ -1540,6 +1555,9 @@ func newOaCommand() *cobra.Command { if err != nil { return fmt.Errorf("--request JSON 解析失败: %w", err) } + if err := validateOARequestProcessCode(request); err != nil { + return err + } if err := validateOARequestPageSize(request); err != nil { return err } diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index 60ae7edb..019b64e8 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -275,6 +275,9 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`}, {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`}, {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} From cf13026f48bb2d3a265881a4007dc74c509fce54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 18:10:23 +0800 Subject: [PATCH 174/267] fix(skills): drop stale Statx identity test from merged remote line skill_publication_identity_linux_test.go pinned the remote line's Statx/birth-time identity design (skillPathStatx seam); the merged head proves ownership with dev:ino plus the fingerprint backstop instead, so the test no longer compiles on Linux. Caught by CI's Linux lint job, which builds what macOS-local vet skips behind the linux build tag. --- .../skill_publication_identity_linux_test.go | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 internal/upgrade/skill_publication_identity_linux_test.go diff --git a/internal/upgrade/skill_publication_identity_linux_test.go b/internal/upgrade/skill_publication_identity_linux_test.go deleted file mode 100644 index 6c634ef7..00000000 --- a/internal/upgrade/skill_publication_identity_linux_test.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build linux - -package upgrade - -import ( - "errors" - "testing" - - "golang.org/x/sys/unix" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" -) - -func TestCrossPlatformCoverageSkillPublicationLinuxFileIdentityEdges(t *testing.T) { - t.Run("statx without birth time is not an identity", func(t *testing.T) { - // CI filesystems always report STATX_BTIME, so the unprovable-identity - // path is driven through the seam: a kernel or filesystem without - // birth-time support must yield an empty file ID, which callers treat - // as "identity cannot be proven". - testseam.Swap(t, &skillPathStatx, func(_ string, stx *unix.Statx_t) error { - stx.Mask = unix.STATX_BASIC_STATS - return nil - }) - if got := skillPathFileIdentityImpl(t.TempDir()); got != "" { - t.Fatalf("statx without btime = %q, want empty", got) - } - }) - - t.Run("statx failure is not an identity", func(t *testing.T) { - testseam.Swap(t, &skillPathStatx, func(string, *unix.Statx_t) error { return errors.New("statx denied") }) - if got := skillPathFileIdentityImpl(t.TempDir()); got != "" { - t.Fatalf("failed statx = %q, want empty", got) - } - }) -} From 0c80aa79e53512911c2758e2b3c01068f7b34619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 18:27:39 +0800 Subject: [PATCH 175/267] test(skills): make replacement-identity tests deterministic on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-confirmation and tunneled-replacement tests physically removed and reseeded the destination to simulate a concurrent swap. On NTFS the recreation can immediately reuse the freed MFT record, making the file ID (volume serial + file index) compare equal and the proof pass against a replaced object — the Windows coverage gate observed the confirmation falling through to the fingerprint branch instead of the identity branch. Both tests now force the replacement through the two primitives the platform proof consults (os.SameFile on Unix, the file-ID seam on Windows), matching the technique the tunneled-rollback case already used for Unix inode recycling. --- internal/upgrade/skill_publication_test.go | 47 +++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 12d922f4..aacf8706 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -80,12 +80,19 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi t.Fatal(err) } // Simulate the identity change that a concurrent replacement causes. - // On Windows, skillPathIdentityProven compares file IDs from - // GetFileInformationByHandle and ignores this seam. On Unix, where the - // file ID is unavailable, this swap replaces the os.SameFile check - // (which can return true for a recreated file on tmpfs due to inode - // reuse) so the test is deterministic across filesystems. + // On Windows, skillPathIdentityProven compares the recorded file ID + // from GetFileInformationByHandle with the live one and ignores the + // SameFile seam; NTFS can reuse the freed MFT record for the + // recreation, so the live probe is perturbed through the file-ID + // seam as well. On Unix, where the file ID is advisory, this swap + // replaces the os.SameFile check (which can return true for a + // recreated file on tmpfs due to inode reuse) so the test is + // deterministic across filesystems. testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + return originalFileIdentity(path) + ":replaced" + }) if err := os.RemoveAll(destination); err != nil { t.Fatal(err) } @@ -161,20 +168,32 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { staged := filepath.Join(root, "staged") destination := filepath.Join(root, "destination") seedUpgradeSkill(t, staged, "value", false) - originalRename := skillPathRenameNoReplace - testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { - if _, err := originalRename(source, target); err != nil { - return "", err + // A real rename consumed the staged path (fast path), but the object + // at the destination must be proven to be the staged one. Whether a + // same-content swap is observable through inode/file-ID comparison + // depends on the filesystem — CI runners' ext4/overlayfs recycle + // inodes eagerly and NTFS can immediately reuse the freed MFT record, + // so reproducing the swap physically is not portable. Force the + // failure through both underlying primitives the platform-specific + // proof consults: os.SameFile (Unix) and the stable file ID + // (Windows), pinning the confirmation's rejection on every platform. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + stagedID := "" + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + id := originalFileIdentity(path) + if stagedID == "" { + stagedID = id + return id } - if err := os.RemoveAll(target); err != nil { - return "", err - } - seedUpgradeSkill(t, target, "replacement", false) - return "", nil + // The published probe must report a different object than the + // staged one so the proof fails on both platforms. + return id + ":swapped" }) if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { t.Fatalf("publish identity error = %v", err) } + assertUpgradeSkillContent(t, destination, "value") }) t.Run("publish confirmation fingerprint", func(t *testing.T) { From 26b5939f9fc0d71765e45f90a017bc84fef8ce62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 19 Aug 2026 18:34:38 +0800 Subject: [PATCH 176/267] chore(ci): retrigger pr checks From 22f87296cda266a540003d8f2e84d96347f50141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 18:46:38 +0800 Subject: [PATCH 177/267] fix(chat): close role flag resolver --- internal/helpers/chat.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 2e2a44fe..528fbbd9 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -47,6 +47,8 @@ func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { default: return nil, apperrors.NewValidation("缺少必填参数 --role-id") } +} + // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when From fdcaa61587bd787aa47bf9909b6a34a0aa084df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 18:54:04 +0800 Subject: [PATCH 178/267] test(skills): cover child-move edges for the 100% changed-code gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform coverage gates execute only TestCrossPlatformCoverage-named tests, so the child-move error and dispatch branches that the full local suite covered incidentally were reported as uncovered changed code on Windows (96.78% vs the 100% target). Adds a seam-driven edge suite for the child-move fallback — source/claim/child stat and read failures, per-child link and symlink collisions and publish failures, rollback rename failure, foreign-entry abort, mode-restore failure, source shell removal failure, nested-directory and simulated-symlink children, and post-rename content drift — plus the retained-destination notice for a dependent uncertain target in skill setup. The POSIX file identity impl now consults the lstat seam so its degradation branches are coverable the same way. Verified against the gate's own changed-line computation: zero uncovered changed statements in internal/upgrade. --- .../skill_setup_canonical_failures_test.go | 23 + .../skill_publication_identity_darwin.go | 2 +- .../skill_publication_identity_linux.go | 2 +- .../skill_publish_childmove_edges_test.go | 451 ++++++++++++++++++ 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 internal/upgrade/skill_publish_childmove_edges_test.go diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go index 14887dc6..9f492abb 100644 --- a/internal/app/skill_setup_canonical_failures_test.go +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -89,6 +89,29 @@ func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t t.Fatalf("uncertain error must not claim a rollback: %v", err) } }) + + t.Run("uncertain dependent target is retained and reported", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + if strings.HasPrefix(destination, filepath.Join(home, ".claude")) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + } + return originalPublish(staged, destination) + }) + errOut := &bytes.Buffer{} + _, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err != nil { + t.Fatalf("dependent uncertain publish = %v", err) + } + if skipped != 1 { + t.Fatalf("skipped = %d, want 1", skipped) + } + if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") { + t.Fatalf("errOut = %q, want retained-destination notice", errOut.String()) + } + }) } func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go index 6f78205e..0a9989ec 100644 --- a/internal/upgrade/skill_publication_identity_darwin.go +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -30,7 +30,7 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { // publication fingerprint backstop still catches. An unreadable or // unrecognizable path reports "" and callers treat that as "no witness". func skillPathFileIdentityImpl(path string) string { - info, err := os.Lstat(path) + info, err := skillPathLstat(path) if err != nil { return "" } diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go index f227d214..ff20caf2 100644 --- a/internal/upgrade/skill_publication_identity_linux.go +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -29,7 +29,7 @@ func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { // which the publication fingerprint backstop still catches. An unreadable // or unrecognizable path reports "" and callers treat that as "no witness". func skillPathFileIdentityImpl(path string) string { - info, err := os.Lstat(path) + info, err := skillPathLstat(path) if err != nil { return "" } diff --git a/internal/upgrade/skill_publish_childmove_edges_test.go b/internal/upgrade/skill_publish_childmove_edges_test.go new file mode 100644 index 00000000..0072228a --- /dev/null +++ b/internal/upgrade/skill_publish_childmove_edges_test.go @@ -0,0 +1,451 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// childMoveDirEntry fakes one directory entry for seam-driven claim scans. +type childMoveDirEntry struct { + name string + isDir bool +} + +func (entry childMoveDirEntry) Name() string { return entry.name } +func (entry childMoveDirEntry) IsDir() bool { return entry.isDir } +func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() } +func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil } +func (entry childMoveDirEntry) info() os.FileInfo { + if entry.isDir { + return skillPathFakeInfo{mode: os.ModeDir | 0o755} + } + return skillPathFakeInfo{mode: 0o644} +} + +// forceChildMove routes every directory publication through the child-move +// slow path: the atomic primitive reports the flag unsupported and the +// fast-path rename fails the way platforms that refuse renaming over any +// existing directory do. +func forceChildMove(t *testing.T) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) +} + +// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch +// branches of the child-move directory fallback on every platform: the +// platform coverage gates only execute TestCrossPlatformCoverage-named +// tests, so these branches must be driven through seams rather than the +// platform-specific filesystem behaviors that cover them incidentally +// elsewhere. +func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) { + + t.Run("directory source stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == source { + calls++ + if calls > 1 { + return nil, os.ErrPermission + } + } + return original(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("directory stat failure = %v", err) + } + }) + + t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, os.ErrPermission + } + return os.ReadDir(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("claim read failure = %v", err) + } + }) + + t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 1 { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("vanished claim cleanup = %v", err) + } + }) + + t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") { + t.Fatalf("link failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("file child collision = %v, want ErrExist", err) + } + }) + + t.Run("later child failure rolls earlier children back", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The second child's link fails; the forced rename failure then also + // breaks the rollback rename of the already relocated first child. + testseam.Swap(t, &skillPathLink, func(source, _ string) error { + if filepath.Base(source) == "second" { + return os.ErrPermission + } + return os.Link(source, filepath.Join(destination, filepath.Base(source))) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") { + t.Fatalf("child failure rollback = %v", err) + } + // The forced rename failure also breaks the rollback restore, so the + // relocated child stays at the destination — retained, never deleted. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("live claim read failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return nil, os.ErrPermission + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "确认降级发布目标") { + t.Fatalf("live claim read failure = %v", err) + } + }) + + t.Run("foreign claim entry aborts the move", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return []os.DirEntry{ + childMoveDirEntry{name: "SKILL.md"}, + childMoveDirEntry{name: "foreign"}, + }, nil + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") { + t.Fatalf("foreign claim entry = %v", err) + } + }) + + t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") { + t.Fatalf("mode restore failure = %v", err) + } + }) + + t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") { + t.Fatalf("shell removal failure = %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("nested directories and mixed children publish", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The "link" child is presented to the dispatcher as a symlink; its + // recreation is simulated through seams so the test needs no + // symlink privileges (Windows runners refuse unprivileged symlinks). + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "link") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the claim identity") + } + assertUpgradeSkillContent(t, destination, "payload") + if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" { + t.Fatalf("nested child = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) { + t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr) + } + if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) { + t.Fatalf("source must be gone, stat err=%v", statErr) + } + }) + + t.Run("special-type child is refused", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return mockSpecialInfo{}, nil + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("special child = %v, want unsupported", err) + } + }) + + t.Run("symlink child readlink failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink readlink failure = %v", err) + } + }) + + t.Run("symlink child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("symlink collision = %v, want ErrExist", err) + } + }) + + t.Run("symlink child publish failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink publish failure = %v", err) + } + }) + + t.Run("child stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("child stat failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == source { + return nil, os.ErrPermission + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "读取源 Skill 目录失败") { + t.Fatalf("source read failure = %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("empty claim must be cleaned, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if id := skillPathFileIdentityImpl("unreadable"); id != "" { + t.Fatalf("unreadable identity = %q, want empty", id) + } + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil }) + if id := skillPathFileIdentityImpl("synthetic"); id != "" { + t.Fatalf("unrecognizable identity = %q, want empty", id) + } + }) + + t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) { + dir := t.TempDir() + staged := filepath.Join(dir, "staged") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + // The published bytes drift after the rename while the object + // keeps its identity, so only the fingerprint check can refuse. + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil { + return "", err + } + return "", nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") { + t.Fatalf("content drift = %v", err) + } + }) +} From 6240584ae23c498019a8b4a5ca3e8502cee05a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 19:13:40 +0800 Subject: [PATCH 179/267] chore(policy): approve group role flag migration --- .../approved-flag-migrations-v1.json | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index 6ddbafb7..700f46d0 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -760,6 +760,39 @@ "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, + { + "command": "dws chat group-role set-user", + "legacy": { + "name": "role-ids", + "before": { + "present": true, + "type": "string", + "scope": "local", + "required": true + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "hidden": true, + "alias_of": "role-id" + } + }, + "canonical": { + "name": "role-id", + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "required": true + } + }, + "state": "pending", + "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" + }, { "command": "dws chat group-role update", "legacy": { From 575303a5b089f59c7e4dd8423229548c5161a758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 19:40:41 +0800 Subject: [PATCH 180/267] docs(skill): remove stale group role flag guidance --- skills/mono/references/products/chat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index f84ef6d8..e31a2fe0 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -2494,7 +2494,7 @@ Flags: - `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送 - `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) - `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10` -- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` +- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` - 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行) - `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner` - `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久) From 95645e4f2cec2d993625af2d47a041f3a9b01177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 19:46:47 +0800 Subject: [PATCH 181/267] fix(skills): no-clobber child moves in POSIX standalone publishers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copy_tree published staged children with mv, which replaces a concurrently created same-name directory (POSIX rename succeeds over an empty target) and whose rollback moved every dest child back — including a concurrent writer's different-named entries — before deleting the staging tree. Children now publish through kernel-level no-clobber primitives (mkdir claim + recursion for directories with the recorded mode restored, ln for regular files, ln -s for symlinks), a manifest records exactly what this transaction published, the rollback retracts only those entries in reverse order, and each level re-counts the destination so a foreign different-named entry aborts the publish with the destination retained. Read-only staged directories (0555 skill trees) are made owner-writable for the move; the backup restore uses the same discipline so a concurrent writer is refused without partially draining the backup. Regression tests cover both scripts: a concurrently created same-name empty child directory and a different-named foreign entry mid-publish are retained with the original backup kept; both fail against the previous mv-based implementation. --- scripts/install-devapp.sh | 192 ++++++++++++++++++++++------ scripts/install-event.sh | 188 +++++++++++++++++++++------ test/scripts/install_script_test.go | 111 ++++++++++++++++ 3 files changed, 414 insertions(+), 77 deletions(-) diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 4859dcf7..480b554b 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -48,59 +48,173 @@ verify_release_asset() { say "✅ SHA256 checksum verified: ${name}" } + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + copy_tree() { - src="$1"; dest="$2"; parent="$(dirname "$dest")" + src="$1" + dest="$2" + parent="$(dirname "$dest")" mkdir -p "$parent" || return 1 stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 - if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi - backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - # mkdir is the atomic no-replace claim: POSIX `mv` of a staging directory - # onto an occupied dest treats dest as a container and nests the stage - # inside it. Children then move into a directory this transaction owns. + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. if mkdir "$dest" 2>/dev/null; then - _ct_failed=0 - for _ct_child in "$stage"/* "$stage"/.[!.]* "$stage"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - if ! mv "$_ct_child" "$dest/"; then - _ct_failed=1 - break - fi - done - if [ "$_ct_failed" -eq 0 ]; then - rm -rf "$stage" + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" return 0 fi - for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - # Bounded failure like install.sh's publish rollback: a child that - # cannot move back leaves dest occupied; stop instead of swallowing - # the error so the restore below reports the failed rollback. - if ! mv "$_ct_child" "$stage/"; then - _ct_failed=1 - break - fi - done + retract_published_children "$stage" "$dest" "$manifest" rmdir "$dest" 2>/dev/null || true fi - rm -rf "$stage" + discard_stage "$stage" + rm -f "$manifest" if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 if mkdir "$dest" 2>/dev/null; then - _ct_restore_failed=0 - for _ct_child in "$backup"/* "$backup"/.[!.]* "$backup"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - if ! mv "$_ct_child" "$dest/"; then - _ct_restore_failed=1 - break - fi - done - if [ "$_ct_restore_failed" -eq 0 ]; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then rmdir "$backup" 2>/dev/null || true else - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi else - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi + rm -f "$restore_manifest" fi return 1 } @@ -331,7 +445,7 @@ link_or_copy_skill() { if [ -e "$dest" ] || [ -L "$dest" ]; then printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 elif ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi fi return 1 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index c367edbb..8ea20dcb 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -111,61 +111,173 @@ resolve_event_version() { [ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag." } + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + copy_tree() { src="$1" dest="$2" parent="$(dirname "$dest")" mkdir -p "$parent" || return 1 stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 - if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi - backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } - # mkdir is the atomic no-replace claim: POSIX `mv` of a staging directory - # onto an occupied dest treats dest as a container and nests the stage - # inside it. Children then move into a directory this transaction owns. + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. if mkdir "$dest" 2>/dev/null; then - _ct_failed=0 - for _ct_child in "$stage"/* "$stage"/.[!.]* "$stage"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - if ! mv "$_ct_child" "$dest/"; then - _ct_failed=1 - break - fi - done - if [ "$_ct_failed" -eq 0 ]; then - rm -rf "$stage" + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" return 0 fi - for _ct_child in "$dest"/* "$dest"/.[!.]* "$dest"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - # Bounded failure like install.sh's publish rollback: a child that - # cannot move back leaves dest occupied; stop instead of swallowing - # the error so the restore below reports the failed rollback. - if ! mv "$_ct_child" "$stage/"; then - _ct_failed=1 - break - fi - done + retract_published_children "$stage" "$dest" "$manifest" rmdir "$dest" 2>/dev/null || true fi - rm -rf "$stage" + discard_stage "$stage" + rm -f "$manifest" if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 if mkdir "$dest" 2>/dev/null; then - _ct_restore_failed=0 - for _ct_child in "$backup"/* "$backup"/.[!.]* "$backup"/..?*; do - [ -e "$_ct_child" ] || [ -L "$_ct_child" ] || continue - if ! mv "$_ct_child" "$dest/"; then - _ct_restore_failed=1 - break - fi - done - if [ "$_ct_restore_failed" -eq 0 ]; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then rmdir "$backup" 2>/dev/null || true else - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi else - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi + rm -f "$restore_manifest" fi return 1 } @@ -400,7 +512,7 @@ link_or_copy_skill() { if [ -e "$dest" ] || [ -L "$dest" ]; then printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 elif ! mv "$backup" "$dest"; then - printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2 + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 fi fi return 1 diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 285694cb..0ff9b5af 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1427,6 +1427,117 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { } } +func TestInstallerStandaloneCopyChildRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + // sameName: a concurrently created same-name empty child directory must + // refuse the publish (a POSIX mv would have replaced it wholesale). + // foreignName: a different-named entry landing inside the claimed dest + // mid-publish aborts the transaction with the destination retained. + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + for _, raceCase := range []string{"same-name", "foreign-name"} { + scriptName, raceCase := scriptName, raceCase + t.Run(scriptName+"/"+raceCase, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + if err := os.MkdirAll(filepath.Join(src, "aaa-subdir"), 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(src, "aaa-subdir", "inner.txt"), []byte("inner\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +case "$RACE_CASE" in + same-name) + mkdir() { + if [ "$1" = "$DWS_TEST_DEST/aaa-subdir" ]; then + # The concurrent writer takes the child path as an empty directory + # between the top-level claim and the child publish. POSIX mv would + # replace it; the no-clobber child primitive must refuse instead. + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" + } + ;; + foreign-name) + ln() { + if [ "$2" = "$DWS_TEST_DEST/SKILL.md" ]; then + command ln "$@" + # A different-named foreign entry lands inside the claim while the + # remaining staged children are still publishing. + printf '%s\n' must-survive > "$DWS_TEST_DEST/zzz-foreign" + return 0 + fi + command ln "$@" + } + ;; +esac +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +case "$RACE_CASE" in + same-name) + [ -f "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt")" = "must-survive" ] || exit 4 + [ ! -e "$DWS_TEST_DEST/aaa-subdir/inner.txt" ] || exit 5 + [ ! -e "$DWS_TEST_DEST/SKILL.md" ] || exit 6 + ;; + foreign-name) + [ -f "$DWS_TEST_DEST/zzz-foreign" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/zzz-foreign")" = "must-survive" ] || exit 4 + # The transaction's own children are retracted; only the foreign entry + # remains in the retained destination. + if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^zzz-foreign$')" ]; then + ls -A "$DWS_TEST_DEST" >&2 + exit 5 + fi + ;; +esac +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "copy-child-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + "RACE_CASE="+raceCase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy child race (%s) must preserve the concurrent object: %v\n%s", scriptName, raceCase, err, string(output)) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish (%s)", scriptName, raceCase) + } + }) + } + } +} + func TestInstallEventScriptStaticExpectations(t *testing.T) { t.Parallel() From c0e1ec576a8282f08efb0f90f88678ef4ba1a821 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 19 Aug 2026 20:48:43 +0800 Subject: [PATCH 182/267] ci: govern command path migrations --- .changes/command-path-migration-governance.md | 5 + CONTRIBUTING.md | 4 +- cmd/interface-snapshot/main.go | 64 ++- cmd/interface-snapshot/main_test.go | 123 +++++ docs/ci-pr-gates.md | 10 +- docs/cli-interface-flag-migrations.md | 22 +- .../interfacesnapshot/command_migrations.go | 507 ++++++++++++++++++ .../command_migrations_test.go | 430 +++++++++++++++ internal/interfacesnapshot/migrations.go | 40 +- ...heck-authoritative-schema-compatibility.sh | 79 ++- scripts/policy/check-command-compatibility.sh | 57 +- .../approved-command-migrations-v1.json | 80 +++ .../schema-compat/command_migrations_test.go | 424 +++++++++++++++ scripts/policy/schema-compat/main.go | 262 ++++++++- scripts/policy/schema-compat/main_test.go | 2 +- 15 files changed, 2054 insertions(+), 55 deletions(-) create mode 100644 .changes/command-path-migration-governance.md create mode 100644 internal/interfacesnapshot/command_migrations.go create mode 100644 internal/interfacesnapshot/command_migrations_test.go create mode 100644 scripts/policy/interface-migrations/approved-command-migrations-v1.json create mode 100644 scripts/policy/schema-compat/command_migrations_test.go diff --git a/.changes/command-path-migration-governance.md b/.changes/command-path-migration-governance.md new file mode 100644 index 00000000..b78a839d --- /dev/null +++ b/.changes/command-path-migration-governance.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or flags from Help and Schema when capability moves to a dedicated command. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce9d6372..4c334984 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code. `make authoritative-interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF=`. The Make target delegates to the authoritative wrapper; CI does not invoke a second comparator or the legacy fixture checker. See - [CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md) + [CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md) for the reviewed two-stage `pending` → `consumed` lifecycle. - Agent-visible flag migrations must also run + Agent-visible flag or command-path migrations must also run `make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF=`; it consumes the same base-owned ledger rather than a second exception list. 5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go index bfaab482..f00252c9 100644 --- a/cmd/interface-snapshot/main.go +++ b/cmd/interface-snapshot/main.go @@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "", "candidate flag migration manifest", ) + approvedCommandMigrationsPath := flags.String( + "approved-command-migrations", + "", + "merge-base-owned approved command migration manifest", + ) + candidateCommandMigrationsPath := flags.String( + "candidate-command-migrations", + "", + "candidate command migration manifest", + ) if err := flags.Parse(args); err != nil { return false, err } @@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "--approved-flag-migrations and --candidate-flag-migrations must be provided together", ) } - if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") { - return false, fmt.Errorf("flag migration compare requires both --base and --stable") + if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") { + return false, fmt.Errorf( + "--approved-command-migrations and --candidate-command-migrations must be provided together", + ) + } + if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") { + return false, fmt.Errorf("migration compare requires both --base and --stable") } current, err := readSnapshot(*currentPath) @@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { } report := interfacesnapshot.CompareAll(current, references) - if *approvedMigrationsPath != "" { + if *approvedCommandMigrationsPath != "" { + flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}} + flagCandidate := flagApproved + if *approvedMigrationsPath != "" { + flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath) + if err != nil { + return false, fmt.Errorf("read approved flag migrations: %w", err) + } + flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath) + if err != nil { + return false, fmt.Errorf("read candidate flag migrations: %w", err) + } + } + commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read approved command migrations: %w", readErr) + } + commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read candidate command migrations: %w", readErr) + } + report, err = interfacesnapshot.CompareAllWithInterfaceMigrations( + current, + references, + flagApproved, + flagCandidate, + commandApproved, + commandCandidate, + ) + if err != nil { + return false, fmt.Errorf("validate interface migration lifecycle: %w", err) + } + } else if *approvedMigrationsPath != "" { approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath) if readErr != nil { return false, fmt.Errorf("read approved flag migrations: %w", readErr) @@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani return interfacesnapshot.ReadFlagMigrationManifest(file) } +func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + defer file.Close() + return interfacesnapshot.ReadCommandMigrationManifest(file) +} + func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error { for _, command := range snapshot.Commands { path := strings.TrimPrefix(command.Path, "dws") @@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") - fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]") } diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go index 62bb91cb..7bb44fb7 100644 --- a/cmd/interface-snapshot/main_test.go +++ b/cmd/interface-snapshot/main_test.go @@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi } } +func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`) + emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`) + invalid := writeManifest(t, dir, "invalid-commands.json", `{`) + + var stdout, stderr bytes.Buffer + args := []string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + "--stable", snapshotPath, + "--approved-flag-migrations", emptyFlag, + "--candidate-flag-migrations", emptyFlag, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(args, &stdout, &stderr); exitCode != 0 { + t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String()) + } + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved flag", invalid, emptyFlag, "read approved flag migrations"}, + {"candidate flag", emptyFlag, invalid, "read candidate flag migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-flag-migrations", test.approved, + "--candidate-flag-migrations", test.candidate, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved", invalid, emptyCommand, "read approved command migrations"}, + {"candidate", emptyCommand, invalid, "read candidate command migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", test.approved, + "--candidate-command-migrations", test.candidate, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, + "--approved-command-migrations", emptyCommand, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") { + t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String()) + } + + if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil { + t.Fatal("missing command migration manifest unexpectedly read") + } + if _, err := readCommandMigrationManifest(invalid); err == nil { + t.Fatal("invalid command migration manifest unexpectedly read") + } + + pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending")) + consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed")) + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", pending, + "--candidate-command-migrations", consumed, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") { + t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String()) + } +} + func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) { dir := t.TempDir() currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws")) @@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string { }`, "STATE", state, 1) } +func commandMigrationManifestJSON(state string) string { + return strings.Replace(`{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.move", + "replacement_tool_id": "chat.move", + "parameters": [] + }, + "state": "STATE", + "reason": "reviewed command migration" + }] +}`, "STATE", state, 1) +} + func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { for _, flag := range flags { if flag.Name == name && flag.Type == flagType { diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index d34f3679..8016833d 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -184,11 +184,11 @@ candidate SHA。 `check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准 flag 迁移。 -Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag -migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整 -Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface -lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会 -放宽其他 Schema 历史字段。 +Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag +与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与 +stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 +Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史 +副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。 For a release-seal branch that archives rendered fragments: diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index d4a50124..ef2fa9e8 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -1,7 +1,9 @@ -# CLI flag 兼容迁移治理 +# CLI Help / Schema 兼容迁移治理 本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。 +同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。 + 同名 flag 的精确类型迁移属于另一类评审机制,只能进入 `internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename 只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。 @@ -31,7 +33,7 @@ Smoke fixture,不参与迁移审批。 同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免 调用方因漏传历史参考而提前清理 consumed receipt。 -PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 +PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。 @@ -39,10 +41,26 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁 ```text scripts/policy/interface-migrations/approved-flag-migrations-v1.json +scripts/policy/interface-migrations/approved-command-migrations-v1.json ``` 清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 +## 跨命令迁移原语 + +`approved-command-migrations-v1.json` 只接受两种 `kind`: + +| kind | CLI after 状态 | Schema 允许的精确投影 | +|---|---|---| +| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | +| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity | + +两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 + +跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 + +当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping 或 reason。 + ## 两阶段迁移与回执清理 每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go new file mode 100644 index 00000000..785c02b1 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations.go @@ -0,0 +1,507 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strings" +) + +const CommandMigrationManifestVersion = 1 + +const ( + CommandMigrationPending = "pending" + CommandMigrationConsumed = "consumed" + + CommandMigrationMove = "command_move" + CommandMigrationFlagExtraction = "flag_extraction" +) + +// CommandMigrationManifest governs compatibility-preserving surface moves that +// cannot be represented as an in-command flag rename. The merge-base owns the +// authorization; the candidate copy is only a lifecycle receipt. +type CommandMigrationManifest struct { + Version int `json:"version"` + Migrations []CommandMigration `json:"migrations"` +} + +type CommandMigration struct { + Kind string `json:"kind"` + Legacy CommandMigrationSide `json:"legacy"` + Replacement CommandMigrationSide `json:"replacement"` + LegacyFlag CommandMigrationFlag `json:"legacy_flag"` + Schema CommandMigrationSchema `json:"schema"` + State string `json:"state"` + Reason string `json:"reason"` +} + +type CommandMigrationSide struct { + Command string `json:"command"` + Before CommandMigrationState `json:"before"` + After CommandMigrationState `json:"after"` +} + +type CommandMigrationState struct { + Present bool `json:"present"` + Runnable bool `json:"runnable,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type CommandMigrationFlag struct { + Name string `json:"name,omitempty"` + Before FlagMigrationState `json:"before"` + After FlagMigrationState `json:"after"` +} + +type CommandMigrationSchema struct { + ProductID string `json:"product_id"` + SourceToolID string `json:"source_tool_id"` + ReplacementToolID string `json:"replacement_tool_id"` + Parameters []CommandParameterMigration `json:"parameters"` +} + +type CommandParameterMigration struct { + From string `json:"from"` + To string `json:"to,omitempty"` +} + +func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err) + } + var manifest CommandMigrationManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return CommandMigrationManifest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values") + } + return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err) + } + strict := json.NewDecoder(bytes.NewReader(data)) + strict.UseNumber() + if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil { + return CommandMigrationManifest{}, err + } + if err := manifest.Validate(); err != nil { + return CommandMigrationManifest{}, err + } + return manifest, nil +} + +func (m CommandMigrationManifest) Validate() error { + if m.Version != CommandMigrationManifestVersion { + return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion) + } + if m.Migrations == nil { + return fmt.Errorf("command migration manifest migrations must be an array") + } + seen := make(map[string]bool, len(m.Migrations)) + for index, migration := range m.Migrations { + if err := migration.validate(); err != nil { + return fmt.Errorf("command migration %d: %w", index, err) + } + if seen[migration.key()] { + return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey()) + } + seen[migration.key()] = true + } + return nil +} + +func (m CommandMigration) validate() error { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid kind %q", m.Kind) + } + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } + if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { + return fmt.Errorf("migration must include a non-empty trimmed reason") + } + if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { + return fmt.Errorf("invalid state %q", m.State) + } + for label, state := range map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + "replacement before": m.Replacement.Before, + "replacement after": m.Replacement.After, + } { + if err := state.validate(label); err != nil { + return err + } + } + if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || + !m.Legacy.After.Present || !m.Legacy.After.Runnable { + return fmt.Errorf("legacy command must remain runnable and start visible") + } + if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") + } + if err := m.Schema.validate(m.Kind); err != nil { + return err + } + switch m.Kind { + case CommandMigrationMove: + if !m.Legacy.After.Hidden { + return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("command_move must not declare legacy_flag") + } + if m.Schema.SourceToolID != m.Schema.ReplacementToolID { + return fmt.Errorf("command_move must retain one stable Schema tool identity") + } + case CommandMigrationFlagExtraction: + if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After { + return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged") + } + if err := m.LegacyFlag.validate(); err != nil { + return err + } + if m.Schema.SourceToolID == m.Schema.ReplacementToolID { + return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool") + } + if len(m.Schema.Parameters) != 1 || m.Schema.Parameters[0].From != m.LegacyFlag.Name || m.Schema.Parameters[0].To != "" { + return fmt.Errorf("flag_extraction must declare the exact extracted flag as one removed Schema parameter") + } + } + return nil +} + +func (s CommandMigrationState) validate(label string) error { + if !s.Present { + if s.Runnable || s.Hidden { + return fmt.Errorf("%s absent state must not declare command attributes", label) + } + return nil + } + if !s.Runnable { + return fmt.Errorf("%s present state must be runnable", label) + } + return nil +} + +func (f CommandMigrationFlag) validate() error { + if !isExactFlagName(f.Name) { + return fmt.Errorf("legacy_flag name must be an exact flag") + } + if err := f.Before.validate("legacy_flag before"); err != nil { + return err + } + if err := f.After.validate("legacy_flag after"); err != nil { + return err + } + if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden { + return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present") + } + before := f.Before + after := f.After + before.Hidden = false + after.Hidden = false + if before != after { + return fmt.Errorf("legacy_flag may change only hidden visibility") + } + return nil +} + +func (s CommandMigrationSchema) validate(kind string) error { + for label, value := range map[string]string{ + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, + "replacement_tool_id": s.ReplacementToolID, + } { + if !isExactSchemaIdentifier(value) { + return fmt.Errorf("schema %s must be an exact identifier", label) + } + } + if s.Parameters == nil { + return fmt.Errorf("schema parameters must be an array") + } + seenFrom := map[string]bool{} + seenTo := map[string]bool{} + for index, parameter := range s.Parameters { + if !isExactFlagName(parameter.From) { + return fmt.Errorf("schema parameter %d from must be an exact parameter name", index) + } + if seenFrom[parameter.From] { + return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From) + } + seenFrom[parameter.From] = true + if kind == CommandMigrationMove { + if !isExactFlagName(parameter.To) || parameter.To == parameter.From { + return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + seenTo[parameter.To] = true + } else if parameter.To != "" { + return fmt.Errorf("flag_extraction schema parameters must not declare to") + } + } + return nil +} + +func isExactSchemaIdentifier(value string) bool { + return value != "" && value == strings.TrimSpace(value) && + !strings.ContainsAny(value, "*?[]{} /\\\t\r\n") +} + +func (m CommandMigration) key() string { + return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00") +} + +func (m CommandMigration) displayKey() string { + return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) +} + +type commandMigrationPhase string + +const ( + commandMigrationBefore commandMigrationPhase = "before" + commandMigrationAfter commandMigrationPhase = "after" + commandMigrationPartial commandMigrationPhase = "partial" +) + +func AuthorizeCommandMigrations( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if err := current.Validate(); err != nil { + return nil, fmt.Errorf("validate current interface snapshot: %w", err) + } + for label, snapshot := range references { + if err := snapshot.Validate(); err != nil { + return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err) + } + } + if err := authority.Validate(); err != nil { + return nil, fmt.Errorf("validate approved command migrations: %w", err) + } + if err := candidate.Validate(); err != nil { + return nil, fmt.Errorf("validate candidate command migrations: %w", err) + } + return evaluateCommandMigrationLifecycle(current, references, authority, candidate) +} + +func evaluateCommandMigrationLifecycle( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 { + return nil, nil + } + if _, ok := references["stable"]; !ok { + return nil, fmt.Errorf("command migration lifecycle requires a stable reference") + } + mergeBase, label, ok := flagMigrationAuthoritySnapshot(references) + if !ok { + return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference") + } + authorityByKey := commandMigrationIndex(authority) + candidateByKey := commandMigrationIndex(candidate) + authorizations := make([]CommandMigration, 0, len(authority.Migrations)) + for _, approved := range authority.Migrations { + basePhase := matchCommandMigrationPhase(mergeBase, approved) + wantBase := commandMigrationBefore + if approved.State == CommandMigrationConsumed { + wantBase = commandMigrationAfter + } + if basePhase != wantBase { + return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State) + } + proposed, exists := candidateByKey[approved.key()] + if exists && !sameCommandMigrationApproval(approved, proposed) { + return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey()) + } + currentPhase := matchCommandMigrationPhase(current, approved) + switch approved.State { + case CommandMigrationPending: + if !exists { + return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey()) + } + switch currentPhase { + case commandMigrationBefore: + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey()) + } + case commandMigrationAfter: + if proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey()) + } + authorizations = append(authorizations, approved) + default: + return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey()) + } + case CommandMigrationConsumed: + if currentPhase != commandMigrationAfter { + return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey()) + } + allAfter := true + for _, reference := range references { + if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter { + allAfter = false + break + } + } + if allAfter { + if exists { + return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey()) + } + continue + } + if !exists || proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey()) + } + authorizations = append(authorizations, approved) + } + } + for _, proposed := range candidate.Migrations { + if _, exists := authorityByKey[proposed.key()]; exists { + continue + } + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey()) + } + if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey()) + } + if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey()) + } + } + return authorizations, nil +} + +func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration { + index := make(map[string]CommandMigration, len(manifest.Migrations)) + for _, migration := range manifest.Migrations { + index[migration.key()] = migration + } + return index +} + +func sameCommandMigrationApproval(left, right CommandMigration) bool { + left.State = "" + right.State = "" + return reflect.DeepEqual(left, right) +} + +func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { + commands := commandIndex(snapshot) + legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) + before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before + after := legacy == migration.Legacy.After && replacement == migration.Replacement.After + if migration.Kind == CommandMigrationFlagExtraction { + command, exists := commands[migration.Legacy.Command] + flagState := FlagMigrationState{} + if exists { + flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name) + } + before = before && flagState == migration.LegacyFlag.Before + after = after && flagState == migration.LegacyFlag.After + } + if before { + return commandMigrationBefore + } + if after { + return commandMigrationAfter + } + return commandMigrationPartial +} + +func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState { + command, exists := commands[path] + if !exists { + return CommandMigrationState{} + } + return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden} +} + +// CompareAllWithInterfaceMigrations applies both migration families to one +// ordinary report, so the two ledgers cannot mask each other's unrelated +// findings. +func CompareAllWithInterfaceMigrations( + current Snapshot, + references map[string]Snapshot, + flagAuthority FlagMigrationManifest, + flagCandidate FlagMigrationManifest, + commandAuthority CommandMigrationManifest, + commandCandidate CommandMigrationManifest, +) (Report, error) { + flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate) + if err != nil { + return Report{}, err + } + commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate) + if err != nil { + return Report{}, err + } + report := CompareAll(current, references) + for index := range report.Comparisons { + comparison := &report.Comparisons[index] + reference := references[comparison.Reference] + filtered := comparison.Blocking[:0] + for _, change := range comparison.Blocking { + if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) || + commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) { + continue + } + filtered = append(filtered, change) + } + comparison.Blocking = filtered + comparison.Compatible = len(filtered) == 0 + } + report.Compatible = true + for _, comparison := range report.Comparisons { + if !comparison.Compatible { + report.Compatible = false + break + } + } + return report, nil +} + +func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool { + canonicalPath := acceptedPathIndex(reference)[change.Path] + if canonicalPath == "" { + canonicalPath = change.Path + } + for _, migration := range authorizations { + if canonicalPath != migration.Legacy.Command || + matchCommandMigrationPhase(reference, migration) != commandMigrationBefore || + matchCommandMigrationPhase(current, migration) != commandMigrationAfter { + continue + } + switch migration.Kind { + case CommandMigrationMove: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } + case CommandMigrationFlagExtraction: + if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { + return true + } + } + } + return false +} diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go new file mode 100644 index 00000000..7b511f29 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -0,0 +1,430 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "errors" + "os" + "strings" + "testing" +) + +type commandMigrationErrorReader struct{} + +func (commandMigrationErrorReader) Read([]byte) (int, error) { + return 0, errors.New("command migration read failure") +} + +func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json") + if err != nil { + t.Fatalf("open approved command migration manifest: %v", err) + } + defer manifest.Close() + if _, err := ReadCommandMigrationManifest(manifest); err != nil { + t.Fatalf("approved command migration manifest is invalid: %v", err) + } +} + +func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) { + valid := commandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil { + t.Fatalf("valid command migration manifest: %v", err) + } + for _, test := range []struct { + input string + want string + }{ + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"}, + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"}, + {strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"}, + {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"}, + {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"}, + {`{"version":1,"migrations":null}`, "must be an array"}, + } { + if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want) + } + } + if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") { + t.Fatalf("reader error=%v", err) + } + for _, input := range []string{valid + ` {}`, valid + ` {`} { + if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing input error=%v", err) + } + } +} + +func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { + validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0] + validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1] + + duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}} + if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("duplicate error=%v", err) + } + if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") { + t.Fatalf("nil migrations error=%v", err) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"}, + {"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"}, + {"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"}, + {"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"}, + {"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"}, + {"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"}, + {"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"}, + {"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"}, + {"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"}, + {"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := validMove + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"}, + {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"}, + {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"}, + {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[0].From = "other" }, "exact extracted flag"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := validExtraction + migration.Schema.Parameters = append([]CommandParameterMigration(nil), validExtraction.Schema.Parameters...) + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + + for _, state := range []CommandMigrationState{ + {Runnable: true}, + {Present: true}, + } { + if err := state.validate("state"); err == nil { + t.Fatalf("invalid command state accepted: %#v", state) + } + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + want string + }{ + {"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"}, + {"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"}, + {"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"}, + {"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"}, + {"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"}, + } { + t.Run("flag "+test.name, func(t *testing.T) { + flag := validExtraction.LegacyFlag + test.mutate(&flag) + if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("flag validate error=%v, want %q", err, test.want) + } + }) + } + + validSchema := validMove.Schema + for _, test := range []struct { + name string + kind string + mutate func(*CommandMigrationSchema) + want string + }{ + {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, + {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, + {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, + {"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"}, + {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To}) + }, "duplicates to"}, + {"extraction to", CommandMigrationFlagExtraction, func(s *CommandMigrationSchema) { s.Parameters[0].To = "new-id" }, "must not declare to"}, + } { + t.Run("schema "+test.name, func(t *testing.T) { + schema := validSchema + schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...) + test.mutate(&schema) + if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + partial := commandMigrationSnapshot(false, false) + partial.Commands = append(partial.Commands, testCommand("dws chat topic new")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 { + t.Fatalf("empty lifecycle=%#v, %v", got, err) + } + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + want string + }{ + {"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"}, + {"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"}, + {"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"}, + {"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"}, + {"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"}, + {"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"}, + {"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"}, + {"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"}, + {"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"}, + {"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"}, + {"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"}, + {"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"}, + {"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"}, + {"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("lifecycle error=%v, want %q", err, test.want) + } + }) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 { + t.Fatalf("retained consumed receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 { + t.Fatalf("cleaned stale receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 { + t.Fatalf("candidate pending plan=%#v, %v", got, err) + } + + invalidSnapshot := before + invalidSnapshot.SchemaVersion = 0 + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + }{ + {"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty}, + {"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty}, + {"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty}, + {"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}}, + } { + t.Run("invalid "+test.name, func(t *testing.T) { + if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil { + t.Fatal("invalid authorization input accepted") + } + }) + } + invalidFlags := FlagMigrationManifest{} + validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil { + t.Fatal("combined compare accepted invalid flag lifecycle") + } + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil { + t.Fatal("combined compare accepted invalid command lifecycle") + } + if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) { + t.Fatal("unrelated command change was authorized") + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := commandMigrationManifest(CommandMigrationPending) + consumed := commandMigrationManifest(CommandMigrationConsumed) + emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatalf("governed command migration: %v", err) + } + if !report.Compatible { + t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons) + } + + unrelated := commandMigrationSnapshot(true, true) + report, err = CompareAllWithInterfaceMigrations( + unrelated, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatal(err) + } + if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") { + t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons) + } + + emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyCommands, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error=%v", err) + } + + partial := commandMigrationSnapshot(true, false) + partial.Commands = partial.Commands[:len(partial.Commands)-1] + if _, err := AuthorizeCommandMigrations( + partial, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("partial command migration error=%v", err) + } +} + +func commandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.old", + "replacement_tool_id": "chat.old", + "parameters": [{"from": "old-id", "to": "new-id"}] + }, + "state": "pending", + "reason": "Reviewed command move." + }] +}` +} + +func commandMigrationManifest(state string) CommandMigrationManifest { + move := CommandMigration{ + Kind: CommandMigrationMove, + Legacy: CommandMigrationSide{ + Command: "dws chat message old", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic new", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.old", + ReplacementToolID: "chat.old", + Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + State: state, + Reason: "Reviewed command move.", + } + extraction := CommandMigration{ + Kind: CommandMigrationFlagExtraction, + Legacy: CommandMigrationSide{ + Command: "dws chat group create", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic create", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: CommandMigrationFlag{ + Name: "thread", + Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []CommandParameterMigration{{From: "thread"}}, + }, + State: state, + Reason: "Reviewed flag extraction.", + } + return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}} +} + +func singleCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[:1] + return manifest +} + +func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { + modified := source + modified.Migrations = append([]CommandMigration(nil), source.Migrations...) + modified.Migrations[0].Reason = "Modified reason." + return modified +} + +func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot { + oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}} + groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}} + commands := []Command{ + testCommand("dws"), + testCommand("dws chat group create", groupFlags...), + testCommand("dws chat message old", oldFlags...), + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + if removeUnrelated { + commands[2].LocalFlags = commands[2].LocalFlags[:1] + } + commands = append(commands, + testCommand("dws chat topic create"), + testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}), + ) + } + return testSnapshot(commands...) +} diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 892a7c55..ff736994 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -91,22 +91,26 @@ func validateFlagMigrationJSONSchema(data []byte) error { } func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error { + return validateLabeledMigrationJSONValue(decoder, path, schema, "flag") +} + +func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error { token, err := decoder.Token() if err != nil { - return fmt.Errorf("read flag migration manifest value at %s: %w", path, err) + return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err) } switch schema.Kind() { case reflect.Struct: if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } fields := migrationJSONFields(schema) seen := make(map[string]bool, len(fields)) for decoder.More() { keyToken, keyErr := decoder.Token() if keyErr != nil { - return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr) + return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr) } // encoding/json guarantees object member names are string tokens. key := keyToken.(string) @@ -115,57 +119,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle for canonical := range fields { if strings.EqualFold(key, canonical) { return fmt.Errorf( - "flag migration manifest contains non-canonical field %q at %s (want %q)", + "%s migration manifest contains non-canonical field %q at %s (want %q)", + label, key, path, canonical, ) } } - return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path) } if seen[key] { - return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path) } seen[key] = true - if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr) } return nil case reflect.Slice: if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } for index := 0; decoder.More(); index++ { - if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr) } return nil case reflect.String: if _, ok := token.(string); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Int: if _, ok := token.(json.Number); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Bool: if _, ok := token.(bool); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil default: - return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema) + return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema) } } @@ -188,9 +193,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type { return fields } -func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error { +func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error { return fmt.Errorf( - "flag migration manifest value at %s must be %s, got %s", + "%s migration manifest value at %s must be %s, got %s", + label, path, migrationJSONKindDescription(want), migrationJSONTokenDescription(token), diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 16453ad4..1cad7e74 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -13,7 +13,9 @@ STABLE_REF="" CANDIDATE_REF="HEAD" SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" usage() { @@ -173,6 +175,31 @@ has_any_schema_migration_governance_artifact() { return 1 } +has_complete_schema_command_migration_governance() { + governance_commit="$1" + has_complete_schema_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_schema_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +require_complete_candidate_schema_command_governance() { + if ! has_complete_schema_command_migration_governance "$CANDIDATE_COMMIT"; then + printf 'error: candidate must preserve the complete Schema command migration governance artifact set at %s\n' \ + "$CANDIDATE_COMMIT" >&2 + exit 2 + fi +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -229,6 +256,7 @@ check_candidate_alias_source_policy EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false if has_complete_schema_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -252,6 +280,21 @@ else fi fi +if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_schema_command_governance +elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap keeps the old base-owned checker in control and therefore grants + # no command migration authorization in the governance PR itself. + require_complete_candidate_schema_command_governance +fi + BASE_BIN="$TMP_ROOT/base-dws" STABLE_BIN="$TMP_ROOT/stable-dws" CANDIDATE_BIN="$TMP_ROOT/candidate-dws" @@ -337,16 +380,28 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT" generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT" generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT" -check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" +check_with_migrations() { + historical_kind="$1" + historical_ref="$2" + historical_baseline="$3" + if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" + else + check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" + fi +} -check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" +check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE" +check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index d26a7abd..01724159 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -12,6 +12,8 @@ BASE_REF="" STABLE_REF="" CANDIDATE_REF="HEAD" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" usage() { @@ -166,6 +168,31 @@ require_complete_candidate_governance() { fi } +has_complete_command_migration_governance() { + governance_commit="$1" + has_complete_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +require_complete_candidate_command_governance() { + if ! has_complete_command_migration_governance "$CANDIDATE_COMMIT"; then + printf 'candidate must preserve the complete command migration governance artifact set at %s\n' \ + "$CANDIDATE_COMMIT" >&2 + exit 2 + fi +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -225,6 +252,7 @@ install_authority_helper() { EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false if has_complete_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -260,6 +288,21 @@ else install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false fi +if has_complete_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_command_governance +elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap is safe because the base-owned comparator receives no new + # authorization input and still performs the ordinary compatibility check. + require_complete_candidate_command_governance +fi + install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" @@ -280,7 +323,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE" generate_snapshot "$BASE_WORKTREE" "$BASELINE" generate_snapshot "$STABLE_WORKTREE" "$STABLE" -if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" + ) +elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then ( cd "$AUTHORITY_ROOT" go run ./cmd/interface-snapshot compare \ diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json new file mode 100644 index 00000000..cd85e1a0 --- /dev/null +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "migrations": [ + { + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group_conversation", + "replacement_tool_id": "chat.create_topic_conversation", + "parameters": [{"from": "thread"}] + }, + "state": "pending", + "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message list-topic-replies", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic list-replies", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.list_topic_replies", + "replacement_tool_id": "chat.list_topic_replies", + "parameters": [ + {"from": "conversation-id", "to": "open-topic-id"}, + {"from": "topic-id", "to": "open-conv-thread-id"} + ] + }, + "state": "pending", + "reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message forward-topic", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic forward", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.forward_topic", + "replacement_tool_id": "chat.forward_topic", + "parameters": [ + {"from": "src-conversation-id", "to": "src-open-topic-id"}, + {"from": "src-thread-id", "to": "src-open-conv-thread-id"}, + {"from": "dest-conversation-id", "to": "dest-open-conversation-id"} + ] + }, + "state": "pending", + "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command." + } + ] +} diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go new file mode 100644 index 00000000..af1636bc --- /dev/null +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -0,0 +1,424 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("normalize exact command migrations: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact command migrations remained incompatible: %v", failures) + } + + unrelated := cloneContract(current) + product := unrelated.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "keep") + product.Tools["chat.move"] = tool + unrelated.Products["chat"] = product + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) { + t.Fatalf("unrelated parameter removal was hidden: %s", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + parameterDrift := schemaCommandMigrationContract(true) + product := parameterDrift.Products["chat"] + tool := product.Tools["chat.move"] + parameter := tool.Parameters["new-id"] + parameter.Property = "differentProperty" + tool.Parameters["new-id"] = parameter + product.Tools["chat.move"] = tool + parameterDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") { + t.Fatalf("parameter drift error=%v", err) + } + + safetyDrift := schemaCommandMigrationContract(true) + product = safetyDrift.Products["chat"] + replacement := product.Tools["chat.create_topic"] + replacement.Risk = "high" + product.Tools["chat.create_topic"] = replacement + safetyDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") { + t.Fatalf("extraction safety drift error=%v", err) + } + + missingReplacement := schemaCommandMigrationContract(true) + product = missingReplacement.Products["chat"] + delete(product.Tools, "chat.create_topic") + missingReplacement.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") { + t.Fatalf("missing replacement error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + + missingHistorical := cloneContract(baseline) + product := missingHistorical.Products["chat"] + delete(product.Tools, "chat.move") + missingHistorical.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil { + t.Fatalf("missing historical tool should be a no-op: %v", err) + } + + missingCurrent := cloneContract(current) + product = missingCurrent.Products["chat"] + delete(product.Tools, "chat.move") + missingCurrent.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil { + t.Fatalf("missing current source should remain for ordinary checker: %v", err) + } + + alreadyAfter := schemaCommandMigrationContract(true) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil { + t.Fatalf("already-after baseline should be a no-op: %v", err) + } + + wrongHistoricalPath := cloneContract(baseline) + product = wrongHistoricalPath.Products["chat"] + tool := product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongHistoricalPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") { + t.Fatalf("wrong historical path error=%v", err) + } + + wrongCurrentPath := cloneContract(current) + product = wrongCurrentPath.Products["chat"] + tool = product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongCurrentPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil { + t.Fatalf("wrong current path should remain for ordinary checker: %v", err) + } + + missingHistoricalParameter := cloneContract(baseline) + product = missingHistoricalParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "old-id") + product.Tools["chat.move"] = tool + missingHistoricalParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") { + t.Fatalf("missing historical parameter error=%v", err) + } + + legacyPublished := cloneContract(current) + product = legacyPublished.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") { + t.Fatalf("legacy parameter error=%v", err) + } + + missingReplacementParameter := cloneContract(current) + product = missingReplacementParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + product.Tools["chat.move"] = tool + missingReplacementParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") { + t.Fatalf("missing replacement parameter error=%v", err) + } + + constraintDrift := cloneContract(current) + product = constraintDrift.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = `{"require_one_of":[["new-id"]]}` + product.Tools["chat.move"] = tool + constraintDrift.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") { + t.Fatalf("constraint drift was hidden: %s", failures) + } + + extractionWrongSource := cloneContract(current) + product = extractionWrongSource.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.create_group"] = tool + extractionWrongSource.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil { + t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err) + } + + extractionHistoricalMissing := cloneContract(baseline) + product = extractionHistoricalMissing.Products["chat"] + tool = product.Tools["chat.create_group"] + delete(tool.Parameters, "thread") + product.Tools["chat.create_group"] = tool + extractionHistoricalMissing.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") { + t.Fatalf("missing extracted historical parameter error=%v", err) + } + + extractionStillPublished := cloneContract(current) + product = extractionStillPublished.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"] + product.Tools["chat.create_group"] = tool + extractionStillPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") { + t.Fatalf("still-published extraction error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) { + directory := t.TempDir() + baselinePath := filepath.Join(directory, "baseline.json") + currentPath := filepath.Join(directory, "current.json") + approvedPath := filepath.Join(directory, "approved.json") + candidatePath := filepath.Join(directory, "candidate.json") + currentSnapshotPath := filepath.Join(directory, "current-snapshot.json") + baseSnapshotPath := filepath.Join(directory, "base-snapshot.json") + stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json") + + writeSchemaContractFile(t, baselinePath, schemaCommandMigrationContract(false)) + writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true)) + writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending)) + writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false)) + writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false)) + + args := []string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + "--migration-current-snapshot", currentSnapshotPath, + "--migration-base-snapshot", baseSnapshotPath, + "--migration-stable-snapshot", stableSnapshotPath, + } + var stdout, stderr strings.Builder + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("command migration run code=%d stderr=%s", code, stderr.String()) + } + + paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath} + for index := range paths { + invalid := append([]string(nil), paths...) + invalid[index] = filepath.Join(directory, "missing.json") + if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil { + t.Fatalf("missing command migration input %d was accepted", index) + } + } + + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") { + t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") { + t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") { + t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String()) + } + + badArgs := append([]string(nil), args...) + badArgs[5] = filepath.Join(directory, "missing-approved.json") + stderr.Reset() + if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") { + t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String()) + } + + legacyCurrent := schemaCommandMigrationContract(true) + product := legacyCurrent.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyCurrent.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, legacyCurrent) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") { + t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String()) + } +} + +func schemaCommandMigrationContract(after bool) schemaContract { + id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} + keep := parameterSchema{Type: `"string"`, Property: "keep"} + thread := parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + group := toolSchema{ + PrimaryCLIPath: "chat group create", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`, + Availability: "available", + Parameters: map[string]parameterSchema{"name": keep, "thread": thread}, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", + } + move := toolSchema{ + PrimaryCLIPath: "chat message old", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`, + Availability: "available", + Parameters: map[string]parameterSchema{"old-id": id, "keep": keep}, + Constraints: `{"require_together":[["keep","old-id"]]}`, + Effect: "read", + Risk: "low", + Confirmation: "not_required", + Idempotency: "idempotent", + } + tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move} + if after { + delete(group.Parameters, "thread") + tools["chat.create_group"] = group + replacement := group + replacement.PrimaryCLIPath = "chat topic create" + tools["chat.create_topic"] = replacement + delete(move.Parameters, "old-id") + move.Parameters["new-id"] = id + move.PrimaryCLIPath = "chat topic new" + move.Constraints = `{"require_together":[["keep","new-id"]]}` + tools["chat.move"] = move + } + return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}} +} + +func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration { + return []interfacesnapshot.CommandMigration{ + { + Kind: interfacesnapshot.CommandMigrationMove, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat message old", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic new", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.move", + ReplacementToolID: "chat.move", + Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + }, + { + Kind: interfacesnapshot.CommandMigrationFlagExtraction, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat group create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic create", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: interfacesnapshot.CommandMigrationFlag{ + Name: "thread", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, Scope: "local"}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []interfacesnapshot.CommandParameterMigration{{From: "thread"}}, + }, + }, + } +} + +func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest { + migrations := schemaCommandMigrationAuthorizations() + for index := range migrations { + migrations[index].State = state + migrations[index].Reason = "Reviewed Schema command migration." + } + return interfacesnapshot.CommandMigrationManifest{ + Version: interfacesnapshot.CommandMigrationManifestVersion, + Migrations: migrations, + } +} + +func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { + commands := []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + { + Path: "dws chat group create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{{ + Name: "thread", Type: "bool", + }}, + InheritedFlags: []interfacesnapshot.Flag{}, + }, + {Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + commands = append(commands, + interfacesnapshot.Command{Path: "dws chat topic create", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + ) + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"}, + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + +func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(manifest); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 1f55eae4..2d19c0e2 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -86,6 +86,7 @@ func main() { func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string var approvedFlagMigrationsPath, candidateFlagMigrationsPath string + var approvedCommandMigrationsPath, candidateCommandMigrationsPath string var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) flags.SetOutput(stderr) @@ -95,6 +96,8 @@ func run(args []string, stdout, stderr io.Writer) int { flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest") flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest") + flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest") + flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest") flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization") flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization") flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization") @@ -112,25 +115,38 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") return 2 } - migrationInputs := []string{ - approvedFlagMigrationsPath, - candidateFlagMigrationsPath, + flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != "" + commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != "" + if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests") + return 2 + } + if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests") + return 2 + } + migrationSnapshots := []string{ migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath, } - migrationInputCount := 0 - for _, path := range migrationInputs { + migrationSnapshotCount := 0 + for _, path := range migrationSnapshots { if path != "" { - migrationInputCount++ + migrationSnapshotCount++ } } - if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) { - fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs") + migrationsEnabled := flagMigrationPair || commandMigrationPair + if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) { + fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots") return 2 } - if migrationInputCount != 0 && checkPath == "" { - fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check") + if !migrationsEnabled && migrationSnapshotCount != 0 { + fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair") + return 2 + } + if migrationsEnabled && checkPath == "" { + fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check") return 2 } @@ -159,7 +175,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } - if migrationInputCount != 0 { + if flagMigrationPair { migrations, err := authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, @@ -177,6 +193,24 @@ func run(args []string, stdout, stderr io.Writer) int { return 2 } } + if commandMigrationPair { + migrations, err := authorizeSchemaCommandMigrations( + approvedCommandMigrationsPath, + candidateCommandMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + ) + if err != nil { + fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) + return 2 + } + baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err) + return 2 + } + } failures := checkCompatibility(baseline, current) if len(failures) > 0 { fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:") @@ -251,6 +285,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data)) } +func authorizeSchemaCommandMigrations( + approvedManifestPath string, + candidateManifestPath string, + currentSnapshotPath string, + baseSnapshotPath string, + stableSnapshotPath string, +) ([]interfacesnapshot.CommandMigration, error) { + approved, err := readCommandMigrationManifestFile(approvedManifestPath) + if err != nil { + return nil, fmt.Errorf("read approved command migrations: %w", err) + } + candidate, err := readCommandMigrationManifestFile(candidateManifestPath) + if err != nil { + return nil, fmt.Errorf("read candidate command migrations: %w", err) + } + current, err := readInterfaceSnapshotFile(currentSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration current snapshot: %w", err) + } + base, err := readInterfaceSnapshotFile(baseSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration base snapshot: %w", err) + } + stable, err := readInterfaceSnapshotFile(stableSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration stable snapshot: %w", err) + } + return interfacesnapshot.AuthorizeCommandMigrations( + current, + map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable}, + approved, + candidate, + ) +} + +func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data)) +} + func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) { data, err := os.ReadFile(path) if err != nil { @@ -1290,6 +1367,169 @@ func normalizeSchemaFlagMigrations( return normalized, nil } +// normalizeSchemaCommandMigrations projects only the Schema consequences that +// are coupled to an already-authorized CLI command migration. It rewrites a +// cloned historical contract; the ordinary checker still rejects every field +// not proven equivalent here. +func normalizeSchemaCommandMigrations( + baseline schemaContract, + current schemaContract, + migrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + normalized := cloneContract(baseline) + for _, migration := range migrations { + oldProduct, productExists := baseline.Products[migration.Schema.ProductID] + oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !toolExists { + // The historical baseline predates this Schema tool, so it has no + // compatibility surface for this migration. + continue + } + newProduct, productExists := current.Products[migration.Schema.ProductID] + newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !sourceExists { + // Preserve the baseline so the ordinary checker reports the removal. + continue + } + legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ") + replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ") + if oldTool.PrimaryCLIPath == replacementPath { + // A consumed receipt can still be needed for the stable baseline after + // main has already reached the after state. + continue + } + if oldTool.PrimaryCLIPath != legacyPath { + return schemaContract{}, fmt.Errorf( + "approved command migration %s historical Schema tool %q has primary_cli_path %q", + migration.Kind, + migration.Schema.SourceToolID, + oldTool.PrimaryCLIPath, + ) + } + + normalizedProduct := normalized.Products[migration.Schema.ProductID] + normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] + switch migration.Kind { + case interfacesnapshot.CommandMigrationMove: + if newSource.PrimaryCLIPath != replacementPath { + continue + } + renames := make(map[string]string, len(migration.Schema.Parameters)) + for _, parameter := range migration.Schema.Parameters { + oldParameter, existed := oldTool.Parameters[parameter.From] + if !existed { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := newSource.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q still publishes legacy Schema parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + newParameter, exists := newSource.Parameters[parameter.To] + if !exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q does not publish replacement Schema parameter %q", + migration.Replacement.Command, + parameter.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil { + return schemaContract{}, err + } + delete(normalizedTool.Parameters, parameter.From) + normalizedTool.Parameters[parameter.To] = newParameter + renames[parameter.From] = parameter.To + } + if oldTool.Constraints != newSource.Constraints { + oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil) + if oldOK && newOK && oldConstraints == newConstraints { + normalizedTool.Constraints = newSource.Constraints + } + } + normalizedTool.PrimaryCLIPath = replacementPath + + case interfacesnapshot.CommandMigrationFlagExtraction: + if newSource.PrimaryCLIPath != legacyPath { + continue + } + replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID] + if !exists || replacement.PrimaryCLIPath != replacementPath { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q lacks replacement Schema tool %q at %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + replacementPath, + ) + } + if oldTool.InterfaceMode != replacement.InterfaceMode || + oldTool.InterfaceRef != replacement.InterfaceRef || + oldTool.Availability != replacement.Availability || + oldTool.Effect != replacement.Effect || + oldTool.Risk != replacement.Risk || + oldTool.Confirmation != replacement.Confirmation || + oldTool.Idempotency != replacement.Idempotency { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed interface or safety identity", + migration.Legacy.Command, + ) + } + for _, parameter := range migration.Schema.Parameters { + if _, existed := oldTool.Parameters[parameter.From]; !existed { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := newSource.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q still publishes extracted Schema parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + delete(normalizedTool.Parameters, parameter.From) + } + } + normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool + normalized.Products[migration.Schema.ProductID] = normalizedProduct + } + return normalized, nil +} + +func validateEquivalentCommandSchemaParameter( + migration interfacesnapshot.CommandMigration, + parameter interfacesnapshot.CommandParameterMigration, + oldParameter parameterSchema, + newParameter parameterSchema, +) error { + if oldParameter.Type != newParameter.Type || + oldParameter.Property != newParameter.Property || + oldParameter.InterfaceType != newParameter.InterfaceType || + oldParameter.Required != newParameter.Required || + oldParameter.CLIRequired != newParameter.CLIRequired || + oldParameter.RequiredWhen != newParameter.RequiredWhen || + oldParameter.Default != newParameter.Default || + oldParameter.InterfaceDefault != newParameter.InterfaceDefault || + oldParameter.Format != newParameter.Format || + !equalStringSlices(oldParameter.Enum, newParameter.Enum) { + return fmt.Errorf( + "approved command migration %q Schema parameter %q -> %q changed a non-name field", + migration.Legacy.Command, + parameter.From, + parameter.To, + ) + } + return nil +} + func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef { var matches []schemaToolRef for productID, product := range contract.Products { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index f33f09c5..c5eaa912 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1589,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t "--migration-stable-snapshot", "stable.json", } var stdout, stderr bytes.Buffer - if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") { + if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") { t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String()) } From 33b76400bfaf17a27623f115dcdbee32a68ebbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 20:54:46 +0800 Subject: [PATCH 183/267] chore(policy): consume group role flag migration --- internal/cli/param_aliases_generated.go | 2 +- internal/cli/param_concepts.json | 1 + internal/helpers/chat.go | 16 +++++- internal/shortcut/chat/chat_group.go | 55 ++----------------- .../approved-flag-migrations-v1.json | 2 +- 5 files changed, 24 insertions(+), 52 deletions(-) diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 91d14d1d..8827cceb 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -2525,7 +2525,7 @@ var generatedParamAliases = []ParamAliasEntry{ "user-id": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat +chat-role-update", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index ab32e71c..4b9086b4 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -153,6 +153,7 @@ "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 528fbbd9..ea69d203 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -32,7 +32,10 @@ func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { roleIDsChanged := cmd.Flags().Changed("role-ids") switch { case roleIDChanged && roleIDsChanged: - return nil, apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + // PreRunE rejects callers that explicitly pass both flags. Reaching this + // branch means the hidden legacy flag was promoted to satisfy Cobra's + // required marker on the public canonical flag. + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil case roleIDChanged: roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) if len(roleIDs) == 0 { @@ -49,6 +52,13 @@ func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { } } +func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error { + if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") { + return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + } + return promoteLegacyChatString(cmd, "role-id", "role-ids") +} + // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when @@ -7778,6 +7788,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, + PreRunE: func(cmd *cobra.Command, args []string) error { + return prepareChatGroupRoleSetUserRoleID(cmd) + }, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err @@ -7843,6 +7856,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") + _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index f2bb5a4c..a959874e 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -1882,7 +1882,7 @@ var ChatRoleSetUser = shortcut.Shortcut{ Command: "+chat-role-set-user", Product: "im", Description: "设置用户的群身份(覆盖该用户的全部群身份)", - Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和一个 openRoleId。", + Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -1904,28 +1904,22 @@ var ChatRoleSetUser = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "设置用户的群身份(覆盖该用户的全部群身份)", - UseWhen: []string{"当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和一个 openRoleId。"}, + UseWhen: []string{"当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws chat +chat-role-set-user --group --user --role-id "}, + Examples: []string{"dws chat +chat-role-set-user --group --user --role-ids roleId1,roleId2"}, }, }, Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, - {Name: "role-id", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId;公开入口必须提供一个非空群身份 openRoleId", Aliases: []string{"role-ids"}}, - }, - Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintCustom, Flags: []string{"role-id"}, Description: "公开入口必须提供一个非空群身份 openRoleId"}, - }, - Tips: []string{`dws chat +chat-role-set-user --group --user --role-id `}, - Validate: func(rt *shortcut.RuntimeContext) error { - return validateChatRoleSetUserRoleFlags(rt) + {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId 列表(空则清除全部)", Required: true}, }, + Tips: []string{`dws chat +chat-role-set-user --group --user --role-ids roleId1,roleId2`}, Execute: func(rt *shortcut.RuntimeContext) error { user := rt.Str("user") params := map[string]any{ "openConversationId": rt.Str("group"), - "openRoleIds": chatRoleSetUserRoleIDs(rt), + "openRoleIds": rt.StrSlice("role-ids"), } if isOpenID(user) { params["openDingTalkId"] = user @@ -1936,43 +1930,6 @@ var ChatRoleSetUser = shortcut.Shortcut{ }, } -func validateChatRoleSetUserRoleFlags(rt *shortcut.RuntimeContext) error { - roleIDChanged := rt.Changed("role-id") - roleIDsChanged := rt.Changed("role-ids") - switch { - case roleIDChanged && roleIDsChanged: - return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") - case roleIDChanged: - roleIDs := compactChatRoleSetUserRoleIDs(rt.StrSlice("role-id")) - if len(roleIDs) == 0 { - return apperrors.NewValidation("--role-id 不能为空") - } - if len(roleIDs) > 1 { - return apperrors.NewValidation("--role-id 只允许指定一个群身份") - } - case !roleIDChanged && !roleIDsChanged: - return apperrors.NewValidation("缺少必填参数 --role-id") - } - return nil -} - -func chatRoleSetUserRoleIDs(rt *shortcut.RuntimeContext) []string { - if rt.Changed("role-id") { - return compactChatRoleSetUserRoleIDs(rt.StrSlice("role-id")) - } - return rt.StrSlice("role-ids") -} - -func compactChatRoleSetUserRoleIDs(values []string) []string { - out := values[:0] - for _, value := range values { - if value = strings.TrimSpace(value); value != "" { - out = append(out, value) - } - } - return out -} - // ChatRoleRemoveUser removes specific roles from a user (remove_custom_user_roles, im). var ChatRoleRemoveUser = shortcut.Shortcut{ Service: "chat", diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index 700f46d0..c1c596fa 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -790,7 +790,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" }, { From 3fa85d19c9c0bc1417220b7f58761263cb0bfb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 21:22:55 +0800 Subject: [PATCH 184/267] docs: add group role flag release fragment --- .changes/1058-chat-group-role-single-flag.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/1058-chat-group-role-single-flag.md diff --git a/.changes/1058-chat-group-role-single-flag.md b/.changes/1058-chat-group-role-single-flag.md new file mode 100644 index 00000000..b84f9ebc --- /dev/null +++ b/.changes/1058-chat-group-role-single-flag.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. From c2ff4ab2424ff5e2c33fba889a6ca17e0506d119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 21:26:52 +0800 Subject: [PATCH 185/267] test(chat): cover missing group role flag --- internal/helpers/chat_command_edges_test.go | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 405ac9ef..c71ea660 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -323,6 +323,44 @@ func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) { } } +func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1"}, + want: "role-id", + }, + { + name: "empty public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="}, + want: "--role-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, tc.args...) + if err == nil { + t.Fatal("set-user accepted a missing or empty --role-id, want validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } + }) + } +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} From c0e579fe6b586223ba599572a24fe320fc19e3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 19 Aug 2026 21:29:47 +0800 Subject: [PATCH 186/267] fix(skills): prove backup ownership before adopting or pruning stamp roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stamp-shaped directory name is not ownership proof: pruneSkillBackups counted and RemoveAll'd any 20260819-120000-shaped entry under ~/.dws/skill-backups, so a user or tool that created such a directory lost its contents once DWS held five backups, and the Go backup path (MkdirAll) adopted a same-named foreign root outright. The PowerShell installers already implemented the correct contract; every other surface now matches it. Go stamps a freshly created root with the exact marker bytes the install scripts write (.dws-skill-backup = "dws skill backup v1") before any payload moves in, claims the root with mkdir so an existing unproven root bumps to a collision suffix instead of being adopted, and prunes only roots whose marker verifies — unmarked or wrongly worded stamp-shaped directories are foreign data, preserved and never counted against the keep limit. The shell installers (install.sh, install-skills.sh, install-event.sh, install-devapp.sh) and the npm installer apply the same rule in their backup collision loops, with roots recorded as created by the running process exempt from marker re-verification so a mid-run marker permission failure still reuses this run's own root and keeps the sibling payload intact. Regression tests cover every surface: pruning an unmarked/wrongly marked stamp-shaped directory alongside marked ones, refusing to adopt a foreign root (payload moves to a suffixed root, foreign data and its nonexistent marker untouched), same-stamp reuse of a proven root, and marker-write failure cleaning the empty fresh root. --- build/npm/install.js | 20 +++- internal/upgrade/paths.go | 89 +++++++++++--- internal/upgrade/paths_multi_test.go | 63 ++++++++-- .../upgrade/skill_backup_ownership_test.go | 113 ++++++++++++++++++ scripts/install-devapp.sh | 19 ++- scripts/install-event.sh | 19 ++- scripts/install-skills.sh | 25 +++- scripts/install.sh | 25 +++- test/scripts/install_js_smoke.mjs | 30 +++-- test/scripts/skill_backup_prune_test.go | 80 +++++++++++++ 10 files changed, 439 insertions(+), 44 deletions(-) create mode 100644 internal/upgrade/skill_backup_ownership_test.go diff --git a/build/npm/install.js b/build/npm/install.js index 345f54c2..58e2a7f1 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -178,6 +178,20 @@ function writeSkillBackupMarker(root) { fs.writeFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); } +// skillBackupRootUsable reports whether a stamp root may be written into: +// either it does not exist yet (this transaction creates it fresh) or its +// ownership marker already verifies. A stamp-shaped directory created by +// anyone else must never be adopted — writing our marker into it would turn +// its foreign contents into prunable DWS backups. +function skillBackupRootUsable(root) { + if (!pathExistsLexicallySync(root)) return true; + // A root this very process created is ours by construction and stays + // reusable even when its marker cannot be re-verified mid-run (for + // example a permission failure after the first payload moved in). + if (currentRunBackupRoots.has(root)) return true; + return isProvenSkillBackupRoot(root); +} + // isProvenSkillBackupRoot reports whether a stamp root carries the ownership // marker with the exact expected bytes; anything else is foreign data. function isProvenSkillBackupRoot(root) { @@ -741,7 +755,11 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; pathExistsLexicallySync(target); i++) { + // Bump not only when the payload path is taken but also when the stamp + // root exists without a verified ownership marker: a same-second foreign + // directory must never be stamped DWS-owned and made prunable. A + // marker-verified root from this run's same second stays reusable. + for (let i = 1; pathExistsLexicallySync(target) || !skillBackupRootUsable(targetRoot); i++) { if (i > 1000) { throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f38d8f18..3261cde1 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -192,23 +192,43 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { } name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel) stamp := upgradeBackupStamp() - backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) - target := filepath.Join(backupRoot, name) - for i := 1; ; i++ { - if _, err := upgradeLstat(target); err != nil { - if os.IsNotExist(err) { - break - } + if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil { + return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err) + } + var backupRoot, target string + for i := 0; ; i++ { + candidate := stamp + if i > 0 { + candidate = fmt.Sprintf("%s-%d", stamp, i) + } + if i > 1000 { + return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate)) + } + backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate) + target = filepath.Join(backupRoot, name) + if _, err := upgradeLstat(target); err == nil { + continue // payload name already taken in this stamp root + } else if !os.IsNotExist(err) { return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) } - backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) - target = filepath.Join(backupRoot, name) - if i > 1000 { - return "", fmt.Errorf("备份目录冲突无法解决: %s", target) + if err := skillPathMkdir(backupRoot, dirPermShared); err == nil { + // We created the root: stamp ownership before any payload moves + // in, so an interrupted backup never leaves an unmarked + // (never-prunable) stamp behind. + if err := skillBackupWriteMarker(backupRoot); err != nil { + // Bounded cleanup: the root is empty and ours alone. + _ = skillPathRemove(backupRoot) + return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err) + } + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) + } else if skillBackupMarkerValid(backupRoot) { + break // proven ours: same-stamp payloads from this run reuse it } - } - if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { - return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) + // A stamp-shaped directory we cannot prove ours must never be + // adopted — writing the marker or the payload into it would turn + // its foreign contents into prunable DWS backups. } if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) @@ -384,6 +404,36 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// The ownership marker inside a stamp root. Every DWS surface writes the +// exact same bytes (install.sh, install-skills.sh, install-event.sh, +// install-devapp.sh, the PowerShell installers, and build/npm/install.js): +// a stamp-shaped directory name alone is not ownership proof, because users +// and unrelated tools can create 20260819-120000-shaped names. Counting or +// deleting a backup root requires the marker to verify, and adopting an +// existing root for a new backup requires it first. +const ( + skillBackupMarkerName = ".dws-skill-backup" + skillBackupMarkerContent = "dws skill backup v1\n" +) + +var ( + skillBackupReadMarker = func(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName)) + return string(data), err + } + skillBackupWriteMarker = func(root string) error { + return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644) + } +) + +// skillBackupMarkerValid reports whether root carries the ownership marker +// with exactly the expected bytes; a missing, unreadable, or differently +// worded marker means the directory is foreign data. +func skillBackupMarkerValid(root string) bool { + body, err := skillBackupReadMarker(root) + return err == nil && body == skillBackupMarkerContent +} + // skillBackupStampRegexp matches only directory names DWS itself writes: // UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry // in the backup root is foreign (user data, unrelated tooling) and must be @@ -397,7 +447,9 @@ func isSkillBackupStamp(name string) bool { // pruneSkillBackups removes the oldest backup directories when more than // skillBackupKeep remain. Only directories whose names match the DWS backup -// stamp format are candidates; unknown directories are preserved. Best-effort: +// stamp format AND whose ownership marker verifies are candidates; unknown +// or unmarked directories — including stamp roots created before the +// ownership marker existed — are foreign data and preserved. Best-effort: // a removal failure never aborts, but pruning failures are reported so // callers can warn the user. func pruneSkillBackups(homeDir string) error { @@ -411,7 +463,12 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() && isSkillBackupStamp(e.Name()) { + if !e.IsDir() || !isSkillBackupStamp(e.Name()) { + continue + } + // A stamp-shaped name alone is not ownership proof: only roots + // whose marker verifies are counted or deleted. + if skillBackupMarkerValid(filepath.Join(root, e.Name())) { names = append(names, e.Name()) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 82322520..a6f2f7a9 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -655,6 +655,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } } +// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every +// install surface does before any payload moves in. +func seedSkillBackupMarker(t *testing.T, root string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil { + t.Fatal(err) + } +} + // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe // contract of the backup helper: all lexical paths are preserved, a colliding backup // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable @@ -801,7 +810,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatal(err) } testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) - if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") { + if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") { t.Fatalf("mkdir error = %v", err) } if _, err := os.Stat(victim3); err != nil { @@ -831,9 +840,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } if err := pruneSkillBackups(home); err != nil { t.Fatalf("pruneSkillBackups() error = %v", err) @@ -866,9 +877,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { // Removal failure is reported as the first error. Seed more than // skillBackupKeep dirs again so the prune loop actually runs. for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") }) if err := pruneSkillBackups(home); err == nil { @@ -885,12 +898,32 @@ func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } // A collision-suffixed stamp is still a valid DWS backup and is eligible. - if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil { + suffixed := filepath.Join(root, "20260910-000000-7") + if err := os.MkdirAll(suffixed, 0o755); err != nil { + t.Fatal(err) + } + seedSkillBackupMarker(t, suffixed) + // Stamp-shaped names without a verifying marker are foreign data too: + // the name format alone proves nothing. + unmarked := filepath.Join(root, "20260901-000000") + if err := os.MkdirAll(unmarked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + wrongMarker := filepath.Join(root, "20260902-000000") + if err := os.MkdirAll(wrongMarker, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil { t.Fatal(err) } unknown := []string{ @@ -898,6 +931,8 @@ func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { "20260910-000000-abc", // stamp-shaped but non-numeric suffix "20260910-00000", // too short "notes", + "20260901-000000", // stamp-shaped but unmarked + "20260902-000000", // stamp-shaped but wrong marker content } for _, name := range unknown { if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { @@ -914,20 +949,24 @@ func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) } } - // Only stamped backups beyond skillBackupKeep are pruned; foreign entries - // are excluded from the count, so the stamp total stays at skillBackupKeep. + // Only marker-proven backups beyond skillBackupKeep are pruned; foreign + // entries (including unmarked stamp-shaped ones) are excluded from the + // count, so the proven total stays at skillBackupKeep. entries, err := os.ReadDir(root) if err != nil { t.Fatal(err) } - stamps := 0 + proven := 0 for _, e := range entries { - if isSkillBackupStamp(e.Name()) { - stamps++ + if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) { + proven++ } } - if stamps != skillBackupKeep { - t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep) + if proven != skillBackupKeep { + t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep) + } + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" { + t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err) } } diff --git a/internal/upgrade/skill_backup_ownership_test.go b/internal/upgrade/skill_backup_ownership_test.go new file mode 100644 index 00000000..a9098806 --- /dev/null +++ b/internal/upgrade/skill_backup_ownership_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership +// contract: a stamp-shaped directory name alone proves nothing, so a backup +// root this transaction writes into is either created fresh (mkdir claim, +// marker stamped before any payload moves in) or an existing root whose +// marker already verifies. A same-stamp foreign directory is never adopted — +// writing the marker or the payload into it would turn its contents into +// prunable DWS backups. +func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) { + + t.Run("unproven stamp root is never adopted", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000") + if err := os.MkdirAll(foreign, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + backup, err := backupAndRemoveSkillDir(home, victim) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want { + t.Fatalf("backup = %s, want suffixed %s", backup, want) + } + assertUpgradeSkillContent(t, backup, "payload") + // The foreign root keeps its data and never receives our marker. + if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" { + t.Fatalf("foreign data = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) { + t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr) + } + if !skillBackupMarkerValid(filepath.Dir(backup)) { + t.Fatal("the freshly claimed root must carry the ownership marker") + } + }) + + t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + first := filepath.Join(home, "skills", "dws") + second := filepath.Join(home, "agents", "claude", "dws") + seedUpgradeSkill(t, first, "first", false) + seedUpgradeSkill(t, second, "second", false) + backupA, err := backupAndRemoveSkillDir(home, first) + if err != nil { + t.Fatal(err) + } + backupB, err := backupAndRemoveSkillDir(home, second) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(backupA) != filepath.Dir(backupB) { + t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB) + } + root := filepath.Dir(backupA) + if !skillBackupMarkerValid(root) { + t.Fatal("reused root must keep a verifying marker") + } + assertUpgradeSkillContent(t, backupA, "first") + assertUpgradeSkillContent(t, backupB, "second") + }) + + t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") }) + _, err := backupAndRemoveSkillDir(home, victim) + if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") { + t.Fatalf("marker write failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir)) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries) + } + }) + + t.Run("backup root creation failure surfaces", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := backupAndRemoveSkillDir(home, victim) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("root creation failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + }) +} diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 480b554b..b7afc315 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -258,7 +258,13 @@ backup_skill_dir() { stamp="$(date -u +%Y%m%d-%H%M%S)" name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 - while [ -e "$backup" ] || [ -L "$backup" ]; do + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" # Same bail-out as install-skills.sh: never spin forever on a pathological # backup root, report it and keep the original directory instead. @@ -317,6 +323,17 @@ record_current_run_backup_stamp() { CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" } +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + # is_skill_backup_stamp accepts only directory names with the stamp shape DWS # itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. # Shape is necessary but not sufficient — pruning additionally verifies the diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 8ea20dcb..aa3716cf 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -325,7 +325,13 @@ backup_skill_dir() { backup_root="$HOME/.dws/skill-backups/$stamp" backup="$backup_root/$name" i=0 - while [ -e "$backup" ] || [ -L "$backup" ]; do + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" # Same bail-out as install-skills.sh: never spin forever on a pathological # backup root, report it and keep the original directory instead. @@ -384,6 +390,17 @@ record_current_run_backup_stamp() { CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" } +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + # is_skill_backup_stamp accepts only directory names with the stamp shape DWS # itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. # Shape is necessary but not sufficient — pruning additionally verifies the diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 8a95c930..77ad6145 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -57,10 +57,18 @@ backup_and_remove_skill_dir() { _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir" @@ -126,6 +134,17 @@ record_current_run_backup_stamp() { DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" } +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + # is_skill_backup_stamp accepts only directory names with the stamp shape DWS # itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. # Shape is necessary but not sufficient — pruning additionally verifies the diff --git a/scripts/install.sh b/scripts/install.sh index 542a1530..9505ff13 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -75,10 +75,18 @@ backup_and_remove_skill_dir() { _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir" @@ -144,6 +152,17 @@ record_current_run_backup_stamp() { DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" } +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + # is_skill_backup_stamp accepts only directory names with the stamp shape DWS # itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. # Shape is necessary but not sufficient — pruning additionally verifies the diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 6d0db715..ace89d5c 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -1606,18 +1606,34 @@ scenario("a failed marker write never leaves an empty unowned stamp root", () => "an empty unowned stamp root is cleaned up", ); - // Foreign non-empty root: the marker path is a directory (the write fails - // with EISDIR), and rmdir must fail on the non-empty root, keeping it. + // Foreign non-empty root with the marker path occupied by a directory: + // unusable, so the backup moves to a suffixed root and the foreign root + // is never touched. const foreignStamp = "20260801-000001"; const foreignRoot = path.join(home, ".dws", "skill-backups", foreignStamp); writeFile(path.join(foreignRoot, "user-data.txt"), "foreign\n"); fs.mkdirSync(path.join(foreignRoot, SKILL_BACKUP_MARKER_FILE)); - assert.throws( - () => backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => foreignStamp }), - /failed to back up Skill directory/, + const foreignBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => foreignStamp }); + assert.ok(foreignBackup.includes(`${foreignStamp}-1`), `the backup moves to a suffixed root: ${foreignBackup}`); + assert.equal(fs.readFileSync(path.join(foreignRoot, "user-data.txt"), "utf8"), "foreign\n", "a non-empty foreign stamp root survives untouched"); + + // Plain foreign root (ordinary files, no marker): must never be adopted — + // the backup lands in the suffixed root instead, and the foreign root + // never receives our marker. + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + const plainStamp = "20260801-000002"; + const plainRoot = path.join(home, ".dws", "skill-backups", plainStamp); + writeFile(path.join(plainRoot, "user-data.txt"), "foreign\n"); + const plainBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => plainStamp }); + assert.ok(plainBackup.includes("20260801-000002-1"), `the backup moves to a suffixed root: ${plainBackup}`); + assert.equal(fs.readFileSync(path.join(plainRoot, "user-data.txt"), "utf8"), "foreign\n", "plain foreign data survives"); + assert.equal(fs.existsSync(path.join(plainRoot, SKILL_BACKUP_MARKER_FILE)), false, "the foreign root never receives our marker"); + assert.equal(fs.readFileSync(path.join(plainBackup, "SKILL.md"), "utf8"), "old codex copy\n", "the payload is intact in the suffixed root"); + assert.equal( + fs.readFileSync(path.join(path.dirname(plainBackup), SKILL_BACKUP_MARKER_FILE), "utf8"), + "dws skill backup v1\n", + "the suffixed root carries the ownership marker", ); - assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old codex copy\n", "the source stays in place"); - assert.equal(fs.readFileSync(path.join(foreignRoot, "user-data.txt"), "utf8"), "foreign\n", "a non-empty foreign stamp root survives"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go index 2e519490..75953d3d 100644 --- a/test/scripts/skill_backup_prune_test.go +++ b/test/scripts/skill_backup_prune_test.go @@ -238,3 +238,83 @@ func twoDigits(i int) string { } return string(rune('0'+i/10)) + string(rune('0'+i%10)) } + +// TestInstallShellBackupNeverAdoptsUnprovenStampRoots pins the ownership +// contract at creation time: a stamp-shaped directory whose marker does not +// verify must never receive the DWS marker or a payload — the backup moves +// to a fresh suffixed root instead, and the foreign data survives untouched. +func TestInstallShellBackupNeverAdoptsUnprovenStampRoots(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + // install.sh and install-skills.sh name the payload by basename; + // the standalone event/devapp publishers mangle the full path. + payloadName := "victim" + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + // Mirror the scripts' own mangling: full path, slashes and + // backslashes to dashes, leading dash dropped. + payloadName = strings.NewReplacer("/", "-", "\\", "-").Replace(filepath.Join(home, "victim")) + payloadName = strings.TrimPrefix(payloadName, "-") + } + harness := `. "$DWS_TEST_LIBRARY" +date() { printf '%s\n' 20260101-120000; } +ROOT="$HOME/.dws/skill-backups" +mkdir -p "$ROOT/20260101-120000" || exit 9 +printf 'must-survive\n' > "$ROOT/20260101-120000/user-data.txt" || exit 9 +mkdir -p "$HOME/victim" || exit 9 +printf 'payload\n' > "$HOME/victim/SKILL.md" || exit 9 +if ! backup_fn "$HOME/victim" >/dev/null 2>&1; then + if [ -e "$HOME/victim/SKILL.md" ]; then + exit 0 # a refused backup that keeps the source is contract-compliant + fi + exit 9 +fi +[ -f "$ROOT/20260101-120000-1/` + "`printf '%s' \"" + payloadName + "\"`" + `/SKILL.md" ] || exit 3 +[ ! -e "$HOME/victim" ] || exit 4 +[ -f "$ROOT/20260101-120000/user-data.txt" ] || exit 5 +[ "$(cat "$ROOT/20260101-120000/user-data.txt")" = "must-survive" ] || exit 6 +[ ! -e "$ROOT/20260101-120000/.dws-skill-backup" ] || exit 7 +[ "$(cat "$ROOT/20260101-120000-1/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || exit 8 +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "backup-adoption.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", "-c", "backup_fn() { "+backupFnName(scriptName)+" \"$1\"; }\n. \"$0\"", harnessPath) + cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s must not adopt an unproven stamp root: %v\n%s", scriptName, err, string(output)) + } + if backup := findSkillBackupContent(home, "payload\n"); !strings.Contains(backup, "20260101-120000-1") { + t.Fatalf("%s payload must land in the suffixed root, got %s", scriptName, backup) + } + }) + } +} + +func backupFnName(scriptName string) string { + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + return "backup_skill_dir" + } + return "backup_and_remove_skill_dir" +} From 5b412ac196c106bbe0b3ca8fb99578963816feb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Wed, 19 Aug 2026 21:48:28 +0800 Subject: [PATCH 187/267] test(chat): cover role flag resolver branches --- internal/helpers/chat_command_edges_test.go | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index c71ea660..b2f70a1b 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -361,6 +361,37 @@ func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) { } } +func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) { + newCommand := func(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().String("role-id", "", "") + cmd.Flags().String("role-ids", "", "") + return cmd + } + + t.Run("legacy flag without pre-run promotion", func(t *testing.T) { + cmd := newCommand(t) + if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil { + t.Fatal(err) + } + got, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + t.Fatal(err) + } + if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("role IDs = %#v, want %#v", got, want) + } + }) + + t.Run("no role flag", func(t *testing.T) { + _, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t)) + if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") { + t.Fatalf("error = %v, want missing --role-id validation", err) + } + }) +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} From 97ca00868ff6642919ce5004447b3495ebe16335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 19 Aug 2026 21:51:49 +0800 Subject: [PATCH 188/267] test(chat): cover personal emotion user resolution --- .../helpers/chat_personal_emotion_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/helpers/chat_personal_emotion_test.go b/internal/helpers/chat_personal_emotion_test.go index e5b212e1..860cbc66 100644 --- a/internal/helpers/chat_personal_emotion_test.go +++ b/internal/helpers/chat_personal_emotion_test.go @@ -26,6 +26,9 @@ func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, copied[key] = value } c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied}) + if server == "contact" && tool == "get_user_info_by_user_ids" { + return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil + } return textToolResult(`{"ok":true}`), nil } @@ -126,6 +129,37 @@ func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *test }) } +func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) { + // TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。 + caller := &personalEmotionCaller{} + err := executePersonalEmotionCommand(t, caller, + "emotion", "send", + "--media-id", "@media", + "--user", "u1", + ) + if err != nil { + t.Fatalf("chat emotion send returned error: %v", err) + } + if len(caller.calls) != 2 { + t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls) + } + resolveCall := caller.calls[0] + if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" { + t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool) + } + sendCall := caller.calls[1] + if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" { + t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool) + } + want := map[string]any{ + "mediaId": "@media", + "receiverOpenDingTalkId": helperCurrentDOpenID2, + } + if !reflect.DeepEqual(sendCall.args, want) { + t.Fatalf("send args = %#v, want %#v", sendCall.args, want) + } +} + func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) { tests := []struct { name string From d8a3d5d6fd0fa0f6e8fd50bbd6eec60dadb87d3b Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 19 Aug 2026 22:57:11 +0800 Subject: [PATCH 189/267] fix(ci): close command migration governance gaps --- .changes/command-path-migration-governance.md | 2 +- docs/cli-interface-flag-migrations.md | 8 +- .../interfacesnapshot/command_migrations.go | 45 ++++++++ .../command_migrations_test.go | 104 ++++++++++++++++++ .../schema-compat/command_migrations_test.go | 55 +++++++++ scripts/policy/schema-compat/main.go | 24 ++++ 6 files changed, 236 insertions(+), 2 deletions(-) diff --git a/.changes/command-path-migration-governance.md b/.changes/command-path-migration-governance.md index b78a839d..fac7e8aa 100644 --- a/.changes/command-path-migration-governance.md +++ b/.changes/command-path-migration-governance.md @@ -2,4 +2,4 @@ category: Changed --- -- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or flags from Help and Schema when capability moves to a dedicated command. +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional flags from Help and Schema when capability moves to a dedicated command, with legacy-leaf and fail-closed required-parameter checks. diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index ef2fa9e8..e7e71b3e 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -55,7 +55,13 @@ scripts/policy/interface-migrations/approved-command-migrations-v1.json | `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | | `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity | -两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 +`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement +不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 +稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 +`required`、`cli_required` 或 `required_when` 参数。`flag_extraction` 只接受 +optional legacy flag,不能隐藏仍由 Cobra hard-required 的参数。 + +两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go index 785c02b1..e9cdac4e 100644 --- a/internal/interfacesnapshot/command_migrations.go +++ b/internal/interfacesnapshot/command_migrations.go @@ -156,6 +156,10 @@ func (m CommandMigration) validate() error { } switch m.Kind { case CommandMigrationMove: + if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") || + strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") { + return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship") + } if !m.Legacy.After.Hidden { return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden") } @@ -208,6 +212,9 @@ func (f CommandMigrationFlag) validate() error { if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden { return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present") } + if f.Before.Required || f.After.Required { + return fmt.Errorf("legacy_flag must remain optional before and after extraction") + } before := f.Before after := f.After before.Hidden = false @@ -297,9 +304,47 @@ func AuthorizeCommandMigrations( if err := candidate.Validate(); err != nil { return nil, fmt.Errorf("validate candidate command migrations: %w", err) } + type commandMoveTopologyCheck struct { + label string + snapshot Snapshot + manifest CommandMigrationManifest + } + topologyChecks := []commandMoveTopologyCheck{ + {label: "approved", snapshot: current, manifest: authority}, + {label: "candidate", snapshot: current, manifest: candidate}, + } + if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok { + topologyChecks = append(topologyChecks, + commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority}, + commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate}, + ) + } + for _, check := range topologyChecks { + if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil { + return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err) + } + } return evaluateCommandMigrationLifecycle(current, references, authority, candidate) } +func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { + commands := commandIndex(snapshot) + for _, migration := range manifest.Migrations { + if migration.Kind != CommandMigrationMove { + continue + } + if _, present := commands[migration.Legacy.Command]; !present { + continue + } + for _, command := range snapshot.Commands { + if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { + return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + } + } + } + return nil +} + func evaluateCommandMigrationLifecycle( current Snapshot, references map[string]Snapshot, diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index 7b511f29..8172ebcc 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -170,6 +170,110 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { } } +func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] + migration.LegacyFlag.Before.Required = true + migration.LegacyFlag.After.Required = true + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { + t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = "dws agoal" + migration.Replacement.Command = "dws goal" + migration.Schema = CommandMigrationSchema{ + ProductID: "agoal", + SourceToolID: "agoal.root", + ReplacementToolID: "agoal.root", + Parameters: []CommandParameterMigration{}, + } + authority := CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{migration}, + } + consumed := authority + consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...) + consumed.Migrations[0].State = CommandMigrationConsumed + + before := testSnapshot( + testCommand("dws"), + testCommand("dws agoal"), + testCommand("dws agoal strategy"), + testCommand("dws agoal strategy list"), + ) + after := testSnapshot( + testCommand("dws"), + Command{Path: "dws agoal", Runnable: true, Hidden: true}, + testCommand("dws goal"), + ) + + _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + authority, + consumed, + ) + if err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err) + } +} + +func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + after.Commands = append(after.Commands, testCommand("dws chat topic new detail")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil { + t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err) + } + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("replacement parent command_move was rejected: %v", err) + } + if len(got) != 1 { + t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got)) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) { + for _, test := range []struct { + name string + legacy string + replacement string + }{ + { + name: "legacy ancestor", + legacy: "dws chat message", + replacement: "dws chat message list", + }, + { + name: "replacement ancestor", + legacy: "dws chat message list", + replacement: "dws chat message", + }, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = test.legacy + migration.Replacement.Command = test.replacement + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") { + t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err) + } + }) + } +} + func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { before := commandMigrationSnapshot(false, false) after := commandMigrationSnapshot(true, false) diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index af1636bc..5b3212f8 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -75,6 +75,48 @@ func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testin } } +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + for _, test := range []struct { + name string + parameter parameterSchema + }{ + {name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}}, + {name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}}, + {name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}}, + } { + t.Run(test.name, func(t *testing.T) { + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["must"] = test.parameter + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required parameter error=%v", err) + } + }) + } + + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"} + product.Tools["chat.move"] = tool + current.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("optional addition should remain compatible: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("optional addition should remain compatible: %v", failures) + } +} + func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { baseline := schemaCommandMigrationContract(false) current := schemaCommandMigrationContract(true) @@ -273,6 +315,19 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") { t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String()) } + + requiredAddition := schemaCommandMigrationContract(true) + product = requiredAddition.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true} + product.Tools["chat.move"] = tool + requiredAddition.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, requiredAddition) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String()) + } } func schemaCommandMigrationContract(after bool) schemaContract { diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 2d19c0e2..44319c65 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -1415,6 +1415,7 @@ func normalizeSchemaCommandMigrations( continue } renames := make(map[string]string, len(migration.Schema.Parameters)) + renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters)) for _, parameter := range migration.Schema.Parameters { oldParameter, existed := oldTool.Parameters[parameter.From] if !existed { @@ -1445,6 +1446,29 @@ func normalizeSchemaCommandMigrations( delete(normalizedTool.Parameters, parameter.From) normalizedTool.Parameters[parameter.To] = newParameter renames[parameter.From] = parameter.To + renameTargets[parameter.To] = struct{}{} + } + parameterNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + parameterNames = append(parameterNames, name) + } + sort.Strings(parameterNames) + for _, name := range parameterNames { + parameter := newSource.Parameters[name] + if _, existed := oldTool.Parameters[name]; existed { + continue + } + if _, approvedRename := renameTargets[name]; approvedRename { + continue + } + if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" { + return schemaContract{}, fmt.Errorf( + "approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q", + migration.Legacy.Command, + migration.Schema.SourceToolID, + name, + ) + } } if oldTool.Constraints != newSource.Constraints { oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) From 95da8214a19eadf77b9ee8f5a75321446331858c Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 19 Aug 2026 23:24:29 +0800 Subject: [PATCH 190/267] test(ci): reject command parameter target collisions --- .changes/command-path-migration-governance.md | 2 +- docs/cli-interface-flag-migrations.md | 3 ++- .../command_migrations_test.go | 19 ++++++++++---- .../schema-compat/command_migrations_test.go | 26 +++++++++++++++++++ scripts/policy/schema-compat/main.go | 8 ++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.changes/command-path-migration-governance.md b/.changes/command-path-migration-governance.md index fac7e8aa..f7dcd3c7 100644 --- a/.changes/command-path-migration-governance.md +++ b/.changes/command-path-migration-governance.md @@ -2,4 +2,4 @@ category: Changed --- -- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional flags from Help and Schema when capability moves to a dedicated command, with legacy-leaf and fail-closed required-parameter checks. +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional flags from Help and Schema when capability moves to a dedicated command, with legacy-leaf, parameter-collision, and fail-closed required-parameter checks. diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index e7e71b3e..341ea7c4 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -58,7 +58,8 @@ scripts/policy/interface-migrations/approved-command-migrations-v1.json `command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement 不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 -`required`、`cli_required` 或 `required_when` 参数。`flag_extraction` 只接受 +`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史 +Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受 optional legacy flag,不能隐藏仍由 Cobra hard-required 的参数。 两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index 8172ebcc..2ed88199 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -171,12 +171,21 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { } func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { - migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] - migration.LegacyFlag.Before.Required = true - migration.LegacyFlag.After.Required = true + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + }{ + {"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }}, + {"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }}, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] + test.mutate(&migration.LegacyFlag) - if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { - t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { + t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + } + }) } } diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 5b3212f8..5d4298ff 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -117,6 +117,32 @@ func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredP } } +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}} + + product := current.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err == nil { + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures) + } + t.Fatal("parameter target collision was accepted") + } + if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) { + t.Fatalf("parameter target collision error=%v", err) + } +} + func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { baseline := schemaCommandMigrationContract(false) current := schemaCommandMigrationContract(true) diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 44319c65..391406e9 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -1425,6 +1425,14 @@ func normalizeSchemaCommandMigrations( parameter.From, ) } + if _, exists := oldTool.Parameters[parameter.To]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q Schema parameter target %q already exists in historical Schema tool %q", + migration.Legacy.Command, + parameter.To, + migration.Schema.SourceToolID, + ) + } if _, exists := newSource.Parameters[parameter.From]; exists { return schemaContract{}, fmt.Errorf( "approved command migration %q still publishes legacy Schema parameter %q", From dfc3b028d74f8314c495595fcbca6bb763ae4d1d Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 00:53:23 +0800 Subject: [PATCH 191/267] fix(ci): prove extracted command constants end to end --- .changes/command-path-migration-governance.md | 2 +- docs/cli-interface-flag-migrations.md | 28 +- internal/corecmd/corecmd.go | 39 ++- internal/corecmd/corecmd_test.go | 100 +++++- internal/corecmd/interface_const_params.go | 63 ++++ .../corecmd/interface_const_params_test.go | 39 +++ .../interfacesnapshot/command_migrations.go | 99 +++++- .../command_migrations_test.go | 204 ++++++++++- internal/interfacesnapshot/compare.go | 11 + internal/interfacesnapshot/compare_test.go | 57 +++ internal/interfacesnapshot/migrations.go | 6 + internal/interfacesnapshot/snapshot.go | 37 +- internal/interfacesnapshot/snapshot_test.go | 98 +++++- ...heck-authoritative-schema-compatibility.sh | 86 ++++- scripts/policy/check-command-compatibility.sh | 117 ++++++- .../authority_integration_test.go | 214 +++++++++++- .../approved-command-migrations-v1.json | 13 +- .../schema-compat/command_migrations_test.go | 326 +++++++++++++++++- scripts/policy/schema-compat/main.go | 303 +++++++++++++++- .../authority_integration_test.go | 218 +++++++++++- 20 files changed, 1963 insertions(+), 97 deletions(-) create mode 100644 internal/corecmd/interface_const_params.go create mode 100644 internal/corecmd/interface_const_params_test.go diff --git a/.changes/command-path-migration-governance.md b/.changes/command-path-migration-governance.md index f7dcd3c7..61f89635 100644 --- a/.changes/command-path-migration-governance.md +++ b/.changes/command-path-migration-governance.md @@ -2,4 +2,4 @@ category: Changed --- -- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional flags from Help and Schema when capability moves to a dedicated command, with legacy-leaf, parameter-collision, and fail-closed required-parameter checks. +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 341ea7c4..60c5b684 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -53,20 +53,42 @@ scripts/policy/interface-migrations/approved-command-migrations-v1.json | kind | CLI after 状态 | Schema 允许的精确投影 | |---|---|---| | `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | -| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity | +| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 | `command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement 不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 `required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史 Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受 -optional legacy flag,不能隐藏仍由 Cobra hard-required 的参数。 +optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool +的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好 +一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有 +`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于 +常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此 +`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即 +`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。 + +`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot +必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、 +值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create` +必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入 +`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份 +同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加 +bool 常量证据属于 bootstrap;一旦任一历史快照已记录该 +证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后 +删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则 +`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与 +`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用 +独立 PR 合入,不能和产品消费混在一起。 + +replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值; +历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。 两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 -当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping 或 reason。 +当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。 ## 两阶段迁移与回执清理 diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 04741dce..b94cc080 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -230,9 +230,10 @@ const ( // - Validate / PostMount — orchestration only; must not register business flags // or assemble business params that belong in Flags/ConstParams. // -// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at -// construction time. corecmd stays dispatch-agnostic and never calls a backend: -// the adapters (FromLeafSpec / FromShortcut) supply the body. +// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New +// validates this at construction time. corecmd stays dispatch-agnostic and +// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the +// body. type Spec struct { Use string Short string @@ -259,7 +260,8 @@ type Spec struct { // backend call). ConfirmFirst bool // ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly). - // They are payload declaration, not user flags, and never satisfy Required. + // They are payload declaration, not user flags, never satisfy Required, and + // require an Invoke or ResultInvoke dispatcher that consumes assembled args. ConstParams map[string]any // Contract is the authoring-time leaf contract declaration (selection / // interface / parameters / dry-run / identity). When non-empty, embed @@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // malformed spec can never run the pipeline — write-confirmation prompt // included — and then silently exit 0 having done nothing. func New(spec Spec) *cobra.Command { + spec.ConstParams = cloneConstParams(spec.ConstParams) validateDispatchDecl(spec) + validateConstParamsDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) validateInputSpecs(spec.Use, spec.Flags) @@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command { if spec.PostMount != nil { spec.PostMount(cmd) } + attachInterfaceBoolConstParams(cmd, spec.ConstParams) if spec.OutputRollout != "" { output.SetCommandRollout(cmd, spec.OutputRollout) } @@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command { return cmd } +func cloneConstParams(params map[string]any) map[string]any { + if params == nil { + return nil + } + frozen := make(map[string]any, len(params)) + for key, value := range params { + frozen[key] = value + } + return frozen +} + // ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The // delivery gate reads it to tell a declared guard-first command apart from an // accidental confirm-before-validate inversion: guard-first is only legitimate @@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) { } } +func validateConstParamsDecl(spec Spec) { + if len(spec.ConstParams) == 0 { + return + } + if spec.Invoke == nil && spec.ResultInvoke == nil { + panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use)) + } + for _, flag := range spec.Flags { + key := bindKey(flag) + if _, conflicts := spec.ConstParams[key]; conflicts { + panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name)) + } + } +} + // validateSafetySpec rejects partial safety declarations. A zero value remains // the historical read-only default, but once any field is authored all four // independent Schema dimensions must be explicit. diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index a5ae5b96..f9743865 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -26,6 +26,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) { } } -func TestNewCommandMergesConstParams(t *testing.T) { +func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) { var got map[string]any + declared := map[string]any{ + "precheckOnly": false, + "convThreadEnabled": true, + } cmd := New(Spec{ Use: "pub", Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}}, - ConstParams: map[string]any{"precheckOnly": false}, + ConstParams: declared, Invoke: func(_ *Ctx, toolArgs map[string]any) error { got = toolArgs return nil }, }) + declared["precheckOnly"] = true + declared["forgedAfterNew"] = true _ = cmd.Flags().Set("id", "V1") if err := cmd.RunE(cmd, nil); err != nil { t.Fatal(err) @@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) { if got["precheckOnly"] != false { t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"]) } + if got["convThreadEnabled"] != true { + t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"]) + } + if _, exists := got["forgedAfterNew"]; exists { + t.Fatalf("caller mutation leaked into dispatch args: %#v", got) + } + evidence := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) { + t.Fatalf("bool ConstParams evidence = %#v", evidence) + } + if got["precheckOnly"] != evidence["precheckOnly"] { + t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence) + } +} + +func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) { + var got map[string]any + cmd := New(Spec{ + Use: "mixed", + ConstParams: map[string]any{ + "convThreadEnabled": true, + "retryLimit": 3, + }, + Invoke: func(_ *Ctx, toolArgs map[string]any) error { + got = toolArgs + return nil + }, + }) + + if evidence := InterfaceBoolConstParams(cmd); evidence != nil { + t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if got["convThreadEnabled"] != true || got["retryLimit"] != 3 { + t.Fatalf("mixed ConstParams dispatch args = %#v", got) + } +} + +func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) { + mustPanic := func(name string, spec Spec, needle string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("%s: expected panic", name) + } + if msg, _ := r.(string); !strings.Contains(msg, needle) { + t.Fatalf("%s: panic=%v, want %q", name, r, needle) + } + }() + New(spec) + } + + mustPanic("RunE", Spec{ + Use: "run-e", + ConstParams: map[string]any{"fixed": true}, + RunE: func(*cobra.Command, []string) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("Orchestrate", Spec{ + Use: "orchestrate", + ConstParams: map[string]any{"fixed": true}, + Orchestrate: func(*Ctx) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("explicit bind conflict", Spec{ + Use: "bind-conflict", + Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}}, + ConstParams: map[string]any{"convThreadEnabled": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --thread") + mustPanic("default bind conflict", Spec{ + Use: "default-bind-conflict", + Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}}, + ConstParams: map[string]any{"fixedValue": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --fixed-value") + + result := New(Spec{ + Use: "result", + OutputRollout: output.RolloutUnifiedActive, + ConstParams: map[string]any{"fixed": true}, + ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) { + return output.Success(nil), nil + }, + }) + if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] { + t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence) + } } func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) { diff --git a/internal/corecmd/interface_const_params.go b/internal/corecmd/interface_const_params.go new file mode 100644 index 00000000..261e64d1 --- /dev/null +++ b/internal/corecmd/interface_const_params.go @@ -0,0 +1,63 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "sync" + + "github.com/spf13/cobra" +) + +var interfaceBoolConstParamsRegistry sync.Map + +// attachInterfaceBoolConstParams records framework-owned ConstParams evidence. +// Only a non-empty, entirely boolean declaration is representable in v1; +// empty or mixed declarations remove any evidence for the command. +func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) { + if cmd == nil { + return + } + if len(params) == 0 { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools := make(map[string]bool, len(params)) + for key, value := range params { + boolValue, ok := value.(bool) + if !ok { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools[key] = boolValue + } + interfaceBoolConstParamsRegistry.Store(cmd, bools) +} + +// InterfaceBoolConstParams returns a clone of framework-owned boolean +// ConstParams evidence. Callers cannot mutate the private registry. +func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool { + if cmd == nil { + return nil + } + stored, ok := interfaceBoolConstParamsRegistry.Load(cmd) + if !ok { + return nil + } + params := stored.(map[string]bool) + clone := make(map[string]bool, len(params)) + for key, value := range params { + clone[key] = value + } + return clone +} diff --git a/internal/corecmd/interface_const_params_test.go b/internal/corecmd/interface_const_params_test.go new file mode 100644 index 00000000..0068376a --- /dev/null +++ b/internal/corecmd/interface_const_params_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package corecmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) { + cmd := &cobra.Command{Use: "send"} + declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false} + attachInterfaceBoolConstParams(cmd, declared) + declared["convThreadEnabled"] = false + + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + got := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want) + } + got["convThreadEnabled"] = false + if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) { + t.Fatalf("reader leaked mutable registry state: %#v", again) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3}) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("mixed ConstParams retained evidence: %#v", got) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true}) + attachInterfaceBoolConstParams(cmd, nil) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("empty ConstParams retained evidence: %#v", got) + } +} diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go index e9cdac4e..053db474 100644 --- a/internal/interfacesnapshot/command_migrations.go +++ b/internal/interfacesnapshot/command_migrations.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "reflect" + "strconv" "strings" ) @@ -66,8 +67,14 @@ type CommandMigrationSchema struct { } type CommandParameterMigration struct { - From string `json:"from"` - To string `json:"to,omitempty"` + From string `json:"from"` + To string `json:"to,omitempty"` + ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"` +} + +type CommandReplacementConstant struct { + Property string `json:"property"` + Value bool `json:"value"` } func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) { @@ -179,8 +186,33 @@ func (m CommandMigration) validate() error { if m.Schema.SourceToolID == m.Schema.ReplacementToolID { return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool") } - if len(m.Schema.Parameters) != 1 || m.Schema.Parameters[0].From != m.LegacyFlag.Name || m.Schema.Parameters[0].To != "" { - return fmt.Errorf("flag_extraction must declare the exact extracted flag as one removed Schema parameter") + if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" { + return fmt.Errorf("flag_extraction legacy_flag must be an optional bool") + } + var extracted *CommandParameterMigration + constantCount := 0 + for index := range m.Schema.Parameters { + parameter := &m.Schema.Parameters[index] + if parameter.ReplacementConstant == nil { + continue + } + constantCount++ + if parameter.From == m.LegacyFlag.Name { + extracted = parameter + } + } + if constantCount != 1 { + return fmt.Errorf("flag_extraction must declare exactly one replacement constant") + } + if extracted == nil { + return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant") + } + if !extracted.ReplacementConstant.Value { + return fmt.Errorf("flag_extraction v1 replacement_constant must be true") + } + wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value) + if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { + return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) } } return nil @@ -240,6 +272,7 @@ func (s CommandMigrationSchema) validate(kind string) error { } seenFrom := map[string]bool{} seenTo := map[string]bool{} + seenConstantProperty := map[string]bool{} for index, parameter := range s.Parameters { if !isExactFlagName(parameter.From) { return fmt.Errorf("schema parameter %d from must be an exact parameter name", index) @@ -249,6 +282,9 @@ func (s CommandMigrationSchema) validate(kind string) error { } seenFrom[parameter.From] = true if kind == CommandMigrationMove { + if parameter.ReplacementConstant != nil { + return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index) + } if !isExactFlagName(parameter.To) || parameter.To == parameter.From { return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index) } @@ -256,9 +292,44 @@ func (s CommandMigrationSchema) validate(kind string) error { return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) } seenTo[parameter.To] = true - } else if parameter.To != "" { - return fmt.Errorf("flag_extraction schema parameters must not declare to") + continue } + if kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid command migration kind %q", kind) + } + + hasTo := parameter.To != "" + hasConstant := parameter.ReplacementConstant != nil + if !hasTo && !hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index) + } + if hasTo && hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index) + } + if hasTo { + if !isExactFlagName(parameter.To) { + return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + if seenConstantProperty[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + property := parameter.ReplacementConstant.Property + if !isExactSchemaIdentifier(property) { + return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index) + } + if seenConstantProperty[property] { + return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property) + } + if seenTo[property] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property) + } + seenConstantProperty[property] = true } return nil } @@ -464,6 +535,11 @@ func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) c } before = before && flagState == migration.LegacyFlag.Before after = after && flagState == migration.LegacyFlag.After + if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists { + after = after && commandMigrationReplacementConstantsMatch(replacement, migration) + } else { + after = false + } } if before { return commandMigrationBefore @@ -474,6 +550,17 @@ func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) c return commandMigrationPartial } +func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool { + expected := make(map[string]bool) + for _, parameter := range migration.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value + } + return reflect.DeepEqual(command.BoolConstParams, expected) +} + func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState { command, exists := commands[path] if !exists { diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index 2ed88199..b41f6600 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -32,6 +32,10 @@ func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil { t.Fatalf("valid command migration manifest: %v", err) } + validExtraction := flagExtractionCommandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil { + t.Fatalf("valid flag extraction command migration manifest: %v", err) + } for _, test := range []struct { input string want string @@ -42,6 +46,9 @@ func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"}, {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"}, {`{"version":1,"migrations":null}`, "must be an array"}, + {strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"}, + {strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`}, + {strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"}, } { if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want) @@ -101,17 +108,46 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"}, {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"}, {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"}, - {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[0].From = "other" }, "exact extracted flag"}, + {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"}, + {"wrong type", func(m *CommandMigration) { + m.LegacyFlag.Before.Type = "string" + m.LegacyFlag.After.Type = "string" + }, "optional bool"}, + {"wrong no opt", func(m *CommandMigration) { + m.LegacyFlag.Before.NoOpt = "false" + m.LegacyFlag.After.NoOpt = "false" + }, "no_opt"}, + {"missing extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = m.Schema.Parameters[:3] + }, "exactly one replacement constant"}, + {"duplicate extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3]) + }, "duplicates from"}, + {"multiple constants", func(m *CommandMigration) { + m.Schema.Parameters[0] = CommandParameterMigration{ + From: "name", + ReplacementConstant: &CommandReplacementConstant{ + Property: "name", + Value: true, + }, + } + }, "exactly one replacement constant"}, } { t.Run(test.name, func(t *testing.T) { - migration := validExtraction - migration.Schema.Parameters = append([]CommandParameterMigration(nil), validExtraction.Schema.Parameters...) + migration := cloneCommandMigration(validExtraction) test.mutate(&migration) if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("validate error=%v, want %q", err, test.want) } }) } + falseConstant := cloneCommandMigration(validExtraction) + falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false + falseConstant.LegacyFlag.Before.NoOpt = "false" + falseConstant.LegacyFlag.After.NoOpt = "false" + if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") { + t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err) + } for _, state := range []CommandMigrationState{ {Runnable: true}, @@ -157,7 +193,9 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To}) }, "duplicates to"}, - {"extraction to", CommandMigrationFlagExtraction, func(s *CommandMigrationSchema) { s.Parameters[0].To = "new-id" }, "must not declare to"}, + {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true} + }, "must not declare replacement_constant"}, } { t.Run("schema "+test.name, func(t *testing.T) { schema := validSchema @@ -168,6 +206,30 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { } }) } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationSchema) + want string + }{ + {"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"}, + {"both targets", func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true} + }, "exactly one target"}, + {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"}, + {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"}, + {"duplicate constant target", func(s *CommandMigrationSchema) { + s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}} + }, "duplicates replacement_constant property"}, + } { + t.Run("extraction schema "+test.name, func(t *testing.T) { + schema := cloneCommandMigration(validExtraction).Schema + test.mutate(&schema) + if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } } func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { @@ -429,6 +491,55 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *test } } +func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := flagExtractionCommandMigrationManifest(CommandMigrationPending) + consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + migration := pending.Migrations[0] + + if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore { + t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore) + } + if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter { + t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter) + } + + for _, test := range []struct { + name string + values map[string]bool + }{ + {name: "missing", values: nil}, + {name: "wrong property", values: map[string]bool{"otherProperty": true}}, + {name: "wrong value", values: map[string]bool{"convThreadEnabled": false}}, + {name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}}, + } { + t.Run(test.name, func(t *testing.T) { + current := withCommandBoolConstParams(after, "dws chat topic create", test.values) + if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial { + t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("constant evidence self-authorization error=%v", err) + } + }) + } +} + func commandMigrationManifestJSON() string { return `{ "version": 1, @@ -456,6 +567,43 @@ func commandMigrationManifestJSON() string { }` } +func flagExtractionCommandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group", + "replacement_tool_id": "chat.create_topic", + "parameters": [ + {"from":"name","to":"name"}, + {"from":"type","to":"type"}, + {"from":"users","to":"users"}, + {"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}} + ] + }, + "state": "pending", + "reason": "Reviewed flag extraction." + }] +}` +} + func commandMigrationManifest(state string) CommandMigrationManifest { move := CommandMigration{ Kind: CommandMigrationMove, @@ -499,7 +647,18 @@ func commandMigrationManifest(state string) CommandMigrationManifest { ProductID: "chat", SourceToolID: "chat.create_group", ReplacementToolID: "chat.create_topic", - Parameters: []CommandParameterMigration{{From: "thread"}}, + Parameters: []CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &CommandReplacementConstant{ + Property: "convThreadEnabled", + Value: true, + }, + }, + }, }, State: state, Reason: "Reviewed flag extraction.", @@ -507,6 +666,25 @@ func commandMigrationManifest(state string) CommandMigrationManifest { return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}} } +func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[1:] + return manifest +} + +func cloneCommandMigration(source CommandMigration) CommandMigration { + cloned := source + cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + for index, parameter := range source.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + constant := *parameter.ReplacementConstant + cloned.Schema.Parameters[index].ReplacementConstant = &constant + } + return cloned +} + func singleCommandMigrationManifest(state string) CommandMigrationManifest { manifest := commandMigrationManifest(state) manifest.Migrations = manifest.Migrations[:1] @@ -534,10 +712,24 @@ func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot { if removeUnrelated { commands[2].LocalFlags = commands[2].LocalFlags[:1] } + topicCreate := testCommand("dws chat topic create") + topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true} commands = append(commands, - testCommand("dws chat topic create"), + topicCreate, testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}), ) } return testSnapshot(commands...) } + +func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot { + cloned := snapshot + cloned.Commands = append([]Command(nil), snapshot.Commands...) + for index := range cloned.Commands { + if cloned.Commands[index].Path == path { + cloned.Commands[index].BoolConstParams = values + break + } + } + return cloned +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index e520626d..5de0dd9e 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report { // alias), // - flags accepted at each command path may not disappear, change type, or // become required; an existing path may not gain a new required flag, +// - once bool ConstParams evidence exists, its exact property/value map is a +// durable contract; adding the first evidence to an older snapshot remains +// a silent bootstrap, // - new commands and flags are allowed. // // The single exception to the type rule is an individually reviewed migration @@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison { } func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) { + result.Blocking = append(result.Blocking, Change{ + Kind: "bool_const_params_changed", + Path: acceptedPath, + Before: fmt.Sprintf("%v", oldCommand.BoolConstParams), + After: fmt.Sprintf("%v", newCommand.BoolConstParams), + }) + } if oldCommand.Runnable && !newCommand.Runnable { result.Blocking = append(result.Blocking, Change{ Kind: "command_became_non_runnable", diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 9dab9936..e73aa074 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T } } +func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + comparison := Compare(current, base, "base") + if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 { + t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + for _, test := range []struct { + name string + current map[string]bool + }{ + {name: "removed", current: nil}, + {name: "value flipped", current: map[string]bool{"convThreadEnabled": false}}, + {name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}}, + } { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = test.current + + comparison := Compare(current, base, "base") + if comparison.Compatible { + t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison) + } + if len(comparison.Blocking) != 1 { + t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking) + } + change := comparison.Blocking[0] + if change.Kind != "bool_const_params_changed" || change.Path != "dws send" { + t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking) + } + if len(comparison.Additions) != 0 { + t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions) + } + }) + } +} + func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) { base := testSnapshot( testCommand("dws"), diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index ff736994..a16395f8 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -99,6 +99,12 @@ func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schem if err != nil { return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err) } + for schema.Kind() == reflect.Pointer { + if token == nil { + return migrationJSONTypeError(path, schema.Elem(), token, label) + } + schema = schema.Elem() + } switch schema.Kind() { case reflect.Struct: diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go index 408805d3..11a7bf46 100644 --- a/internal/interfacesnapshot/snapshot.go +++ b/internal/interfacesnapshot/snapshot.go @@ -22,12 +22,13 @@ import ( "sort" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const SchemaVersion = 2 +const SchemaVersion = 3 // FlagAliasOfAnnotation records a framework-originated reviewed relationship // between a retained compatibility flag and an exact canonical Cobra flag. @@ -66,13 +67,14 @@ type Snapshot struct { // Command contains compatibility-relevant command metadata. Path always // includes the root command name (for example, "dws chat message send"). type Command struct { - Path string `json:"path"` - Runnable bool `json:"runnable"` - Hidden bool `json:"hidden"` - Deprecated string `json:"deprecated,omitempty"` - Aliases []string `json:"aliases"` - LocalFlags []Flag `json:"local_flags"` - InheritedFlags []Flag `json:"inherited_flags"` + Path string `json:"path"` + Runnable bool `json:"runnable"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` + Aliases []string `json:"aliases"` + LocalFlags []Flag `json:"local_flags"` + InheritedFlags []Flag `json:"inherited_flags"` + BoolConstParams map[string]bool `json:"bool_const_params,omitempty"` } // Flag contains the stable pflag contract visible at a command node. @@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot { } aliases = compactSorted(aliases) + boolConstParams := corecmd.InterfaceBoolConstParams(cmd) snapshot.Commands = append(snapshot.Commands, Command{ - Path: path, - Runnable: cmd.Runnable(), - Hidden: cmd.Hidden, - Deprecated: strings.TrimSpace(cmd.Deprecated), - Aliases: aliases, - LocalFlags: captureFlags(cmd.LocalFlags()), - InheritedFlags: captureFlags(cmd.InheritedFlags()), + Path: path, + Runnable: cmd.Runnable(), + Hidden: cmd.Hidden, + Deprecated: strings.TrimSpace(cmd.Deprecated), + Aliases: aliases, + LocalFlags: captureFlags(cmd.LocalFlags()), + InheritedFlags: captureFlags(cmd.InheritedFlags()), + BoolConstParams: boolConstParams, }) children := append([]*cobra.Command(nil), cmd.Commands()...) @@ -198,6 +202,9 @@ func (s Snapshot) Validate() error { return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path) } seenCommands[command.Path] = true + if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 { + return fmt.Errorf("command %q must omit empty bool_const_params", command.Path) + } if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { return err } diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go index eb27274e..2bb6324d 100644 --- a/internal/interfacesnapshot/snapshot_test.go +++ b/internal/interfacesnapshot/snapshot_test.go @@ -17,6 +17,7 @@ import ( "bytes" "encoding/json" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { input := bytes.NewBufferString(`{ - "schema_version": 2, - "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, - "commands": [], - "future_field": true + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true }`) if _, err := Read(input); err == nil { t.Fatal("Read accepted an unknown field") } } +func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := corecmd.New(corecmd.Spec{ + Use: "send", + ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false}, + Invoke: func(*corecmd.Ctx, map[string]any) error { return nil }, + }) + root.AddCommand(leaf) + + snapshot := Capture(root) + got := commandIndex(snapshot)["dws send"].BoolConstParams + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want) + } + + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write: %v", err) + } + decoded, err := Read(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) { + t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) { + root := &cobra.Command{Use: "dws"} + direct := &cobra.Command{ + Use: "direct", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + "dws.compat.bool_const_params": `{"forged":true}`, + "dws.compat.const_params_origin": "corecmd.const_params.v1", + }, + } + businessConstructor := func() *cobra.Command { + payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "") + originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "") + origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "") + return &cobra.Command{ + Use: "business", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + payloadKey: `{"forged":true}`, + originKey: origin, + }, + } + } + root.AddCommand(direct, businessConstructor()) + + snapshot := Capture(root) + if err := snapshot.Validate(); err != nil { + t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err) + } + for _, path := range []string{"dws direct", "dws business"} { + if got := commandIndex(snapshot)[path].BoolConstParams; got != nil { + t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got) + } + } + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write snapshot without forged evidence: %v", err) + } +} + +func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) { + for _, payload := range []string{`{"fixed":"true"}`, `{}`} { + input := bytes.NewBufferString(`{ + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [{ + "path": "dws send", + "runnable": true, + "aliases": [], + "local_flags": [], + "inherited_flags": [], + "bool_const_params": ` + payload + ` + }] + }`) + if _, err := Read(input); err == nil { + t.Fatalf("Read accepted malformed bool_const_params %s", payload) + } + } +} + func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) { if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf { t.Fatalf( diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 1cad7e74..45441cfd 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -16,7 +16,10 @@ MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrat COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 @@ -146,6 +149,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -179,12 +193,13 @@ has_complete_schema_command_migration_governance() { governance_commit="$1" has_complete_schema_migration_governance "$governance_commit" && commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" } has_any_schema_command_migration_governance_artifact() { governance_commit="$1" - for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do if commit_path_exists "$governance_commit" "$relative_path"; then return 0 fi @@ -192,6 +207,51 @@ has_any_schema_command_migration_governance_artifact() { return 1 } +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -252,6 +329,7 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then exit 2 fi check_candidate_alias_source_policy +check_candidate_const_params_source_policy EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" @@ -285,6 +363,7 @@ if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" require_complete_candidate_schema_command_governance + require_base_identical_command_migration_bridges elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \ "$BASE_REF" >&2 @@ -295,6 +374,11 @@ elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; t require_complete_candidate_schema_command_governance fi +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE" +fi + BASE_BIN="$TMP_ROOT/base-dws" STABLE_BIN="$TMP_ROOT/stable-dws" CANDIDATE_BIN="$TMP_ROOT/candidate-dws" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index 01724159..2292bd77 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -14,7 +14,10 @@ CANDIDATE_REF="HEAD" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2 @@ -132,6 +135,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -172,12 +186,13 @@ has_complete_command_migration_governance() { governance_commit="$1" has_complete_migration_governance "$governance_commit" && commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" } has_any_command_migration_governance_artifact() { governance_commit="$1" - for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do if commit_path_exists "$governance_commit" "$relative_path"; then return 0 fi @@ -185,6 +200,41 @@ has_any_command_migration_governance_artifact() { return 1 } +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -249,11 +316,37 @@ install_authority_helper() { fi } +install_authority_const_params_registry() { + source_root="$1" + worktree="$2" + + [ "$source_root" = "$worktree" ] && return + mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")" + rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL" + cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL" +} + EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false USE_COMMAND_MIGRATION_GOVERNANCE=false +if has_complete_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_command_governance + require_base_identical_command_migration_bridges +elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap is safe because the base-owned comparator receives no new + # authorization input and still performs the ordinary compatibility check. + require_complete_candidate_command_governance +fi + if has_complete_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true AUTHORITY_ROOT="$BASE_WORKTREE" @@ -261,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true else @@ -280,6 +374,7 @@ else AUTHORITY_ROOT="$BASE_WORKTREE" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2 @@ -288,23 +383,13 @@ else install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false fi -if has_complete_command_migration_governance "$BASE_COMMIT"; then - USE_COMMAND_MIGRATION_GOVERNANCE=true - APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" - CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" - require_complete_candidate_command_governance -elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then - printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ - "$BASE_REF" >&2 - exit 2 -elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then - # Bootstrap is safe because the base-owned comparator receives no new - # authorization input and still performs the ordinary compatibility check. - require_complete_candidate_command_governance -fi - install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE" +fi generate_snapshot() { worktree="$1" diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go index 9f1ddd8b..494a50e2 100644 --- a/scripts/policy/commandcompat/authority_integration_test.go +++ b/scripts/policy/commandcompat/authority_integration_test.go @@ -51,6 +51,8 @@ func main() { base := flags.String("base", "", "base snapshot") stable := flags.String("stable", "", "stable snapshot") _ = flags.String("approved-flag-migrations", "", "approved manifest") + _ = flags.String("approved-command-migrations", "", "approved command manifest") + _ = flags.String("candidate-command-migrations", "", "candidate command manifest") _ = flags.Parse(os.Args[2:]) for _, path := range []string{*current, *base, *stable} { content, err := os.ReadFile(path) @@ -97,6 +99,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const commandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-command-compat-authority-test/internal/corecmd" + "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const baseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -121,6 +144,54 @@ const ( ) ` +func constParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const baseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const forgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const baseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const forgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -131,11 +202,13 @@ func main() { ` type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - want string - wantAuthorityDecision bool + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + want string + wantAuthorityDecision bool } func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test baseGovernance: "none", wantAuthorityDecision: true, }, + { + name: "command governed base owns bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + wantAuthorityDecision: true, + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "bootstrap", + wantAuthorityDecision: true, + }, + { + name: "command governed candidate cannot delete bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + wantAuthorityDecision: true, + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + wantAuthorityDecision: true, + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } run(t, fixtureRoot, "git", "add", ".") run(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { 0o644, ) } + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } switch test.candidateMutation { case "": case "delete-alias-contract": @@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json index cd85e1a0..52a6bff1 100644 --- a/scripts/policy/interface-migrations/approved-command-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -22,7 +22,18 @@ "product_id": "chat", "source_tool_id": "chat.create_group_conversation", "replacement_tool_id": "chat.create_topic_conversation", - "parameters": [{"from": "thread"}] + "parameters": [ + {"from": "name", "to": "name"}, + {"from": "type", "to": "type"}, + {"from": "users", "to": "users"}, + { + "from": "thread", + "replacement_constant": { + "property": "convThreadEnabled", + "value": true + } + } + ] }, "state": "pending", "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path." diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 5d4298ff..2bdcdd0b 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -264,6 +264,249 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin } } +func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) { + t.Run("missing historical mapping", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1) + for _, parameter := range migrations[1].Schema.Parameters { + if parameter.From != "users" { + parameters = append(parameters, parameter) + } + } + migrations[1].Schema.Parameters = parameters + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) { + t.Fatalf("missing historical mapping error=%v", err) + } + }) + + t.Run("replacement removes historical dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), "changed or removed dry_run") { + t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err) + } + }) + + t.Run("replacement adds dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil { + t.Fatalf("additive replacement dry_run was rejected: %v", err) + } + }) + + for _, test := range []struct { + name string + mutate func(*schemaContract) + want string + }{ + { + name: "missing shared parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + delete(tool.Parameters, "users") + }) + }, + want: `does not publish mapped Schema parameter "users"`, + }, + { + name: "shared parameter field drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) { + parameter.Property = "differentUserIds" + }) + }, + want: "changed a non-name field", + }, + { + name: "extra replacement parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"} + }) + }, + want: `publishes unmapped Schema parameter "extra"`, + }, + { + name: "constraints drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["name","type"]]}` + }) + }, + want: "changed constraints", + }, + { + name: "positionals drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals[0].Name = "topic-name" + }) + }, + want: "changed positionals", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + test.mutate(¤t) + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement projection error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration) + want string + }{ + { + name: "constant property mismatch", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty" + }, + want: "constant property", + }, + { + name: "constant false", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false + }, + want: "constant true", + }, + { + name: "legacy type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Type = `"string"` + }) + }, + want: "must have boolean type", + }, + { + name: "legacy required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Required = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy cli required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.CLIRequired = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy required when", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.RequiredWhen = "mode=topic" + }) + }, + want: "must not declare required_when", + }, + { + name: "legacy default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Default = "true" + }) + }, + want: "default must be absent or false", + }, + { + name: "legacy interface default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceDefault = "true" + }) + }, + want: "interface_default must be absent or false", + }, + { + name: "legacy interface type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceType = "string" + }) + }, + want: "interface_type must be empty or boolean", + }, + { + name: "legacy enum excludes true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Enum = []string{"false"} + }) + }, + want: "enum must allow true", + }, + { + name: "legacy format", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Format = "custom-bool" + }) + }, + want: "format must be empty", + }, + { + name: "replacement publishes constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "still publishes replacement constant property", + }, + { + name: "source renames constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "source Schema tool still publishes replacement constant property", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(&baseline, ¤t, migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement constant error=%v, want %q", err, test.want) + } + }) + } +} + func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) { directory := t.TempDir() baselinePath := filepath.Join(directory, "baseline.json") @@ -359,17 +602,34 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T func schemaCommandMigrationContract(after bool) schemaContract { id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} keep := parameterSchema{Type: `"string"`, Property: "keep"} - thread := parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + name := parameterSchema{Type: `"string"`, Property: "name"} + groupType := parameterSchema{Type: `"string"`, Property: "type"} + users := parameterSchema{Type: `"array"`, Property: "userIds"} + thread := parameterSchema{ + Type: `"boolean"`, + Property: "threadEnabled", + InterfaceType: "boolean", + Default: "false", + InterfaceDefault: "false", + Enum: []string{"false", "true"}, + } group := toolSchema{ PrimaryCLIPath: "chat group create", InterfaceMode: "mcp", InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`, Availability: "available", - Parameters: map[string]parameterSchema{"name": keep, "thread": thread}, - Effect: "write", - Risk: "medium", - Confirmation: "not_required", - Idempotency: "unknown", + Parameters: map[string]parameterSchema{ + "name": name, + "thread": thread, + "type": groupType, + "users": users, + }, + Constraints: `{"require_together":[["name","type"]]}`, + Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}}, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", } move := toolSchema{ PrimaryCLIPath: "chat message old", @@ -388,6 +648,11 @@ func schemaCommandMigrationContract(after bool) schemaContract { delete(group.Parameters, "thread") tools["chat.create_group"] = group replacement := group + replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters)) + for name, parameter := range group.Parameters { + replacement.Parameters[name] = parameter + } + replacement.Positionals = append([]positionalSchema(nil), group.Positionals...) replacement.PrimaryCLIPath = "chat topic create" tools["chat.create_topic"] = replacement delete(move.Parameters, "old-id") @@ -432,14 +697,25 @@ func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration }, LegacyFlag: interfacesnapshot.CommandMigrationFlag{ Name: "thread", - Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Scope: "local"}, - After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, Scope: "local"}, + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, }, Schema: interfacesnapshot.CommandMigrationSchema{ ProductID: "chat", SourceToolID: "chat.create_group", ReplacementToolID: "chat.create_topic", - Parameters: []interfacesnapshot.CommandParameterMigration{{From: "thread"}}, + Parameters: []interfacesnapshot.CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{ + Property: "threadEnabled", + Value: true, + }, + }, + }, }, }, } @@ -465,7 +741,7 @@ func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{{ - Name: "thread", Type: "bool", + Name: "thread", Type: "bool", NoOpt: "true", }}, InheritedFlags: []interfacesnapshot.Flag{}, }, @@ -475,7 +751,14 @@ func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { commands[1].LocalFlags[0].Hidden = true commands[2].Hidden = true commands = append(commands, - interfacesnapshot.Command{Path: "dws chat topic create", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + interfacesnapshot.Command{ + Path: "dws chat topic create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + BoolConstParams: map[string]bool{"threadEnabled": true}, + }, interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, ) } @@ -489,6 +772,27 @@ func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { } } +func mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) { + product := contract.Products["chat"] + tool := product.Tools[toolID] + mutate(&tool) + product.Tools[toolID] = tool + contract.Products["chat"] = product +} + +func mutateSchemaCommandMigrationParameter( + contract *schemaContract, + toolID string, + parameterName string, + mutate func(*parameterSchema), +) { + mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) { + parameter := tool.Parameters[parameterName] + mutate(¶meter) + tool.Parameters[parameterName] = parameter + }) +} + func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) { t.Helper() file, err := os.Create(path) diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 391406e9..7496d4f3 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -1512,22 +1512,22 @@ func normalizeSchemaCommandMigrations( migration.Legacy.Command, ) } - for _, parameter := range migration.Schema.Parameters { - if _, existed := oldTool.Parameters[parameter.From]; !existed { - return schemaContract{}, fmt.Errorf( - "approved flag extraction %q historical Schema tool lacks parameter %q", - migration.Legacy.Command, - parameter.From, - ) - } - if _, exists := newSource.Parameters[parameter.From]; exists { - return schemaContract{}, fmt.Errorf( - "approved flag extraction %q still publishes extracted Schema parameter %q", - migration.Legacy.Command, - parameter.From, - ) - } - delete(normalizedTool.Parameters, parameter.From) + if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed or removed dry_run", + migration.Legacy.Command, + ) + } + var err error + normalizedTool, err = normalizeFlagExtractionSchemaMigration( + migration, + oldTool, + newSource, + replacement, + normalizedTool, + ) + if err != nil { + return schemaContract{}, err } } normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool @@ -1536,6 +1536,277 @@ func normalizeSchemaCommandMigrations( return normalized, nil } +func normalizeFlagExtractionSchemaMigration( + migration interfacesnapshot.CommandMigration, + oldTool toolSchema, + newSource toolSchema, + replacement toolSchema, + normalizedSource toolSchema, +) (toolSchema, error) { + mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters)) + for _, mapping := range migration.Schema.Parameters { + if _, duplicate := mappingsBySource[mapping.From]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q maps historical Schema parameter %q more than once", + migration.Legacy.Command, + mapping.From, + ) + } + if _, exists := oldTool.Parameters[mapping.From]; !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + mapping.From, + ) + } + mappingsBySource[mapping.From] = mapping + } + + historicalNames := make([]string, 0, len(oldTool.Parameters)) + for name := range oldTool.Parameters { + historicalNames = append(historicalNames, name) + } + sort.Strings(historicalNames) + for _, name := range historicalNames { + if _, mapped := mappingsBySource[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q does not map historical Schema parameter %q", + migration.Legacy.Command, + name, + ) + } + } + + legacyName := migration.LegacyFlag.Name + legacyMapping, mapped := mappingsBySource[legacyName] + if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must map to one replacement constant", + migration.Legacy.Command, + legacyName, + ) + } + legacyParameter := oldTool.Parameters[legacyName] + if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil { + return toolSchema{}, err + } + constantProperty := legacyMapping.ReplacementConstant.Property + if _, exists := newSource.Parameters[legacyName]; exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q still publishes extracted Schema parameter %q", + migration.Legacy.Command, + legacyName, + ) + } + sourceNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + sourceNames = append(sourceNames, name) + } + sort.Strings(sourceNames) + for _, name := range sourceNames { + if newSource.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q", + migration.Legacy.Command, + constantProperty, + name, + migration.Schema.SourceToolID, + ) + } + } + + replacementNames := make([]string, 0, len(replacement.Parameters)) + for name := range replacement.Parameters { + replacementNames = append(replacementNames, name) + } + sort.Strings(replacementNames) + for _, name := range replacementNames { + if replacement.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + constantProperty, + name, + ) + } + } + + replacementTargets := make(map[string]string, len(historicalNames)-1) + renames := make(map[string]string, len(historicalNames)-1) + for _, sourceName := range historicalNames { + if sourceName == legacyName { + continue + } + mapping := mappingsBySource[sourceName] + if mapping.To == "" || mapping.ReplacementConstant != nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q shared Schema parameter %q must map to one replacement parameter", + migration.Legacy.Command, + sourceName, + ) + } + if previous, duplicate := replacementTargets[mapping.To]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q", + migration.Legacy.Command, + mapping.To, + previous, + sourceName, + ) + } + replacementParameter, exists := replacement.Parameters[mapping.To] + if !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + mapping.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil { + return toolSchema{}, err + } + replacementTargets[mapping.To] = sourceName + renames[sourceName] = mapping.To + } + for _, name := range replacementNames { + if _, mapped := replacementTargets[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + name, + ) + } + } + + oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil) + if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed constraints", + migration.Legacy.Command, + ) + } + if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed positionals", + migration.Legacy.Command, + ) + } + + delete(normalizedSource.Parameters, legacyName) + return normalizedSource, nil +} + +func validateFlagExtractionBooleanConstant( + migration interfacesnapshot.CommandMigration, + parameter parameterSchema, + constant *interfacesnapshot.CommandReplacementConstant, +) error { + if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property { + return fmt.Errorf( + "approved flag extraction %q replacement constant property %q does not match historical Schema property %q", + migration.Legacy.Command, + constant.Property, + parameter.Property, + ) + } + if !constant.Value { + return fmt.Errorf( + "approved flag extraction %q replacement constant for Schema property %q must be constant true", + migration.Legacy.Command, + constant.Property, + ) + } + if parameter.Type != `"boolean"` { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must have boolean type", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Required || parameter.CLIRequired { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must remain optional", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.RequiredWhen != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must not declare required_when", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Default != "" && parameter.Default != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Format != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q format must be empty", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if len(parameter.Enum) > 0 { + allowsTrue := false + for _, value := range parameter.Enum { + if value == "true" { + allowsTrue = true + break + } + } + if !allowsTrue { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q enum must allow true", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + } + return nil +} + +func equivalentMigratedPositionals( + historical []positionalSchema, + replacement []positionalSchema, + renames map[string]string, +) bool { + if len(historical) != len(replacement) { + return false + } + for index, positional := range historical { + if renamed := renames[positional.Name]; renamed != "" { + positional.Name = renamed + } + if positional != replacement[index] { + return false + } + } + return true +} + func validateEquivalentCommandSchemaParameter( migration interfacesnapshot.CommandMigration, parameter interfacesnapshot.CommandParameterMigration, diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index 5866ee50..be8857f4 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -32,6 +32,8 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") + _ = flag.String("approved-command-migrations", "", "base command ledger") + _ = flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -171,6 +173,8 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") + _ = flag.String("approved-command-migrations", "", "base command ledger") + _ = flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -282,6 +286,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const schemaCommandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-schema-compat-authority-test/internal/corecmd" + "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const schemaBaseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -306,6 +331,54 @@ const ( ) ` +func schemaConstParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const schemaBaseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaForgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaBaseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const schemaForgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -327,12 +400,14 @@ func main() { } type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - checkerMode string - want string - authorityMarker string + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + checkerMode string + want string + authorityMarker string } func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -353,6 +428,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi baseGovernance: "none", authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED", }, + { + name: "command governed base owns bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "bootstrap", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governed candidate cannot delete bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete Schema command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -479,6 +633,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } schemaRun(t, fixtureRoot, "git", "add", ".") schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -508,6 +675,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n" } schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644) + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644) switch test.candidateMutation { case "": @@ -534,6 +712,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") From d583247935fb70099e9447cdf98ff632b1648a57 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 01:50:13 +0800 Subject: [PATCH 192/267] test(ci): cover command governance branches --- .../corecmd/interface_const_params_test.go | 5 ++ .../command_migrations_test.go | 9 +++ .../schema-compat/command_migrations_test.go | 58 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/internal/corecmd/interface_const_params_test.go b/internal/corecmd/interface_const_params_test.go index 0068376a..ea01d24e 100644 --- a/internal/corecmd/interface_const_params_test.go +++ b/internal/corecmd/interface_const_params_test.go @@ -11,6 +11,11 @@ import ( ) func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) { + attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true}) + if got := InterfaceBoolConstParams(nil); got != nil { + t.Fatalf("nil command evidence = %#v, want nil", got) + } + cmd := &cobra.Command{Use: "send"} declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false} attachInterfaceBoolConstParams(cmd, declared) diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index b41f6600..9585afa1 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -196,6 +196,7 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true} }, "must not declare replacement_constant"}, + {"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"}, } { t.Run("schema "+test.name, func(t *testing.T) { schema := validSchema @@ -216,11 +217,19 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { {"both targets", func(s *CommandMigrationSchema) { s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true} }, "exactly one target"}, + {"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"}, {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"}, {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"}, {"duplicate constant target", func(s *CommandMigrationSchema) { s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}} }, "duplicates replacement_constant property"}, + {"constant before parameter target", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0] + }, "duplicates parameter target"}, + {"parameter target before constant", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + }, "duplicates parameter target"}, } { t.Run("extraction schema "+test.name, func(t *testing.T) { schema := cloneCommandMigration(validExtraction).Schema diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 2bdcdd0b..ada5dcc7 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -283,6 +283,55 @@ func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementPro } }) + for _, test := range []struct { + name string + mutate func([]interfacesnapshot.CommandMigration) + want string + }{ + { + name: "duplicate historical mapping", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters = append( + migrations[1].Schema.Parameters, + migrations[1].Schema.Parameters[0], + ) + }, + want: "maps historical Schema parameter \"name\" more than once", + }, + { + name: "legacy mapping has two targets", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].To = "thread" + }, + want: "must map to one replacement constant", + }, + { + name: "shared mapping has no target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[0].To = "" + }, + want: "must map to one replacement parameter", + }, + { + name: "duplicate replacement target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[1].To = "name" + }, + want: "is mapped from both \"name\" and \"type\"", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("invalid mapping error=%v, want %q", err, test.want) + } + }) + } + t.Run("replacement removes historical dry run", func(t *testing.T) { baseline := schemaCommandMigrationContract(false) mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) { @@ -361,6 +410,15 @@ func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementPro }, want: "changed positionals", }, + { + name: "positionals length drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"}) + }) + }, + want: "changed positionals", + }, } { t.Run(test.name, func(t *testing.T) { baseline := schemaCommandMigrationContract(false) From 169bbe88c018378f04abe48bdf976c36327a03ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 10:24:35 +0800 Subject: [PATCH 193/267] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=99=84=E4=BB=B6dws?= =?UTF-8?q?=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changes/oa-approval-attachment-upload.md | 6 + .../app/schema_oa_attachment_contract_test.go | 24 ++++ internal/helpers/oa.go | 119 ++++++++++++++++- internal/helpers/oa_attachment_test.go | 122 ++++++++++++++++++ skills/mono/references/products/oa.md | 34 +++++ skills/multi/dingtalk-misc/references/oa.md | 34 +++++ .../oa_attachment_delivery_policy_test.go | 4 + 7 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 .changes/oa-approval-attachment-upload.md diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md new file mode 100644 index 00000000..f2bbe2fd --- /dev/null +++ b/.changes/oa-approval-attachment-upload.md @@ -0,0 +1,6 @@ +--- +category: Added +--- + +- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`). +- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`). diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go index 75d40694..e175c275 100644 --- a/internal/app/schema_oa_attachment_contract_test.go +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -54,6 +54,30 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t "spaceId": "integer", "agentId": "integer", "class": "string", }, }, + { + cliPath: "oa approval attachment init-upload", + canonical: "oa.init_attachment_upload_info", + rpc: "init_attachment_upload_info", + description: "初始化审批附件上传信息,返回 OSS 上传凭证", + effect: "read", + resultType: "object", + resultFields: map[string]string{ + "uploadKey": "string", "resourceUrls": "array", "storageDriver": "string", + "expirationSeconds": "integer", "region": "string", "class": "string", + }, + }, + { + cliPath: "oa approval attachment commit-upload", + canonical: "oa.commit_attachment_upload_info", + rpc: "commit_attachment_upload_info", + description: "提交附件上传信息,完成附件上传流程", + effect: "write", + resultType: "object", + resultFields: map[string]string{ + "spaceId": "integer", "fileName": "string", "fileSize": "integer", + "class": "string", "fileType": "string", "fileId": "string", + }, + }, } for _, test := range tests { diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 8dfe47b5..c7bd865d 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -140,6 +140,15 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any return output.Success(result), nil } +// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的 +// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。 +func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error { + if size, _ := cmd.Flags().GetInt("file-size"); size < 0 { + return apperrors.NewValidation("--file-size 必须大于等于 0") + } + return nil +} + func newOAAttachmentCommand() *cobra.Command { attachmentCmd := &cobra.Command{ Use: "attachment", @@ -318,7 +327,113 @@ func newOAAttachmentCommand() *cobra.Command { }, }) - attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) + initUploadCmd := NewLeafCommand(LeafSpec{ + Use: "init-upload", + Short: "初始化审批附件上传信息,使用获取到的信息上传文件", + Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400", + Server: "oa", + Tool: "init_attachment_upload_info", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, + Flags: []LeafFlag{ + {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, + {Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true}, + }, + Validate: validateOAAttachmentFileSize, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "init_attachment_upload_info", + CanonicalPath: "oa.init_attachment_upload_info", + CLIPath: "oa approval attachment init-upload", + PrimaryCLIPath: "oa approval attachment init-upload", + }, + Description: "初始化审批附件上传信息,返回 OSS 上传凭证", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址", + UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"}, + AvoidWhen: []string{ + "文件已上传完成需要提交确认时使用 attachment commit-upload", + "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", + }, + Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "file-name", Property: "fileName", InterfaceType: "string"}, + {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, + {Name: "md5", Property: "md5", InterfaceType: "string"}, + }, + }, + }) + + commitUploadCmd := NewLeafCommand(LeafSpec{ + Use: "commit-upload", + Short: "提交附件上传信息", + Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400", + Server: "oa", + Tool: "commit_attachment_upload_info", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, + Flags: []LeafFlag{ + {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, + {Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, + }, + Validate: validateOAAttachmentFileSize, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "commit_attachment_upload_info", + CanonicalPath: "oa.commit_attachment_upload_info", + CLIPath: "oa approval attachment commit-upload", + PrimaryCLIPath: "oa approval attachment commit-upload", + }, + Description: "提交附件上传信息,完成附件上传流程", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "提交附件上传信息,确认文件上传完成", + UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"}, + AvoidWhen: []string{ + "尚未初始化上传信息时使用 attachment init-upload", + "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", + }, + Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "file-name", Property: "fileName", InterfaceType: "string"}, + {Name: "upload-key", Property: "uploadKey", InterfaceType: "string"}, + {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, + }, + }, + }) + + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd) return attachmentCmd } @@ -330,7 +445,7 @@ func newOAAttachmentCommand() *cobra.Command { // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, // start_process_instance, get_attachment_download_url, auth_download_file, -// auth_preview_attachment +// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info // ────────────────────────────────────────────────────────── func newOaCommand() *cobra.Command { diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 230e9021..8eb9e1f3 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -337,3 +337,125 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin }) } } + +func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) { + const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}` + commandArgs := []string{ + "approval", "attachment", "init-upload", + "--file-name", "合同.pdf", + "--file-size", "102400", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + } + wantArgs := map[string]any{ + "fileName": "合同.pdf", + "fileSize": 102400, + "md5": "d41d8cd98f00b204e9800998ecf8427e", + } + + t.Run("json", func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.server != "oa" || caller.tool != "init_attachment_upload_info" { + t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool) + } + if !reflect.DeepEqual(caller.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["uploadKey"] != "key-123" { + t.Fatalf("unified data = %#v", envelope["data"]) + } + }) + + t.Run("optional md5 omitted", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} + if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "init-upload", + "--file-name", "report.xlsx", + "--file-size", "2048", + ); err != nil { + t.Fatalf("execute command: %v", err) + } + if _, exists := caller.args["md5"]; exists { + t.Fatalf("optional md5 should be omitted: %#v", caller.args) + } + }) +} + +func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) { + const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` + commandArgs := []string{ + "approval", "attachment", "commit-upload", + "--file-name", "合同.pdf", + "--upload-key", "key-123", + "--file-size", "102400", + } + wantArgs := map[string]any{ + "fileName": "合同.pdf", + "uploadKey": "key-123", + "fileSize": 102400, + } + + t.Run("json", func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" { + t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool) + } + if !reflect.DeepEqual(caller.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["fileId"] != "file-abc" { + t.Fatalf("unified data = %#v", envelope["data"]) + } + }) +} + +func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}}, + {name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}}, + {name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}}, + {name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}}, + {name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}}, + {name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}}, + {name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, test.args...) + if err == nil { + t.Fatalf("%s unexpectedly succeeded", test.name) + } + if caller.calls != 0 { + t.Fatalf("missing required flag made %d MCP call(s)", caller.calls) + } + }) + } +} diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 332d8649..bb9eb775 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -34,6 +34,8 @@ Flags: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` +- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` +- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` #### 获取审批附件临时下载链接 @@ -78,6 +80,36 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 +#### 初始化审批附件上传 + +``` +Usage: + dws oa approval attachment init-upload [flags] +Example: + dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json +Flags: + --file-name string 完整文件名,例如 合同.pdf (必填) + --file-size int 文件字节数,必须大于等于 0 (必填) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) +``` + +该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 + +#### 提交附件上传信息 + +``` +Usage: + dws oa approval attachment commit-upload [flags] +Example: + dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json +Flags: + --file-name string 完整文件名,例如 合同.pdf (必填) + --upload-key string 初始化接口返回的 uploadKey (必填) + --file-size int 文件字节数,必须大于等于 0 (必填) +``` + +该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -721,6 +753,8 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) +用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 0f218814..42581359 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -38,6 +38,8 @@ Flags: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` +- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` +- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` #### 获取审批附件临时下载链接 @@ -82,6 +84,36 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 +#### 初始化审批附件上传 + +``` +Usage: + dws oa approval attachment init-upload [flags] +Example: + dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json +Flags: + --file-name string 完整文件名,例如 合同.pdf (必填) + --file-size int 文件字节数,必须大于等于 0 (必填) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) +``` + +该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 + +#### 提交附件上传信息 + +``` +Usage: + dws oa approval attachment commit-upload [flags] +Example: + dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json +Flags: + --file-name string 完整文件名,例如 合同.pdf (必填) + --upload-key string 初始化接口返回的 uploadKey (必填) + --file-size int 文件字节数,必须大于等于 0 (必填) +``` + +该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -724,6 +756,8 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) +用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go index cd5f7cd7..25dd8b64 100644 --- a/test/unit/oa_attachment_delivery_policy_test.go +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -37,6 +37,8 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { "download-url": "get_attachment_download_url", "authorize-download": "auth_download_file", "authorize-preview": "auth_preview_attachment", + "init-upload": "init_attachment_upload_info", + "commit-upload": "commit_attachment_upload_info", } gotTools := make(map[string]string, len(wantTools)) foundConstructor := false @@ -78,6 +80,8 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { "dws oa approval attachment download-url", "dws oa approval attachment authorize-download", "dws oa approval attachment authorize-preview", + "dws oa approval attachment init-upload", + "dws oa approval attachment commit-upload", "临时下载链接", "最多 10", "最多 20", From 2c0d6e4118fed362b19d8b5d20a5879901b8a046 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 10:25:13 +0800 Subject: [PATCH 194/267] chore: prepare v1.0.59-beta.4 changelog --- .../1045-attendance-mail-shortcuts.md | 0 .../1058-chat-group-role-single-flag.md | 0 .../1.0.59-beta.4}/976-recruit-job.md | 0 .../996-canonical-agent-skills.md | 0 .../chat-user-mention-tokens.md | 0 .../command-path-migration-governance.md | 0 .../oa-approval-list-by-admin.md | 0 CHANGELOG.md | 33 +++++++++++++++++++ 8 files changed, 33 insertions(+) rename .changes/{ => released/1.0.59-beta.4}/1045-attendance-mail-shortcuts.md (100%) rename .changes/{ => released/1.0.59-beta.4}/1058-chat-group-role-single-flag.md (100%) rename .changes/{ => released/1.0.59-beta.4}/976-recruit-job.md (100%) rename .changes/{ => released/1.0.59-beta.4}/996-canonical-agent-skills.md (100%) rename .changes/{ => released/1.0.59-beta.4}/chat-user-mention-tokens.md (100%) rename .changes/{ => released/1.0.59-beta.4}/command-path-migration-governance.md (100%) rename .changes/{ => released/1.0.59-beta.4}/oa-approval-list-by-admin.md (100%) diff --git a/.changes/1045-attendance-mail-shortcuts.md b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md similarity index 100% rename from .changes/1045-attendance-mail-shortcuts.md rename to .changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md diff --git a/.changes/1058-chat-group-role-single-flag.md b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md similarity index 100% rename from .changes/1058-chat-group-role-single-flag.md rename to .changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md diff --git a/.changes/976-recruit-job.md b/.changes/released/1.0.59-beta.4/976-recruit-job.md similarity index 100% rename from .changes/976-recruit-job.md rename to .changes/released/1.0.59-beta.4/976-recruit-job.md diff --git a/.changes/996-canonical-agent-skills.md b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md similarity index 100% rename from .changes/996-canonical-agent-skills.md rename to .changes/released/1.0.59-beta.4/996-canonical-agent-skills.md diff --git a/.changes/chat-user-mention-tokens.md b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md similarity index 100% rename from .changes/chat-user-mention-tokens.md rename to .changes/released/1.0.59-beta.4/chat-user-mention-tokens.md diff --git a/.changes/command-path-migration-governance.md b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md similarity index 100% rename from .changes/command-path-migration-governance.md rename to .changes/released/1.0.59-beta.4/command-path-migration-governance.md diff --git a/.changes/oa-approval-list-by-admin.md b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md similarity index 100% rename from .changes/oa-approval-list-by-admin.md rename to .changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d8f7b..4377f4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.4] - 2026-08-20 + +### Added + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. + +### Changed + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. + +### Fixed + +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. + + ## [1.0.59-beta.3] - 2026-08-19 ### Added From 103b05413ee40196a54c2ebf30438c896bc98086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 10:40:35 +0800 Subject: [PATCH 195/267] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E7=9B=B8=E5=85=B3dws=20help=E8=A1=A5=E5=85=85=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/helpers/oa.go | 2 +- skills/mono/references/products/oa.md | 2 +- skills/multi/dingtalk-misc/references/oa.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index c7bd865d..4326b493 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -152,7 +152,7 @@ func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error { func newOAAttachmentCommand() *cobra.Command { attachmentCmd := &cobra.Command{ Use: "attachment", - Short: "审批附件授权与下载链接", + Short: "审批附件授权、上传、下载与链接管理", RunE: groupRunE, } diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index bb9eb775..a0cb242b 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -27,7 +27,7 @@ Flags: --instance-id string 审批实例 ID (必填) ``` -### 审批附件授权与下载 +### 审批附件授权、上传与下载 先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 42581359..959fb48f 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -31,7 +31,7 @@ Flags: --instance-id string 审批实例 ID (必填) ``` -### 审批附件授权与下载 +### 审批附件授权、上传与下载 先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: From 7a019c6fa37916f48ca70db9241f7c542cdaefb1 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 10:46:17 +0800 Subject: [PATCH 196/267] fix(ci): align multi-profile skill paths --- scripts/dev/test-multi-profile-e2e.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh index 50cba792..d24371b7 100755 --- a/scripts/dev/test-multi-profile-e2e.sh +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -638,13 +638,13 @@ MONO_HOME="$WORKDIR/home-mono" MULTI_HOME="$WORKDIR/home-multi" mkdir -p "$MONO_HOME" "$MULTI_HOME" capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId" -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName" capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" log "verifying empty profile list" capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json From da049be58dd708f2546c0004d6bd0b46d3d6b78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Thu, 20 Aug 2026 11:21:58 +0800 Subject: [PATCH 197/267] docs(skill): require terminal evidence for doc writes --- skills/multi/dingtalk-doc/references/contracts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md index e64f5650..544382e1 100644 --- a/skills/multi/dingtalk-doc/references/contracts.md +++ b/skills/multi/dingtalk-doc/references/contracts.md @@ -18,7 +18,7 @@ 进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。 -优先级从高到低为:稳定返回 ID/任务 ID → 写后读回匹配 → 明确服务端终态 → 仅 transport/退出码。只有前三类中与该操作匹配的证据足够时才报告成功;Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。 +稳定返回 ID/任务 ID 只用于定位目标、恢复流程或继续查询,不能单独证明写操作成功。成功证据优先级从高到低为:与该操作匹配的明确服务端成功终态 → 契约要求的写后读回匹配 → 仅 transport/退出码;异步任务必须使用返回的任务 ID 查询到成功终态。只有成功终态成立且所有必要回读均匹配时才报告成功;`partial_success`、`unknown`、未完成任务或仅返回资源/任务 ID 都不得报告完成。Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。 ## 分页与完整性 From e40397e239f25e1bc9b443197906871e6aecefee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Thu, 20 Aug 2026 11:34:14 +0800 Subject: [PATCH 198/267] docs(skill): restore bounded doc guidance --- skills/multi/dingtalk-doc/SKILL.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 2855adf2..5e67eff3 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-doc -description: 钉钉在线文字文档(adoc)的查找、创建、读取、编辑、块、评论、媒体、导入导出、版本、模板、权限与分享;本地文件转在线文档、“传上去后在线改”或协作编辑归 dingtalk-doc。保留原文件及文件夹管理归 dingtalk-drive,知识库节点归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。命令前缀:dws doc。 +description: 钉钉在线文字文档(adoc)的查找创建、读写、文档信息、白板卡片、附件、评论、媒体、导入导出、版本、模板、权限分享;本地文件转在线文档或协作编辑归 dingtalk-doc。原文件/文件夹归 dingtalk-drive,知识库归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。前缀:dws doc。 metadata: cli_version: ">=0.2.14" category: product @@ -51,7 +51,7 @@ metadata: | 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` | | 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog | | 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 | -| 搜索模板 | `dws doc +template-search --query <关键词> [--source MY\|PUBLIC]` | 继承来源;零命中停止,禁止拿无关模板替代;多候选消歧 | +| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 | | 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search | | 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` | | 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 | @@ -70,10 +70,10 @@ metadata: ## 参数与安全边界 -- `@file`:文件暂存 cwd 后传 `@相对路径`;生成文本优先 `--content -`;禁绝对路径和 `..`。 +- `@file`:已有或临时文件先暂存到 cwd;传 `@相对路径`,禁绝对路径和 `..`。 - `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。 -- Schema 门禁:路由/参数/安全语义明确时直用;只有 leaf 参数、确认或契约漂移时查一次精确 compact Schema;禁用产品级/`--all`。 -- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测。 +- Schema 门禁:不确定时仅查一次精确 leaf:`--fields use_when,avoid_when,parameters,constraints,confirmation`;禁用产品级/`--all`。准备 Help 时,本轮仅查一次。 +- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测门禁。 - JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。 ## 按需加载 @@ -84,7 +84,7 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余 |---|---| | 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 | | 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) | -| 长文创作、局部读取或精准改写策略 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) | +| 复杂 JSONML、长文或局部精准读写 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) | | block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) | | 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) | @@ -101,7 +101,7 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余 ## 跨产品边界 -- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`;文档空间原文件用 `drive upload --workspace`,在线协作转换用 `doc +import --workspace` +- 普通文件/目录/纯上传下载/节点权限 → `dingtalk-drive`;保留原文件用原子命令 `dws drive upload --workspace`,在线转换用 `doc +import --workspace` - 知识库空间、节点层级和成员管理 → `dingtalk-wiki` - 原生 `.md` 文件读取和编辑 → `dingtalk-misc` - `axls` / `able` → 对应电子表格或多维表 Skill From 80d5d2463756810cdb21056a1963409707ff5bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 20 Aug 2026 11:39:50 +0800 Subject: [PATCH 199/267] Revert "docs(chat): trim chat skill context budget" This reverts commit c5951a10ff04503b824fb9387de962063270a6fc. --- skills/multi/dingtalk-chat/SKILL.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index a77945a6..cf63ec02 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-chat -description: 钉钉群聊与消息。Use when 用户提到发消息、单聊/群聊、群管理、@消息、搜索记录、话题回复、收藏/置顶、机器人群发、Webhook、收发图片与文件。不做紧急 DING/短信/电话、邮件、班级群。前缀:dws chat。 +description: 钉钉群聊与消息。Use when 用户提到 发消息/编辑或撤回消息/单聊/群聊/建群/普通群升级外部群/群昵称/会话分组/群成员管理/@消息/搜索聊天记录/话题回复/收藏消息/机器人群发/Webhook通知/发送或下载消息图片与文件。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。 metadata: cli_version: ">=0.2.14" category: product @@ -35,7 +35,7 @@ metadata: ## Golden Route -按任务选择最小充分入口;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用。 +按用户任务选择最小充分入口。公开层按意图分流;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用,不把所有能力塞进一个万能命令。 | 用户意图 | 唯一推荐入口 | 关键边界 | |---|---|---| @@ -47,10 +47,10 @@ metadata: | 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | | 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | | 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | -| 查看个人收藏表情 | `dws chat emotion list` | MCP `im/list_personal_emotions`;入参 `{}`,当前用户由 server 注入 | -| 发送个人收藏表情 | `dws chat emotion send --media-id ` | MCP `im/send_personal_emotion`;`--emotion-id` 可选;目标 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 解析为 openDingTalkId;`--uuid/--idempotency-key` -> `uuid` | -| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | MCP `im/favorite_personal_emotion`;`--name` 可选;`--source-conversation-id/--source-message-id` 成对 | -| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | +| 查看个人收藏表情 | `dws chat emotion list` | 当前用户身份由 MCP server 注入 | +| 发送个人收藏表情 | `dws chat emotion send --media-id --group ` | 目标为 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 会解析为 openDingTalkId | +| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | `--source-conversation-id` 与 `--source-message-id` 必须成对指定 | +| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | | 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 | @@ -73,22 +73,22 @@ metadata: ### 发送入口边界 -- `+dm`:姓名目标的简单文本/Markdown。 -- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown。 +- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。 +- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。 - Markdown 中的公网图片必须写成 `![图片标题](https://example.com/image.png)` 才会内联展示; 省略开头的 `!` 时只会显示为链接。 -- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。已知 ID 可直传,也可用 `--user-query` / `--chat-query` 解析;Bot 多群用 `--groups/--groups-file`,返回 `im.batch-write.v1`。 +- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 - 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 - Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 -- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`;禁写占位符;仅 text。 +- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`,Runtime 把 create 返回前缀加到 `--content`;禁写占位符;仅 text。 ## 关键结果语义 - `openTaskId` 是发送任务 ID,不是回复或撤回所需的消息 ID;消息 ID 必须来自真实查询结果。 -- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`。 +- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`;`--no-reactions` 可关闭 reaction。 - 查询结果必须检查 `complete`、`hasMore`、`failures` 和资源下载 ledger;partial result 不得表述为完整成功。 - 子消息使用自己的 `messageId`;仅缺会话 ID 时继承父消息的 `conversationId`。 -- 下载只允许工作目录内安全相对路径,默认不覆盖;覆盖必须显式传 `--overwrite`。读取和下载不需要 `--yes`。 +- 下载只允许工作目录内安全相对路径,默认不覆盖并原子落盘;覆盖必须由用户显式传 `--overwrite`。读取和下载不需要 `--yes`。 - Favorite、消息 Pin、消息 Top、会话 Top 是不同对象层级,不能互换。 ## 按需加载 From b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:42:00 +0000 Subject: [PATCH 200/267] chore: update beta formula for v1.0.59-beta.4 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 90ab9635..285d2254 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.59-beta.3" + version "1.0.59-beta.4" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz" - sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz" + sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz" - sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz" + sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz" - sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz" + sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz" - sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz" + sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip" - sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip" + sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435" end def install From 3922970bfc410de44f1d9e23649a0cd79802a6f2 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 11:00:13 +0800 Subject: [PATCH 201/267] fix(policy): preserve schema migration lineage --- .github/workflows/release.yml | 6 +- docs/cli-interface-flag-migrations.md | 11 + docs/releasing.md | 6 +- ...heck-authoritative-schema-compatibility.sh | 32 +- .../schema-compat/command_migrations_test.go | 698 ++++++++++++++++++ scripts/policy/schema-compat/main.go | 412 ++++++++++- .../authority_integration_test.go | 47 +- .../release/check-release-compatibility.sh | 122 +++ scripts/release/release.sh | 18 +- test/scripts/package_script_test.go | 16 +- test/scripts/release_script_test.go | 239 +++++- 11 files changed, 1554 insertions(+), 53 deletions(-) create mode 100755 scripts/release/check-release-compatibility.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ee47b14..774495a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2698,9 +2698,11 @@ jobs: ;; compatibility) test -n "$PREVIOUS_STABLE" - ./scripts/policy/check-command-compatibility.sh \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \ + --repo-root "$GITHUB_WORKSPACE" \ --base-ref HEAD \ - --stable-ref "$PREVIOUS_STABLE" + --stable-ref "$PREVIOUS_STABLE" \ + --candidate-ref HEAD ;; e2e) bash scripts/dev/test-multi-profile-e2e.sh diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 60c5b684..81477cd5 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。 `replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即 `true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。 +如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter +只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且 +`state=consumed` 的 flag rename 回执作为前驱边。例如 +`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把 +candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。 +首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数, +并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main +消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after +并按 lifecycle 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、 +target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。 + `replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot 必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、 值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create` diff --git a/docs/releasing.md b/docs/releasing.md index 428cdbcc..c8d6c702 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,7 +23,7 @@ `plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。 -为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。 +为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。 OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。 @@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR dws-release v1.2.3-beta.1 ``` -预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。 +预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。 ## 正式发布 @@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released//` - 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。 - tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。 -- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 +- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 - GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。 - 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。 - stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。 diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 45441cfd..f7a5b5b3 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json" go build -o "$CANDIDATE_BIN" ./cmd ) +CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false +if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then + CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true +fi + mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home" HOME="$TMP_ROOT/base-home" DWS_LANG=zh \ "$BASE_BIN" schema --all --format json >"$BASE_RAW" @@ -468,23 +473,22 @@ check_with_migrations() { historical_kind="$1" historical_ref="$2" historical_baseline="$3" + set -- \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then - check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + set -- "$@" \ --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ - --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" - else - check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" fi + if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] && + [ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then + set -- "$@" --migration-base-schema "$BASELINE" + fi + check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@" } check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE" diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index ada5dcc7..5c6f32fb 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -233,6 +233,53 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin t.Fatalf("constraint drift was hidden: %s", failures) } + unchangedLegacyConstraints := cloneContract(current) + product = unchangedLegacyConstraints.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints + product.Tools["chat.move"] = tool + unchangedLegacyConstraints.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, unchangedLegacyConstraints, migrations); err == nil || + !strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") { + t.Fatalf("unchanged legacy constraints error=%v", err) + } + + mixedLegacyConstraints := cloneContract(current) + product = mixedLegacyConstraints.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = `{"require_together":[["keep","new-id","old-id"]]}` + product.Tools["chat.move"] = tool + mixedLegacyConstraints.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, mixedLegacyConstraints, migrations); err == nil || + !strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") { + t.Fatalf("mixed legacy constraints error=%v", err) + } + + malformedHistoricalConstraints := cloneContract(baseline) + product = malformedHistoricalConstraints.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = "{" + product.Tools["chat.move"] = tool + malformedHistoricalConstraints.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(malformedHistoricalConstraints, current, migrations); err == nil || + !strings.Contains(err.Error(), "historical Schema constraints are not canonicalizable") { + t.Fatalf("malformed historical constraints error=%v", err) + } + + malformedCurrentConstraints := cloneContract(current) + product = malformedCurrentConstraints.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = "{" + product.Tools["chat.move"] = tool + malformedCurrentConstraints.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, malformedCurrentConstraints, migrations); err == nil || + !strings.Contains(err.Error(), "current Schema constraints are not canonicalizable") { + t.Fatalf("malformed current constraints error=%v", err) + } + if source, found := migratedConstraintSourceParameter("{", map[string]string{"legacy": "canonical"}); found || source != "" { + t.Fatalf("malformed migrated constraint source = %q, %v", source, found) + } + extractionWrongSource := cloneContract(current) product = extractionWrongSource.Products["chat"] tool = product.Tools["chat.create_group"] @@ -657,6 +704,508 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T } } +func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineage(t *testing.T) { + directory := t.TempDir() + stableContract, baseContract, currentContract := schemaCommandLineageContracts() + baselinePath := filepath.Join(directory, "stable-schema.json") + baseSchemaPath := filepath.Join(directory, "base-schema.json") + currentPath := filepath.Join(directory, "current-schema.json") + approvedFlagPath := filepath.Join(directory, "approved-flags.json") + candidateFlagPath := filepath.Join(directory, "candidate-flags.json") + approvedCommandPath := filepath.Join(directory, "approved-commands.json") + candidateCommandPath := filepath.Join(directory, "candidate-commands.json") + currentSnapshotPath := filepath.Join(directory, "current-snapshot.json") + baseSnapshotPath := filepath.Join(directory, "base-snapshot.json") + stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json") + + writeSchemaContractFile(t, baselinePath, stableContract) + writeSchemaContractFile(t, baseSchemaPath, baseContract) + writeRawSchemaContractFile(t, currentPath, currentContract) + writeFlagMigrationManifestFile(t, approvedFlagPath, schemaCommandLineageFlagManifest()) + writeFlagMigrationManifestFile(t, candidateFlagPath, schemaCommandLineageFlagManifest()) + writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending)) + writeCommandMigrationManifestFile(t, candidateCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandLineageSnapshot(true, true)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, false)) + writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandLineageSnapshot(false, false)) + + args := []string{ + "--check", baselinePath, + "--current", currentPath, + "--migration-base-schema", baseSchemaPath, + "--approved-flag-migrations", approvedFlagPath, + "--candidate-flag-migrations", candidateFlagPath, + "--approved-command-migrations", approvedCommandPath, + "--candidate-command-migrations", candidateCommandPath, + "--migration-current-snapshot", currentSnapshotPath, + "--migration-base-snapshot", baseSnapshotPath, + "--migration-stable-snapshot", stableSnapshotPath, + } + var stdout, stderr strings.Builder + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("pending command lineage code=%d stderr=%s", code, stderr.String()) + } + + withoutBaseSchema := append([]string(nil), args[:4]...) + withoutBaseSchema = append(withoutBaseSchema, args[6:]...) + stderr.Reset() + if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), "requires --migration-base-schema") { + t.Fatalf("missing migration base Schema code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{ + "--check", baselinePath, + "--current", currentPath, + "--migration-base-schema", baseSchemaPath, + }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") { + t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String()) + } + missingBaseSchema := append([]string(nil), args...) + missingBaseSchema[5] = filepath.Join(directory, "missing-base-schema.json") + stderr.Reset() + if code := run(missingBaseSchema, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), "read migration merge-base Schema contract") { + t.Fatalf("unreadable migration base Schema code=%d stderr=%q", code, stderr.String()) + } + + // After the command move is merged, the merge-base Schema is already at the + // final name. The two consumed receipts must keep the stable lineage durable + // until the stable release itself reaches the after state. + writeSchemaContractFile(t, baseSchemaPath, currentContract) + writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, true)) + stdout.Reset() + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("consumed command lineage code=%d stderr=%s", code, stderr.String()) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLineageFailsClosed(t *testing.T) { + tests := []struct { + name string + commandState string + mutate func(*schemaContract, *schemaContract, *schemaContract, *[]interfacesnapshot.FlagMigration, *[]interfacesnapshot.CommandMigration) + want string + }{ + { + name: "pending merge-base missing intermediate", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + delete(tool.Parameters, "conversation-id") + }) + }, + want: "merge-base Schema lacks intermediate parameter", + }, + { + name: "merge-base missing source tool", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + product := base.Products["chat"] + delete(product.Tools, "chat.list_topic_replies") + base.Products["chat"] = product + }, + want: "merge-base Schema lacks source tool", + }, + { + name: "pending merge-base parameter drift", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "conversation-id", func(parameter *parameterSchema) { + parameter.Property = "differentProperty" + }) + }, + want: "changed a non-migration field", + }, + { + name: "pending merge-base wrong path", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.PrimaryCLIPath = "chat unrelated" + }) + }, + want: "merge-base Schema source tool has primary_cli_path", + }, + { + name: "pending merge-base already publishes final", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["open-topic-id"] = tool.Parameters["conversation-id"] + }) + }, + want: "already publishes final parameter", + }, + { + name: "pending flag receipt", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + (*flags)[0].State = interfacesnapshot.FlagMigrationPending + }, + want: "requires a consumed flag migration receipt", + }, + { + name: "flag receipt belongs to another command", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + (*flags)[0].Command = "dws chat message other" + }, + want: `historical Schema tool lacks parameter "conversation-id"`, + }, + { + name: "current retains predecessor", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(stable, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"] + }) + }, + want: "current Schema still publishes predecessor parameter", + }, + { + name: "pending current constraints retain predecessor", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}` + }) + }, + want: "current Schema constraints still reference predecessor parameter", + }, + { + name: "consumed current constraints retain predecessor", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}` + }) + }, + want: "current Schema constraints still reference predecessor parameter", + }, + { + name: "current retains intermediate", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["conversation-id"] = base.Products["chat"].Tools["chat.list_topic_replies"].Parameters["conversation-id"] + }) + }, + want: `still publishes legacy Schema parameter "conversation-id"`, + }, + { + name: "consumed merge-base final drift", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "open-topic-id", func(parameter *parameterSchema) { + parameter.Required = false + }) + }, + want: "changed a non-name field", + }, + { + name: "consumed merge-base wrong path", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.PrimaryCLIPath = "chat unrelated" + }) + }, + want: "consumed command migration", + }, + { + name: "consumed merge-base missing final", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + delete(tool.Parameters, "open-topic-id") + }) + }, + want: "merge-base Schema lacks final parameter", + }, + { + name: "consumed merge-base retains intermediate", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["conversation-id"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"] + }) + }, + want: "still publishes intermediate parameter", + }, + { + name: "pending constraints drift", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["conversation-id"]]}` + }) + }, + want: "changed merge-base Schema constraints", + }, + { + name: "pending current keeps intermediate constraints", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = base.Products["chat"].Tools["chat.list_topic_replies"].Constraints + }) + }, + want: "still reference legacy Schema constraint parameter", + }, + { + name: "merge-base retains predecessor", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"] + }) + }, + want: "merge-base Schema still publishes predecessor parameter", + }, + { + name: "historical constraints malformed", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = "not-json" + }) + }, + want: "historical Schema constraints are not canonicalizable", + }, + { + name: "merge-base constraints malformed", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = "not-json" + }) + }, + want: "merge-base Schema constraints are not canonicalizable", + }, + { + name: "consumed constraints drift", + commandState: interfacesnapshot.CommandMigrationConsumed, + mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["open-topic-id"]]}` + }) + }, + want: "changed merge-base Schema constraints", + }, + { + name: "target collision", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["open-topic-id"] = tool.Parameters["group"] + }) + }, + want: "target \"open-topic-id\" already exists", + }, + { + name: "lineage cycle", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) { + (*commands)[0].Schema.Parameters[0].To = "group" + }, + want: "lineage cycle", + }, + { + name: "unsupported command receipt state", + commandState: "unknown", + mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + }, + want: "unsupported lineage state", + }, + { + name: "duplicate historical path", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) { + product := stable.Products["chat"] + product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"] + stable.Products["chat"] = product + }, + want: "matches 2 historical Schema tools", + }, + { + name: "source tool fork", + commandState: interfacesnapshot.CommandMigrationPending, + mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) { + fork := (*commands)[0] + fork.Legacy.Command = "dws chat message fork" + fork.Replacement.Command = "dws chat topic fork" + *commands = append(*commands, fork) + }, + want: "fork Schema source tool", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stable, baseBefore, current := schemaCommandLineageContracts() + base := baseBefore + if test.commandState == interfacesnapshot.CommandMigrationConsumed { + base = cloneContract(current) + } + flags := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...) + commands := append([]interfacesnapshot.CommandMigration(nil), schemaCommandLineageManifest(test.commandState).Migrations...) + test.mutate(&stable, &base, ¤t, &flags, &commands) + if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("lineage error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) { + stable, base, current := schemaCommandLineageContracts() + mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}} + }) + normalized, err := normalizeSchemaCommandMigrationLineage( + stable, + base, + current, + schemaCommandLineageFlagManifest().Migrations, + schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations, + ) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") { + t.Fatalf("lineage hid positional drift: %s", failures) + } + + // Multiple historical names may converge only when every predecessor carries + // the exact same contract and every receipt is already consumed. + stable, base, current = schemaCommandLineageContracts() + mutateSchemaCommandMigrationTool(&stable, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["id"] = tool.Parameters["group"] + }) + flags := schemaCommandLineageFlagManifest().Migrations + second := flags[0] + second.Legacy.Name = "id" + flags = append(flags, second) + normalized, err = normalizeSchemaCommandMigrationLineage( + stable, + base, + current, + flags, + schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations, + ) + if err != nil { + t.Fatalf("equivalent predecessor aliases should pass: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("equivalent predecessor aliases remained incompatible: %v", failures) + } + + mutateSchemaCommandMigrationParameter(&stable, "chat.list_topic_replies", "id", func(parameter *parameterSchema) { + parameter.Property = "differentProperty" + }) + if _, err := normalizeSchemaCommandMigrationLineage( + stable, + base, + current, + flags, + schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations, + ); err == nil || !strings.Contains(err.Error(), "changed a non-migration field") { + t.Fatalf("drifted predecessor alias error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLineageDefensiveEdges(t *testing.T) { + stable, base, current := schemaCommandLineageContracts() + flags := schemaCommandLineageFlagManifest().Migrations + commands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations + + flagExtraction := schemaCommandMigrationAuthorizations()[1] + if _, err := stageSchemaCommandMigrationPredecessors(stable, base, current, flags, []interfacesnapshot.CommandMigration{flagExtraction}); err != nil { + t.Fatalf("non-move command migration should be ignored: %v", err) + } + + missingTool := cloneContract(stable) + product := missingTool.Products["chat"] + delete(product.Tools, "chat.list_topic_replies") + missingTool.Products["chat"] = product + if _, err := stageSchemaCommandMigrationPredecessors(missingTool, base, current, flags, commands); err != nil { + t.Fatalf("missing historical source should remain for the ordinary checker: %v", err) + } + if _, err := stageSchemaCommandMigrationPredecessors(current, current, current, flags, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed).Migrations); err != nil { + t.Fatalf("already-after historical source should be a no-op: %v", err) + } + wrongPath := cloneContract(stable) + mutateSchemaCommandMigrationTool(&wrongPath, "chat.list_topic_replies", func(tool *toolSchema) { + tool.PrimaryCLIPath = "chat unrelated" + }) + if _, err := stageSchemaCommandMigrationPredecessors(wrongPath, base, current, flags, commands); err != nil { + t.Fatalf("wrong historical path should remain for the command normalizer: %v", err) + } + + bothNames := cloneContract(stable) + mutateSchemaCommandMigrationTool(&bothNames, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["conversation-id"] = tool.Parameters["group"] + }) + if _, err := stageSchemaCommandMigrationPredecessors(bothNames, base, current, flags, commands); err == nil || + !strings.Contains(err.Error(), "publishes both predecessor") { + t.Fatalf("ambiguous predecessor error=%v", err) + } + + duplicatePath := cloneContract(stable) + product = duplicatePath.Products["chat"] + product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"] + duplicatePath.Products["chat"] = product + if _, err := stageSchemaCommandMigrationPredecessors(duplicatePath, base, current, flags, commands); err == nil || + !strings.Contains(err.Error(), "requires one exact historical Schema tool") { + t.Fatalf("duplicate primary path error=%v", err) + } + + forkStable, forkBase, forkCurrent := schemaCommandLineageContracts() + mutateSchemaCommandMigrationTool(&forkBase, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["other-mid"] = tool.Parameters["conversation-id"] + }) + mutateSchemaCommandMigrationTool(&forkCurrent, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["other-final"] = tool.Parameters["open-topic-id"] + }) + forkFlags := append([]interfacesnapshot.FlagMigration(nil), flags...) + forkFlag := flags[0] + forkFlag.Canonical.Name = "other-mid" + forkFlags = append(forkFlags, forkFlag) + forkCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations + forkCommands[0].Schema.Parameters = append(forkCommands[0].Schema.Parameters, + interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"}) + if _, err := stageSchemaCommandMigrationPredecessors(forkStable, forkBase, forkCurrent, forkFlags, forkCommands); err == nil || + !strings.Contains(err.Error(), "forks Schema predecessor") { + t.Fatalf("forked predecessor error=%v", err) + } + + cycleStable, cycleBase, _ := schemaCommandLineageContracts() + mutateSchemaCommandMigrationTool(&cycleStable, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["id"] = tool.Parameters["group"] + }) + mutateSchemaCommandMigrationTool(&cycleBase, "chat.list_topic_replies", func(tool *toolSchema) { + tool.Parameters["other-mid"] = tool.Parameters["conversation-id"] + }) + cycleFlags := append([]interfacesnapshot.FlagMigration(nil), flags...) + cycleFlag := flags[0] + cycleFlag.Legacy.Name = "id" + cycleFlag.Canonical.Name = "other-mid" + cycleFlags = append(cycleFlags, cycleFlag) + cycleCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations + cycleCommands[0].Schema.Parameters[0].To = "id" + cycleCommands[0].Schema.Parameters = append(cycleCommands[0].Schema.Parameters, + interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"}) + missingCurrent := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}} + if _, err := stageSchemaCommandMigrationPredecessors(cycleStable, cycleBase, missingCurrent, cycleFlags, cycleCommands); err == nil || + !strings.Contains(err.Error(), "lineage cycle") { + t.Fatalf("cross-lineage cycle error=%v", err) + } +} + func schemaCommandMigrationContract(after bool) schemaContract { id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} keep := parameterSchema{Type: `"string"`, Property: "keep"} @@ -722,6 +1271,155 @@ func schemaCommandMigrationContract(after bool) schemaContract { return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}} } +func schemaCommandLineageContracts() (schemaContract, schemaContract, schemaContract) { + conversation := parameterSchema{ + Type: `"string"`, + Property: "openconversationId", + InterfaceType: "string", + Required: true, + CLIRequired: true, + } + topic := parameterSchema{ + Type: `"string"`, + Property: "openConversationThreadId", + InterfaceType: "string", + Required: true, + CLIRequired: true, + } + tool := toolSchema{ + PrimaryCLIPath: "chat message list-topic-replies", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"im","rpc_name":"list_topic_replies"}`, + Availability: "available", + Parameters: map[string]parameterSchema{ + "group": conversation, + "topic-id": topic, + }, + Constraints: `{"require_together":[["group","topic-id"]]}`, + Effect: "read", + Risk: "low", + Confirmation: "not_required", + Idempotency: "idempotent", + } + stable := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{ + "chat": {Tools: map[string]toolSchema{"chat.list_topic_replies": tool}}, + }} + + base := cloneContract(stable) + mutateSchemaCommandMigrationTool(&base, "chat.list_topic_replies", func(tool *toolSchema) { + delete(tool.Parameters, "group") + tool.Parameters["conversation-id"] = conversation + tool.Constraints = `{"require_together":[["conversation-id","topic-id"]]}` + }) + + current := cloneContract(base) + mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) { + delete(tool.Parameters, "conversation-id") + delete(tool.Parameters, "topic-id") + tool.Parameters["open-topic-id"] = conversation + tool.Parameters["open-conv-thread-id"] = topic + tool.PrimaryCLIPath = "chat topic list-replies" + tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}` + }) + return stable, base, current +} + +func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest { + beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"} + afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"} + return interfacesnapshot.FlagMigrationManifest{ + Version: interfacesnapshot.FlagMigrationManifestVersion, + Migrations: []interfacesnapshot.FlagMigration{{ + Command: "dws chat message list-topic-replies", + Legacy: interfacesnapshot.FlagMigrationSide{ + Name: "group", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"}, + }, + Canonical: interfacesnapshot.FlagMigrationSide{ + Name: "conversation-id", + Before: beforeCanonical, + After: afterCanonical, + }, + State: interfacesnapshot.FlagMigrationConsumed, + Reason: "preserve the reviewed group to conversation-id lineage", + }}, + } +} + +func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest { + return interfacesnapshot.CommandMigrationManifest{ + Version: interfacesnapshot.CommandMigrationManifestVersion, + Migrations: []interfacesnapshot.CommandMigration{{ + Kind: interfacesnapshot.CommandMigrationMove, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat message list-topic-replies", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic list-replies", + Before: interfacesnapshot.CommandMigrationState{}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.list_topic_replies", + ReplacementToolID: "chat.list_topic_replies", + Parameters: []interfacesnapshot.CommandParameterMigration{ + {From: "conversation-id", To: "open-topic-id"}, + {From: "topic-id", To: "open-conv-thread-id"}, + }, + }, + State: state, + Reason: "move topic reply listing while retaining the legacy command", + }}, + } +} + +func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot { + legacyFlags := []interfacesnapshot.Flag{ + {Name: "conversation-id", Type: "string", Hidden: true}, + {Name: "group", Type: "string", Required: true}, + {Name: "topic-id", Type: "string", Required: true}, + } + if flagAfter { + legacyFlags[0].Hidden = false + legacyFlags[0].Required = true + legacyFlags[1].Required = false + legacyFlags[1].Hidden = true + legacyFlags[1].AliasOf = "conversation-id" + } + commands := []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + { + Path: "dws chat message list-topic-replies", + Runnable: true, + Aliases: []string{}, + LocalFlags: legacyFlags, + InheritedFlags: []interfacesnapshot.Flag{}, + }, + } + if commandAfter { + commands[1].Hidden = true + commands = append(commands, interfacesnapshot.Command{ + Path: "dws chat topic list-replies", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + }) + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"}, + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration { return []interfacesnapshot.CommandMigration{ { diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 7496d4f3..9aa151cc 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string var approvedFlagMigrationsPath, candidateFlagMigrationsPath string var approvedCommandMigrationsPath, candidateCommandMigrationsPath string + var migrationBaseSchemaPath string var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) flags.SetOutput(stderr) @@ -98,6 +99,7 @@ func run(args []string, stdout, stderr io.Writer) int { flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest") flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest") flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest") + flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage") flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization") flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization") flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization") @@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check") return 2 } + if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" { + fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema") + return 2 + } + if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) { + fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs") + return 2 + } if normalizePath != "" { currentPath = normalizePath @@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } + var flagMigrations []interfacesnapshot.FlagMigration if flagMigrationPair { - migrations, err := authorizeSchemaFlagMigrations( + flagMigrations, err = authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, migrationCurrentSnapshotPath, @@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err) return 2 } - baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations) - if err != nil { - fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err) - return 2 - } } if commandMigrationPair { - migrations, err := authorizeSchemaCommandMigrations( + commandMigrations, err := authorizeSchemaCommandMigrations( approvedCommandMigrationsPath, candidateCommandMigrationsPath, migrationCurrentSnapshotPath, @@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) return 2 } - baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations) + if flagMigrationPair { + migrationBase, readErr := readContract(migrationBaseSchemaPath) + if readErr != nil { + fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr) + return 2 + } + baseline, err = normalizeSchemaCommandMigrationLineage( + baseline, + migrationBase, + current, + flagMigrations, + commandMigrations, + ) + } else { + baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations) + } if err != nil { fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err) return 2 } + } else if flagMigrationPair { + baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations) + if err != nil { + fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err) + return 2 + } } failures := checkCompatibility(baseline, current) if len(failures) > 0 { @@ -1367,6 +1394,328 @@ func normalizeSchemaFlagMigrations( return normalized, nil } +// normalizeSchemaCommandMigrationLineage composes two independently reviewed +// migration receipts without inventing a second alias authority. A consumed +// flag migration may supply the historical predecessor of a command_move +// parameter, but only after the merge-base Schema or a retained consumed +// command receipt proves the corresponding next hop. +func normalizeSchemaCommandMigrationLineage( + historical schemaContract, + mergeBase schemaContract, + current schemaContract, + flagMigrations []interfacesnapshot.FlagMigration, + commandMigrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + normalized, err := normalizeSchemaFlagMigrations(historical, current, flagMigrations) + if err != nil { + return schemaContract{}, fmt.Errorf("normalize ordinary flag migrations: %w", err) + } + staged, err := stageSchemaCommandMigrationPredecessors( + normalized, + mergeBase, + current, + flagMigrations, + commandMigrations, + ) + if err != nil { + return schemaContract{}, err + } + return normalizeSchemaCommandMigrations(staged, current, commandMigrations) +} + +// stageSchemaCommandMigrationPredecessors replays only the name-changing edge +// recorded by a base-owned consumed flag migration. It leaves interface, +// safety, dry-run, and positional facts untouched so the ordinary checker +// remains authoritative for every non-name change. +func stageSchemaCommandMigrationPredecessors( + historical schemaContract, + mergeBase schemaContract, + current schemaContract, + flagMigrations []interfacesnapshot.FlagMigration, + commandMigrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + staged := cloneContract(historical) + moveBySource := map[schemaToolRef]string{} + for _, migration := range commandMigrations { + if migration.Kind != interfacesnapshot.CommandMigrationMove { + continue + } + ref := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID} + if previous, exists := moveBySource[ref]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migrations fork Schema source tool %q between %q and %q", + migration.Schema.SourceToolID, + previous, + migration.Legacy.Command, + ) + } + moveBySource[ref] = migration.Legacy.Command + } + + for _, migration := range commandMigrations { + if migration.Kind != interfacesnapshot.CommandMigrationMove { + continue + } + oldProduct, productExists := historical.Products[migration.Schema.ProductID] + oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !toolExists { + continue + } + legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ") + replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ") + if oldTool.PrimaryCLIPath == replacementPath { + continue + } + if oldTool.PrimaryCLIPath != legacyPath { + // Preserve the existing command normalizer's deterministic path error. + continue + } + + baseProduct, baseProductExists := mergeBase.Products[migration.Schema.ProductID] + baseTool, baseToolExists := baseProduct.Tools[migration.Schema.SourceToolID] + currentProduct, currentProductExists := current.Products[migration.Schema.ProductID] + currentTool, currentToolExists := currentProduct.Tools[migration.Schema.SourceToolID] + firstHopRenames := map[string]string{} + composedRenames := map[string]string{} + lineageApplied := false + + for _, parameter := range migration.Schema.Parameters { + if _, direct := oldTool.Parameters[parameter.From]; direct { + for _, flagMigration := range flagMigrations { + if flagMigration.Command != migration.Legacy.Command || + flagMigration.Canonical.Name != parameter.From { + continue + } + if _, legacyAlsoPublished := oldTool.Parameters[flagMigration.Legacy.Name]; legacyAlsoPublished { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema tool publishes both predecessor %q and intermediate %q", + migration.Legacy.Command, + flagMigration.Legacy.Name, + parameter.From, + ) + } + } + composedRenames[parameter.From] = parameter.To + continue + } + + predecessors := make([]interfacesnapshot.FlagMigration, 0, 1) + for _, flagMigration := range flagMigrations { + if flagMigration.Command != migration.Legacy.Command || + flagMigration.Canonical.Name != parameter.From { + continue + } + if flagMigration.State != interfacesnapshot.FlagMigrationConsumed { + return schemaContract{}, fmt.Errorf( + "approved command migration %q Schema predecessor %q -> %q requires a consumed flag migration receipt", + migration.Legacy.Command, + flagMigration.Legacy.Name, + parameter.From, + ) + } + if _, published := oldTool.Parameters[flagMigration.Legacy.Name]; published { + predecessors = append(predecessors, flagMigration) + } + } + if len(predecessors) == 0 { + continue + } + + lineageApplied = true + if !baseProductExists || !baseToolExists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q merge-base Schema lacks source tool %q", + migration.Legacy.Command, + migration.Schema.SourceToolID, + ) + } + var stagedParameter parameterSchema + switch migration.State { + case interfacesnapshot.CommandMigrationPending: + if baseTool.PrimaryCLIPath != legacyPath { + return schemaContract{}, fmt.Errorf( + "pending command migration %q merge-base Schema source tool has primary_cli_path %q", + migration.Legacy.Command, + baseTool.PrimaryCLIPath, + ) + } + intermediate, exists := baseTool.Parameters[parameter.From] + if !exists { + return schemaContract{}, fmt.Errorf( + "pending command migration %q merge-base Schema lacks intermediate parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := baseTool.Parameters[parameter.To]; exists { + return schemaContract{}, fmt.Errorf( + "pending command migration %q merge-base Schema already publishes final parameter %q", + migration.Legacy.Command, + parameter.To, + ) + } + stagedParameter = intermediate + case interfacesnapshot.CommandMigrationConsumed: + if baseTool.PrimaryCLIPath != replacementPath { + return schemaContract{}, fmt.Errorf( + "consumed command migration %q merge-base Schema source tool has primary_cli_path %q", + migration.Legacy.Command, + baseTool.PrimaryCLIPath, + ) + } + finalParameter, exists := baseTool.Parameters[parameter.To] + if !exists { + return schemaContract{}, fmt.Errorf( + "consumed command migration %q merge-base Schema lacks final parameter %q", + migration.Legacy.Command, + parameter.To, + ) + } + if _, exists := baseTool.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "consumed command migration %q merge-base Schema still publishes intermediate parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + stagedParameter = oldTool.Parameters[predecessors[0].Legacy.Name] + for _, predecessor := range predecessors { + oldParameter := oldTool.Parameters[predecessor.Legacy.Name] + composite := interfacesnapshot.CommandParameterMigration{From: predecessor.Legacy.Name, To: parameter.To} + if err := validateEquivalentCommandSchemaParameter(migration, composite, oldParameter, finalParameter); err != nil { + return schemaContract{}, err + } + } + default: + return schemaContract{}, fmt.Errorf( + "approved command migration %q has unsupported lineage state %q", + migration.Legacy.Command, + migration.State, + ) + } + + stagedProduct := staged.Products[migration.Schema.ProductID] + stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID] + for _, predecessor := range predecessors { + if predecessor.Legacy.Name == parameter.To { + return schemaContract{}, fmt.Errorf( + "approved command migration %q forms a Schema parameter lineage cycle through %q", + migration.Legacy.Command, + parameter.To, + ) + } + if existing, claimed := firstHopRenames[predecessor.Legacy.Name]; claimed && existing != parameter.From { + return schemaContract{}, fmt.Errorf( + "approved command migration %q forks Schema predecessor %q to both %q and %q", + migration.Legacy.Command, + predecessor.Legacy.Name, + existing, + parameter.From, + ) + } + oldParameter := oldTool.Parameters[predecessor.Legacy.Name] + if migration.State == interfacesnapshot.CommandMigrationPending { + if err := validateRenamedSchemaParameter(predecessor, oldParameter, stagedParameter); err != nil { + return schemaContract{}, err + } + } + if _, exists := baseTool.Parameters[predecessor.Legacy.Name]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q merge-base Schema still publishes predecessor parameter %q", + migration.Legacy.Command, + predecessor.Legacy.Name, + ) + } + if currentProductExists && currentToolExists { + if _, exists := currentTool.Parameters[predecessor.Legacy.Name]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q current Schema still publishes predecessor parameter %q", + migration.Legacy.Command, + predecessor.Legacy.Name, + ) + } + } + delete(stagedTool.Parameters, predecessor.Legacy.Name) + firstHopRenames[predecessor.Legacy.Name] = parameter.From + composedRenames[predecessor.Legacy.Name] = parameter.To + } + stagedTool.Parameters[parameter.From] = stagedParameter + stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool + staged.Products[migration.Schema.ProductID] = stagedProduct + } + + if !lineageApplied { + continue + } + matches := schemaToolsByPrimaryPath(historical, legacyPath) + wantRef := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID} + if len(matches) != 1 || matches[0] != wantRef { + return schemaContract{}, fmt.Errorf( + "approved command migration %q predecessor lineage requires one exact historical Schema tool, got %#v", + migration.Legacy.Command, + matches, + ) + } + if currentProductExists && currentToolExists { + if source, found := migratedConstraintSourceParameter(currentTool.Constraints, firstHopRenames); found { + return schemaContract{}, fmt.Errorf( + "approved command migration %q current Schema constraints still reference predecessor parameter %q", + migration.Legacy.Command, + source, + ) + } + } + for _, finalTarget := range composedRenames { + if _, cycle := firstHopRenames[finalTarget]; cycle { + return schemaContract{}, fmt.Errorf( + "approved command migration %q forms a Schema parameter lineage cycle through %q", + migration.Legacy.Command, + finalTarget, + ) + } + } + + firstHopConstraints, firstHopOK := canonicalizeMigratedConstraints(oldTool.Constraints, firstHopRenames) + if !firstHopOK { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema constraints are not canonicalizable", + migration.Legacy.Command, + ) + } + baseConstraints, baseOK := canonicalizeMigratedConstraints(baseTool.Constraints, nil) + if !baseOK { + return schemaContract{}, fmt.Errorf( + "approved command migration %q merge-base Schema constraints are not canonicalizable", + migration.Legacy.Command, + ) + } + stagedProduct := staged.Products[migration.Schema.ProductID] + stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID] + switch migration.State { + case interfacesnapshot.CommandMigrationPending: + if firstHopConstraints != baseConstraints { + return schemaContract{}, fmt.Errorf( + "pending command migration %q predecessor lineage changed merge-base Schema constraints", + migration.Legacy.Command, + ) + } + stagedTool.Constraints = baseTool.Constraints + case interfacesnapshot.CommandMigrationConsumed: + composedConstraints, composedOK := canonicalizeMigratedConstraints(oldTool.Constraints, composedRenames) + if !composedOK || composedConstraints != baseConstraints { + return schemaContract{}, fmt.Errorf( + "consumed command migration %q predecessor lineage changed merge-base Schema constraints", + migration.Legacy.Command, + ) + } + stagedTool.Constraints = firstHopConstraints + } + stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool + staged.Products[migration.Schema.ProductID] = stagedProduct + } + return staged, nil +} + // normalizeSchemaCommandMigrations projects only the Schema consequences that // are coupled to an already-authorized CLI command migration. It rewrites a // cloned historical contract; the ordinary checker still rejects every field @@ -1478,12 +1827,30 @@ func normalizeSchemaCommandMigrations( ) } } - if oldTool.Constraints != newSource.Constraints { - oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) - newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil) - if oldOK && newOK && oldConstraints == newConstraints { - normalizedTool.Constraints = newSource.Constraints - } + oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + if !oldOK { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema constraints are not canonicalizable", + migration.Legacy.Command, + ) + } + newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil) + if !newOK { + return schemaContract{}, fmt.Errorf( + "approved command migration %q current Schema constraints are not canonicalizable", + migration.Legacy.Command, + ) + } + if source, found := migratedConstraintSourceParameter(newSource.Constraints, renames); found { + return schemaContract{}, fmt.Errorf( + "approved command migration %q current constraints still reference legacy Schema constraint parameter %q", + migration.Legacy.Command, + source, + ) + } + normalizedTool.Constraints = oldConstraints + if oldConstraints == newConstraints { + normalizedTool.Constraints = newSource.Constraints } normalizedTool.PrimaryCLIPath = replacementPath @@ -1938,6 +2305,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str return string(encoded), err == nil } +func migratedConstraintSourceParameter(raw string, renames map[string]string) (string, bool) { + groups, ok := parseMigrationConstraintsStrict(raw) + if !ok { + return "", false + } + for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} { + for _, group := range groups[kind] { + for _, member := range group { + if _, renamed := renames[member]; renamed { + return member, true + } + } + } + } + return "", false +} + func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) { trimmed := strings.TrimSpace(raw) if trimmed == "" { diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index be8857f4..9346e1c1 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -173,8 +173,9 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") - _ = flag.String("approved-command-migrations", "", "base command ledger") - _ = flag.String("candidate-command-migrations", "", "candidate command ledger") + migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract") + approvedCommand := flag.String("approved-command-migrations", "", "base command ledger") + candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -214,6 +215,29 @@ func main() { os.Exit(2) } } + commandPair := *approvedCommand != "" || *candidateCommand != "" + if commandPair && (*approvedCommand == "" || *candidateCommand == "") { + fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair") + os.Exit(2) + } + if commandPair { + data, err := os.ReadFile(*migrationBaseSchema) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if !strings.Contains(string(data), "BASE_AUTHORITY") { + fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data) + os.Exit(2) + } + fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") + } else { + if *migrationBaseSchema != "" { + fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage") + os.Exit(2) + } + fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") + } currentData, err := os.ReadFile(*current) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", }, { - name: "governed checker protects stable Schema contract", + name: "governed checker protects stable Schema contract", + baseGovernance: "complete", + commandGovernance: "complete", + checkerMode: "stable-schema-guard", + authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED", + }, + { + name: "lineage capable checker omits command lineage for flag-only governance", baseGovernance: "complete", - checkerMode: "stable-schema-guard", + checkerMode: "flag-only-schema-guard", authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED", }, { @@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { switch test.baseGovernance { case "complete": checkerSource := governedSchemaCheckerSource - if test.checkerMode == "stable-schema-guard" { + if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" { checkerSource = stableSchemaGuardCheckerSource } schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644) @@ -794,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 { t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got) } + if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 { + t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got) + } + if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 { + t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got) + } wrongStable := exec.Command( "sh", diff --git a/scripts/release/check-release-compatibility.sh b/scripts/release/check-release-compatibility.sh new file mode 100755 index 00000000..290826bb --- /dev/null +++ b/scripts/release/check-release-compatibility.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eu + +# Release compatibility is one decision seam over the sealed source tree. +# Trusted release tooling may orchestrate this script, but the explicitly +# selected repository remains the authority for both CLI and Schema checks. + +REPO_ROOT="" +BASE_REF="" +STABLE_REF="" +CANDIDATE_REF="HEAD" + +usage() { + printf '%s\n' \ + "usage: $0 --repo-root --base-ref --stable-ref [--candidate-ref ]" >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --repo-root) + [ "$#" -ge 2 ] || { usage; exit 2; } + REPO_ROOT="$2" + shift 2 + ;; + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + --stable-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + STABLE_REF="$2" + shift 2 + ;; + --candidate-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + CANDIDATE_REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { + usage + exit 2 +} + +REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || { + printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2 + exit 2 +} +GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || { + printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2 + exit 2 +} +[ "$GIT_ROOT" = "$REPO_ROOT" ] || { + printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2 + exit 2 +} + +CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh" +SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh" +for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do + [ -x "$check" ] || { + printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2 + exit 2 + } +done + +resolve_commit() { + label="$1" + ref="$2" + commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || { + printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2 + return 2 + } + [ -n "$commit" ] || { + printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2 + return 2 + } + printf '%s\n' "$commit" +} + +# Freeze the complete comparison tuple before either checker runs. Otherwise a +# concurrent ref update (or the first checker itself) could make Schema inspect +# different Git objects from the CLI gate even when both receive the same names. +BASE_COMMIT="$(resolve_commit base "$BASE_REF")" +STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")" +CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")" + +printf '==> Checking authoritative CLI compatibility\n' +if "$CLI_CHECK" \ + --base-ref "$BASE_COMMIT" \ + --stable-ref "$STABLE_COMMIT" \ + --candidate-ref "$CANDIDATE_COMMIT"; then + : +else + status=$? + printf 'error: authoritative CLI compatibility failed\n' >&2 + exit "$status" +fi + +printf '==> Checking authoritative Schema compatibility\n' +if "$SCHEMA_CHECK" \ + --base-ref "$BASE_COMMIT" \ + --stable-ref "$STABLE_COMMIT" \ + --candidate-ref "$CANDIDATE_COMMIT"; then + : +else + status=$? + printf 'error: authoritative Schema compatibility failed\n' >&2 + exit "$status" +fi diff --git a/scripts/release/release.sh b/scripts/release/release.sh index bb7dea1f..eb44b5a5 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -19,7 +19,7 @@ usage() { cat >&2 <<'EOF' usage: release.sh [options] -Runs the full test, command-compatibility, package, and install preflight. +Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight. The default is validation only. Official publication is cloud-only. Options: @@ -359,10 +359,12 @@ else make policy if [ -n "$previous_stable" ]; then - printf '==> Comparing command tree with %s\n' "$previous_stable" - "$ROOT/scripts/policy/check-command-compatibility.sh" \ + printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable" + "$SCRIPT_DIR/check-release-compatibility.sh" \ + --repo-root "$ROOT" \ --base-ref HEAD \ - --stable-ref "$previous_stable" + --stable-ref "$previous_stable" \ + --candidate-ref HEAD fi printf '==> Building local release artifacts for %s\n' "$VERSION" @@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable" require_delivered_previous_stable if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then - printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \ + printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \ "${previous_stable_before_refresh:-none}" "$previous_stable" - "$ROOT/scripts/policy/check-command-compatibility.sh" \ + "$SCRIPT_DIR/check-release-compatibility.sh" \ + --repo-root "$ROOT" \ --base-ref HEAD \ - --stable-ref "$previous_stable" + --stable-ref "$previous_stable" \ + --candidate-ref HEAD fi if [ "$PUBLISH" -eq 1 ]; then diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 46155da0..73798dcd 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -1303,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication( "- e2e", "verify-github-tag-authority.sh", "go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'", - "check-command-compatibility.sh", + `tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh`, + `--repo-root "$GITHUB_WORKSPACE"`, + `--base-ref HEAD`, + `--stable-ref "$PREVIOUS_STABLE"`, + `--candidate-ref HEAD`, "test-multi-profile-e2e.sh", } { if !strings.Contains(validation, required) { t.Errorf("parallel release validation is missing %q", required) } } + for _, forbidden := range []string{ + "./scripts/policy/check-command-compatibility.sh", + "./scripts/policy/check-authoritative-schema-compatibility.sh", + } { + if strings.Contains(validation, forbidden) { + t.Errorf("parallel release validation bypasses the shared compatibility runner with %q", forbidden) + } + } if strings.Contains(build, "test-multi-profile-e2e.sh") { t.Error("multi-profile E2E must not serialize GoReleaser") @@ -1379,7 +1391,7 @@ func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *t verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh") deleteLocalTag := strings.Index(validation, "git update-ref -d") - compatibility := strings.Index(validation, "check-command-compatibility.sh") + compatibility := strings.Index(validation, "tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh") if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 || verifiedTag > deleteLocalTag || deleteLocalTag > compatibility { t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered") diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 6ad03b77..7c8e98cc 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -75,6 +75,7 @@ type releaseTestRepo struct { prepare string render string releaseCmd string + compat string lib string verify string } @@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo { prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"), render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"), releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"), + compat: filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh"), lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"), verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"), } } +func seedReleaseCompatibilityRefs(t *testing.T, repo string) (stable, base, candidate string) { + t.Helper() + mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test") + mustRun(t, repo, "git", "config", "user.email", "release-compatibility-test@example.com") + mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("stable\n"), 0o644) + mustRun(t, repo, "git", "add", "seed.txt") + mustRun(t, repo, "git", "commit", "-m", "stable") + mustRun(t, repo, "git", "tag", "stable-ref") + stable = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}")) + + mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("base\n"), 0o644) + mustRun(t, repo, "git", "add", "seed.txt") + mustRun(t, repo, "git", "commit", "-m", "base") + mustRun(t, repo, "git", "branch", "base-ref") + base = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}")) + + mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("candidate\n"), 0o644) + mustRun(t, repo, "git", "add", "seed.txt") + mustRun(t, repo, "git", "commit", "-m", "candidate") + mustRun(t, repo, "git", "branch", "candidate-ref") + candidate = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}")) + return stable, base, candidate +} + +func TestReleaseCompatibilityCheckRunsCLIAndSchemaWithExactRefs(t *testing.T) { + t.Parallel() + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh") + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + stable, base, candidate := seedReleaseCompatibilityRefs(t, repo) + + trace := filepath.Join(t.TempDir(), "compatibility.log") + checker := func(name string) []byte { + return []byte("#!/bin/sh\nset -eu\nprintf '" + name + ":%s\\n' \"$*\" >> \"$TRACE\"\n") + } + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), checker("cli"), 0o755) + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), checker("schema"), 0o755) + + cmd := exec.Command( + runner, + "--repo-root", repo, + "--base-ref", "base-ref", + "--stable-ref", "stable-ref", + "--candidate-ref", "candidate-ref", + ) + cmd.Env = append(os.Environ(), "TRACE="+trace) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", trace, err) + } + want := fmt.Sprintf("cli:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) + + fmt.Sprintf("schema:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) + if string(got) != want { + t.Fatalf("release compatibility calls = %q, want %q", got, want) + } +} + +func TestReleaseCompatibilityCheckFreezesRefsBeforeRunningEitherChecker(t *testing.T) { + t.Parallel() + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh") + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + stable, base, candidate := seedReleaseCompatibilityRefs(t, repo) + + trace := filepath.Join(t.TempDir(), "compatibility.log") + resolveAndLog := ` +base="$2" +stable="$4" +candidate="$6" +printf '%s:%s:%s:%s\n' "$CHECK_NAME" \ + "$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${base}^{commit}")" \ + "$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${stable}^{commit}")" \ + "$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${candidate}^{commit}")" >> "$TRACE" +` + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=cli\n"+resolveAndLog+"git -C \"$REPO_ROOT_FOR_TEST\" update-ref refs/heads/base-ref \"$MUTATE_TO\"\n"), 0o755) + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=schema\n"+resolveAndLog), 0o755) + + cmd := exec.Command( + runner, + "--repo-root", repo, + "--base-ref", "base-ref", + "--stable-ref", "stable-ref", + "--candidate-ref", "candidate-ref", + ) + cmd.Env = append(os.Environ(), "TRACE="+trace, "REPO_ROOT_FOR_TEST="+repo, "MUTATE_TO="+candidate) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", trace, err) + } + want := fmt.Sprintf("cli:%s:%s:%s\nschema:%s:%s:%s\n", base, stable, candidate, base, stable, candidate) + if string(got) != want { + t.Fatalf("release compatibility resolved commits = %q, want %q", got, want) + } + if moved := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "base-ref^{commit}")); moved != candidate { + t.Fatalf("mutating checker did not move base-ref: got %s, want %s", moved, candidate) + } +} + +func TestReleaseCompatibilityCheckStopsBeforeSchemaWhenCLIFails(t *testing.T) { + t.Parallel() + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh") + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + seedReleaseCompatibilityRefs(t, repo) + + trace := filepath.Join(t.TempDir(), "compatibility.log") + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\nexit 17\n"), 0o755) + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\n"), 0o755) + + cmd := exec.Command( + runner, + "--repo-root", repo, + "--base-ref", "base-ref", + "--stable-ref", "stable-ref", + "--candidate-ref", "candidate-ref", + ) + cmd.Env = append(os.Environ(), "TRACE="+trace) + output, err := cmd.CombinedOutput() + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 17 { + t.Fatalf("release compatibility CLI failure = %v, want exit 17\noutput:\n%s", err, output) + } + if !strings.Contains(string(output), "error: authoritative CLI compatibility failed") { + t.Fatalf("release compatibility CLI failure has no boundary message:\n%s", output) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", trace, err) + } + if string(got) != "cli\n" { + t.Fatalf("checks after CLI failure = %q, want only CLI", got) + } +} + +func TestReleaseCompatibilityCheckFailsWhenSchemaFails(t *testing.T) { + t.Parallel() + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh") + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + seedReleaseCompatibilityRefs(t, repo) + + trace := filepath.Join(t.TempDir(), "compatibility.log") + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\n"), 0o755) + mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\nexit 19\n"), 0o755) + + cmd := exec.Command( + runner, + "--repo-root", repo, + "--base-ref", "base-ref", + "--stable-ref", "stable-ref", + "--candidate-ref", "candidate-ref", + ) + cmd.Env = append(os.Environ(), "TRACE="+trace) + output, err := cmd.CombinedOutput() + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 19 { + t.Fatalf("release compatibility Schema failure = %v, want exit 19\noutput:\n%s", err, output) + } + if !strings.Contains(string(output), "error: authoritative Schema compatibility failed") { + t.Fatalf("release compatibility Schema failure has no boundary message:\n%s", output) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", trace, err) + } + if string(got) != "cli\nschema\n" { + t.Fatalf("checks before Schema failure = %q, want CLI then Schema", got) + } +} + func TestReleaseVersionOrdering(t *testing.T) { sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { @@ -2631,12 +2827,35 @@ func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) { } } +func TestReleaseCommandRunsCLIAndSchemaCompatibilityInInitialPreflight(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "install release compatibility fixture") + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.1-beta.1", "--remote", "origin", + ) + if err != nil { + t.Fatalf("release validation error = %v\noutput:\n%s", err, output) + } + head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}")) + stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}")) + for _, want := range []string{ + fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head), + fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head), + } { + if !strings.Contains(output, want) { + t.Errorf("initial release preflight is missing %q\noutput:\n%s", want, output) + } + } +} + func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) { r := newReleaseTestRepo(t) installReleaseCommandFixture(t, r) section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n" mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644) - mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755) mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644) r.commitAndPush(t, "install advancing release fixture") @@ -2646,20 +2865,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) { if err != nil { t.Fatalf("release validation error = %v\noutput:\n%s", err, output) } - if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") || - !strings.Contains(output, "--stable-ref v1.0.1") { - t.Fatalf("advanced stable command tree was not rechecked:\n%s", output) + head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}")) + stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.1^{commit}")) + for _, want := range []string{ + "Stable authority advanced from v1.0.0 to v1.0.1", + fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head), + fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head), + } { + if !strings.Contains(output, want) { + t.Errorf("advanced stable authority recheck is missing %q\noutput:\n%s", want, output) + } } } func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) { t.Helper() - for _, source := range []string{r.lib, r.contract, r.releaseCmd} { + for _, source := range []string{r.lib, r.contract, r.releaseCmd, r.compat} { releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755) } mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) - mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) + mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'cli-compatibility %s\\n' \"$*\"\n"), 0o755) + mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'schema-compatibility %s\\n' \"$*\"\n"), 0o755) mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644) } From 096dfd48f0981fd9ae4308c950188f8a99dcc014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 20 Aug 2026 11:56:54 +0800 Subject: [PATCH 202/267] docs(chat): keep emotion skill route within budget --- skills/multi/dingtalk-chat/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index cf63ec02..a6fb537a 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -47,9 +47,7 @@ metadata: | 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | | 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | | 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | -| 查看个人收藏表情 | `dws chat emotion list` | 当前用户身份由 MCP server 注入 | -| 发送个人收藏表情 | `dws chat emotion send --media-id --group ` | 目标为 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一;`--user` 会解析为 openDingTalkId | -| 新增个人收藏表情 | `dws chat emotion favorite --media-id ` | `--source-conversation-id` 与 `--source-message-id` 必须成对指定 | +| 个人收藏表情列表/发送/收藏 | `dws chat emotion list/send/favorite` | 约束见 leaf Schema | | 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | From 3d7ab2690c3bce837a1d4344ca92e4469a3df801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Thu, 20 Aug 2026 12:44:32 +0800 Subject: [PATCH 203/267] feat(cli): expand Minutes TODO Wiki parameter aliases --- .changes/minutes-todo-wiki-param-aliases.md | 5 + internal/app/param_alias_e2e_test.go | 28 + .../param_alias_payload_equivalence_test.go | 151 +- internal/cli/param_aliases_generated.go | 1514 +++++++++++++++++ internal/cli/param_concepts.json | 287 +++- 5 files changed, 1964 insertions(+), 21 deletions(-) create mode 100644 .changes/minutes-todo-wiki-param-aliases.md diff --git a/.changes/minutes-todo-wiki-param-aliases.md b/.changes/minutes-todo-wiki-param-aliases.md new file mode 100644 index 00000000..e7c4fb2d --- /dev/null +++ b/.changes/minutes-todo-wiki-param-aliases.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products. diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index cca79f8e..6316f78c 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string return `{"success":true,"result":[]}` case "list_suggested_event_times": return `{"success":true,"result":{"recommendEventTimes":[]}}` + case "list_by_keyword_and_time_range": + return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}` + case "get_minutes_basic_info": + return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}` + case "get_minutes_transcription": + return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}` + case "create_personal_todo": + return `{"success":true,"result":{"taskId":"task-1"}}` + case "get_todo_detail": + return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}` + case "get_user_todos_in_current_org": + return `{"success":true,"result":{"todoCards":[],"hasMore":false}}` + case "add_todo_reminder": + return `{"success":true}` + case "copy_document": + return `{"success":true,"nodeId":"copy-1"}` + case "move_document", "add_member", "update_member", "remove_member": + return `{"success":true}` + case "get_document_info": + if len(c.calls) > 1 { + switch c.calls[len(c.calls)-2].tool { + case "copy_document": + return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}` + case "move_document": + return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}` + } + } + return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}` case "create_calendar_event": return `{"success":true,"result":{"eventId":"event-1"}}` case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant": diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 542c9b8a..2d6ef0ba 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -232,6 +232,67 @@ var paramAliasCompleteCommands = map[string][]string{ "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, } +// paramAliasCandidateCompleteCommands contains complete invocations for the +// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a +// separate map lets this test file land before the draft replaces the formal +// param_concepts.json: inactive candidate templates are ignored, while every +// command becomes mandatory as soon as one of its reviewed aliases is active. +var paramAliasCandidateCompleteCommands = map[string][]string{ + "minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"}, + "minutes +latest": {"minutes", "+latest", "--keyword", "fixture"}, + "minutes +list-all": {"minutes", "+list-all", "--limit", "7"}, + "minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"}, + "minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"}, + "minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"}, + "minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"}, + "minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"}, + "minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"}, + "minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"}, + "minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"}, + "minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"}, + "minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"}, + "minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"}, + "minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"}, + "minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"}, + "minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"}, + "minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"}, + "minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"}, + "minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"}, + "todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"}, + "todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"}, + "todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"}, + "todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"}, + "todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"}, + "todo +due-today": {"todo", "+due-today", "--role-types", "executor"}, + "todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"}, + "todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"}, + "todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"}, + "todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"}, + "todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"}, + "todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"}, + "todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"}, + "todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"}, + "todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"}, + "todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"}, + "todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"}, + "todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"}, + "todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"}, + "todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"}, + "todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"}, + "todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"}, + "todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"}, + "todo task get": {"todo", "task", "get", "--task-id", "task-1"}, + "todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"}, + "todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"}, + "wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"}, + "wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"}, + "wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"}, + "wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"}, + "wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"}, + "wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"}, + "wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"}, +} + // A command can expose more than one mutually exclusive canonical route. In // that case the shared command template above cannot contain every canonical // flag at once, so select a fixture-specific complete invocation here. @@ -531,6 +592,20 @@ var paramAliasNewConfirmationCases = []struct { {command: "drive +version-revert", emitted: "version-number", canonical: "version"}, } +// Candidate confirmation cases become active with the joint draft. One write +// workflow per product plus TODO's reminder workflow proves semantic aliasing +// cannot move execution across the shared --yes barrier. +var paramAliasCandidateConfirmationCases = []struct { + command string + emitted string + canonical string +}{ + {command: "minutes +record-pause", emitted: "uuid", canonical: "id"}, + {command: "todo +create", emitted: "deadline", canonical: "due"}, + {command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"}, + {command: "wiki +node-copy", emitted: "node-id", canonical: "node"}, +} + // paramAliasRepresentativePayloadCases keeps final transport coverage across // old concept aliases, command overrides, native compatibility flags, read and // write commands, and different products. Every reviewed alias is still @@ -600,6 +675,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias } +// Candidate representatives exercise the final transport boundary for each +// Minutes/TODO/Wiki alias family. They are required only when the exact fixture +// exists in the loaded reviewed table, so the tests are mergeable before the +// joint draft is promoted to internal/cli/param_concepts.json. +var paramAliasCandidateRepresentativePayloadCases = map[string]bool{ + paramAliasPayloadCaseKey("minutes +latest", "query"): true, + paramAliasPayloadCaseKey("minutes +transcript", "query"): true, + paramAliasPayloadCaseKey("minutes get batch", "uuids"): true, + paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true, + paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true, + paramAliasPayloadCaseKey("todo +create", "deadline"): true, + paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true, + paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true, + paramAliasPayloadCaseKey("todo comment add", "text"): true, + paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true, + paramAliasPayloadCaseKey("todo task get", "todo-id"): true, + paramAliasPayloadCaseKey("todo task update", "status"): true, + paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true, + paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true, + paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true, + paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true, + paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true, +} + // paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion // separate from the long-lived app-c race process. Each case still executes // both canonical and alias argv through the real PreParse/Cobra path and @@ -709,6 +808,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres } activeCommands := make(map[string]bool) + activeFixtureCases := make(map[string]bool) activeCases := 0 executedRepresentatives := make(map[string]bool) for _, fixture := range concepts.Fixture { @@ -717,6 +817,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres } activeCommands[fixture.Command] = true activeCases++ + caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) + activeFixtureCases[caseKey] = true complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) if !ok { t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted) @@ -729,8 +831,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres continue } - caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) - if !paramAliasRepresentativePayloadCases[caseKey] { + if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] { continue } executedRepresentatives[caseKey] = true @@ -742,26 +843,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres if activeCases == 0 { t.Fatal("reviewed fixture contains no active alias cases") } + templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands)) for command := range paramAliasCompleteCommands { if !activeCommands[command] { t.Errorf("complete-command E2E template %q has no active reviewed fixture", command) } + templateCommands[command] = true + } + for command := range paramAliasCandidateCompleteCommands { + if activeCommands[command] { + templateCommands[command] = true + } } for command := range activeCommands { - if _, ok := paramAliasCompleteCommands[command]; !ok { + if !templateCommands[command] { t.Errorf("active reviewed command %q has no complete-command E2E template", command) } } - if len(activeCommands) != len(paramAliasCompleteCommands) { - t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases) + if len(activeCommands) != len(templateCommands) { + t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases) } for caseKey := range paramAliasRepresentativePayloadCases { if !executedRepresentatives[caseKey] { t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey) } } - if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) { - t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases)) + activeRepresentatives := len(paramAliasRepresentativePayloadCases) + for caseKey := range paramAliasCandidateRepresentativePayloadCases { + if !activeFixtureCases[caseKey] { + continue + } + activeRepresentatives++ + if !executedRepresentatives[caseKey] { + t.Errorf("candidate representative final-payload case %q was not executed", caseKey) + } + } + if len(executedRepresentatives) != activeRepresentatives { + t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives) } } @@ -1091,7 +1209,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation } func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) { - for _, test := range paramAliasNewConfirmationCases { + tests := append([]struct { + command string + emitted string + canonical string + }{}, paramAliasNewConfirmationCases...) + for _, candidate := range paramAliasCandidateConfirmationCases { + entry, exists := cli.LookupParamAlias(candidate.command) + target, active := entry.ResolveAlias(candidate.emitted) + if exists && active && target == candidate.canonical { + tests = append(tests, candidate) + } + } + + for _, test := range tests { test := test t.Run(test.command+"/"+test.emitted, func(t *testing.T) { complete, ok := paramAliasCompleteCommand(test.command, test.canonical) @@ -1173,6 +1304,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) { return variant, true } } + if ok { + return complete, true + } + complete, ok = paramAliasCandidateCompleteCommands[command] return complete, ok } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 8827cceb..c64abdcb 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -6052,6 +6052,609 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "minutes +action-items", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +apply-permission", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "policy", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +detail", + Aliases: map[string]string{ + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-token": "cursor", + "task-uuid": "id", + "task-uuids": "ids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "offset", "page", "record-id", "task-id", "url"}, + }, + { + CLIPath: "minutes +download", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "record-id", "task-id", "url"}, + Ambiguous: []string{"destination", "path", "save-path"}, + }, + { + CLIPath: "minutes +export-pack", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +latest", + Aliases: map[string]string{ + "keywords": "keyword", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-all", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-mine", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-shared", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +mindmap", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +prepare-asr", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes +record-pause", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-resume", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-start", + Blocked: []string{"session-ids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids"}, + }, + { + CLIPath: "minutes +record-stop", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-wrap-up", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +replace-batch", + Aliases: map[string]string{ + "pairs": "pair", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "original-text", "record-id", "replace", "replacement-text", "search", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +search", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "name", "offset", "page", "subject", "text", "time", "title"}, + }, + { + CLIPath: "minutes +share", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "user-ids": "member-uids", + "users": "member-uids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"at-user-ids", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "policy", "record-id", "staff-id", "task-id", "uid", "union-id", "url", "user", "user-id", "userid"}, + }, + { + CLIPath: "minutes +speaker-insights", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-ids", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +speaker-replace", + Aliases: map[string]string{ + "new-speaker": "to", + "old-speaker": "from", + "source-speaker": "from", + "target-speaker": "to", + "task-uuid": "id", + "uid": "target-uid", + "user-id": "target-uid", + "userid": "target-uid", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "open-dingtalk-id", "record-id", "staff-id", "target-uids", "task-id", "task-uuids", "union-id", "url", "user", "uuids"}, + }, + { + CLIPath: "minutes +summary", + Aliases: map[string]string{ + "body": "content", + "task-uuid": "id", + "text": "content", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "record-id", "task-id", "task-uuids", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes +transcript", + Aliases: map[string]string{ + "keywords": "keyword", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-token": "cursor", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "offset", "page", "record-id", "subject", "task-id", "task-uuids", "text", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes +unshare", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "user-ids": "member-uids", + "users": "member-uids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"at-user-ids", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "record-id", "staff-id", "task-id", "uid", "union-id", "url", "user", "user-id", "userid"}, + }, + { + CLIPath: "minutes +update", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +upload", + Blocked: []string{"file-name", "file-size", "size-bytes", "template-ids"}, + }, + { + CLIPath: "minutes +upload-and-analyze", + Aliases: map[string]string{ + "task-uuid": "resume-id", + "uuid": "resume-id", + }, + Blocked: []string{"file-name", "file-size", "id", "resume-ids", "size-bytes", "template-ids"}, + }, + { + CLIPath: "minutes audio-memo list", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "limit": "max", + "max-results": "max", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "page-size": "max", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + }, + Blocked: []string{"date", "next-page-token", "next-token", "page-token", "time"}, + }, + { + CLIPath: "minutes get audio", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get batch", + Aliases: map[string]string{ + "task-uuids": "ids", + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes get info", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get keywords", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get summary", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get todos", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get transcription", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes hot-word add", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes hot-word delete", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes list all", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes list mine", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes list shared", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes mind-graph create", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes mind-graph status", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes permission add", + Aliases: map[string]string{ + "user-ids": "member-uids", + "users": "member-uids", + }, + Blocked: []string{"at-user-ids", "id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "permission", "staff-id", "task-uuid", "uid", "union-id", "url", "user", "user-id", "userid", "uuid"}, + }, + { + CLIPath: "minutes permission apply", + Blocked: []string{"ids", "permission", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes permission remove", + Aliases: map[string]string{ + "user-ids": "member-uids", + "users": "member-uids", + }, + Blocked: []string{"at-user-ids", "id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "staff-id", "task-uuid", "uid", "union-id", "url", "user", "user-id", "userid", "uuid"}, + }, + { + CLIPath: "minutes record pause", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes record resume", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes record start", + Blocked: []string{"session-ids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids"}, + }, + { + CLIPath: "minutes record stop", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes replace-text", + Aliases: map[string]string{ + "find": "search", + "new-text": "replace", + "original-text": "search", + "replacement-text": "replace", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"body", "content", "ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "text", "url", "uuids"}, + }, + { + CLIPath: "minutes speaker replace", + Aliases: map[string]string{ + "new-speaker": "to", + "old-speaker": "from", + "source-speaker": "from", + "target-speaker": "to", + "task-uuid": "id", + "uid": "target-uid", + "user-id": "target-uid", + "userid": "target-uid", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "open-dingtalk-id", "record-id", "staff-id", "target-uids", "task-id", "task-uuids", "union-id", "url", "user", "uuids"}, + }, + { + CLIPath: "minutes speaker summary create", + Aliases: map[string]string{ + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes speaker summary get", + Aliases: map[string]string{ + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes tag query", + Aliases: map[string]string{ + "id": "tag-id", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "group-id", "group-name", "page", "tag-ids"}, + }, + { + CLIPath: "minutes update summary", + Aliases: map[string]string{ + "body": "content", + "task-uuid": "id", + "text": "content", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "record-id", "task-id", "task-uuids", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes update title", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes upload cancel", + Aliases: map[string]string{ + "upload-id": "session-id", + "upload-session-id": "session-id", + }, + Blocked: []string{"id", "minutes-id", "recording-session-id", "session-ids", "task-id", "task-uuid", "upload-ids", "upload-session-ids"}, + }, + { + CLIPath: "minutes upload complete", + Aliases: map[string]string{ + "upload-id": "session-id", + "upload-session-id": "session-id", + }, + Blocked: []string{"id", "minutes-id", "recording-session-id", "session-ids", "task-id", "task-uuid", "upload-ids", "upload-session-ids"}, + }, + { + CLIPath: "minutes upload create", + Blocked: []string{"file", "file-path", "path", "template-ids"}, + }, { CLIPath: "oa +list-executed", Aliases: map[string]string{ @@ -6103,6 +6706,917 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "report outbox list", Blocked: []string{"template-type"}, }, + { + CLIPath: "todo +assign", + Aliases: map[string]string{ + "assignee-name": "to", + "deadline": "due", + "due-time": "due", + "title": "task", + }, + Blocked: []string{"due-date-offset", "executor-id", "executor-ids", "executors", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "user-id", "user-ids"}, + }, + { + CLIPath: "todo +assign-multi", + Aliases: map[string]string{ + "assignee-names": "to", + "title": "task", + }, + Blocked: []string{"executor-id", "executor-ids", "executors", "user-id", "user-ids"}, + }, + { + CLIPath: "todo +comment", + Aliases: map[string]string{ + "body": "content", + "text": "content", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "name", "open-task-id", "parent-id", "task", "task-ids", "title", "todo-ids"}, + }, + { + CLIPath: "todo +complete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "done", "id", "ids", "is-done", "open-task-id", "parent-id", "status", "task", "task-ids", "todo-ids", "todo-status"}, + }, + { + CLIPath: "todo +create", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "task": "title", + }, + Blocked: []string{"due-date-offset", "executor", "executor-id", "name", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "to", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo +due-today", + Aliases: map[string]string{ + "roles": "role-types", + }, + Blocked: []string{"role", "role-id", "role-ids"}, + }, + { + CLIPath: "todo +get", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +get-my-tasks", + Aliases: map[string]string{ + "current-page": "page", + "done": "status", + "is-done": "status", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "priorities": "priority", + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"cursor", "limit", "max-result", "max-results", "page-index", "page-token", "plan-finish-date-end", "plan-finish-date-start", "priority-id", "priority-name", "role", "role-id", "role-ids", "state", "take", "task-status", "top"}, + }, + { + CLIPath: "todo +get-related-tasks", + Aliases: map[string]string{ + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"done", "is-done", "role", "role-id", "role-ids"}, + }, + { + CLIPath: "todo +list-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +list-comment", + Aliases: map[string]string{ + "current-page": "page", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "take": "size", + "todo-id": "task-id", + "top": "size", + }, + Blocked: []string{"attachment-id", "comment-id", "count", "cursor", "id", "ids", "open-task-id", "page-index", "page-token", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +list-sub", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +remind", + Aliases: map[string]string{ + "deadline": "at", + "due": "at", + "due-time": "at", + "title": "task", + }, + Blocked: []string{"remind-at", "reminder-at", "reminder-rules", "reminder-time", "reminder-time-stamp"}, + }, + { + CLIPath: "todo +reminder", + Aliases: map[string]string{ + "remind-at": "at", + "reminder-at": "at", + "reminder-time": "at", + "reminder-time-stamp": "at", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "deadline", "due", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-rules", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +reopen", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "done", "id", "ids", "is-done", "open-task-id", "parent-id", "status", "task", "task-ids", "todo-ids", "todo-status"}, + }, + { + CLIPath: "todo +search", + Aliases: map[string]string{ + "done": "status", + "is-done": "status", + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + "subject": "query", + "task": "query", + "title": "query", + "todo-status": "status", + }, + Blocked: []string{"limit", "max-result", "max-results", "name", "page", "page-size", "per-page", "size", "state", "take", "task-status", "text", "top"}, + }, + { + CLIPath: "todo +todo-done", + Aliases: map[string]string{ + "keyword": "task", + "query": "task", + "title": "task", + }, + Blocked: []string{"task-id", "todo-id"}, + }, + { + CLIPath: "todo +update", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "id", "ids", "open-task-id", "parent-id", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "task-ids", "todo-ids"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "todo comment add", + Aliases: map[string]string{ + "body": "content", + "text": "content", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "name", "open-task-id", "parent-id", "task", "task-ids", "title", "todo-ids"}, + }, + { + CLIPath: "todo comment delete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo comment list", + Aliases: map[string]string{ + "current-page": "page", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "take": "size", + "todo-id": "task-id", + "top": "size", + }, + Blocked: []string{"attachment-id", "comment-id", "count", "cursor", "id", "ids", "open-task-id", "page-index", "page-token", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo tag add", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "tag-ids", "tag-names", "tags", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo tag delete", + Blocked: []string{"tag-ids", "tag-names", "tags"}, + }, + { + CLIPath: "todo tag update", + Blocked: []string{"tag-codes", "tag-ids", "tag-names", "tags"}, + }, + { + CLIPath: "todo task add-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task add-executor", + Aliases: map[string]string{ + "executor-ids": "executors", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "participant-ids", "participants", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task add-participant", + Aliases: map[string]string{ + "participant-ids": "participants", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "executor-ids", "executors", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task add-reminder", + Aliases: map[string]string{ + "at": "reminder-time-stamp", + "reminder-at": "reminder-time-stamp", + "reminder-time": "reminder-time-stamp", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "deadline", "due", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-rules", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task create", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "task": "title", + }, + Blocked: []string{"due-date-offset", "executor", "executor-id", "name", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "to", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task create-sub", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "parent-task-id": "parent-id", + "task-id": "parent-id", + "todo-id": "parent-id", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "executor", "executor-id", "id", "ids", "name", "open-task-id", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "todo task delete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task done", + Aliases: map[string]string{ + "done": "status", + "is-done": "status", + "todo-id": "task-id", + "todo-status": "status", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "state", "task", "task-ids", "task-status", "todo-ids"}, + }, + { + CLIPath: "todo task get", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task list", + Aliases: map[string]string{ + "current-page": "page", + "done": "status", + "is-done": "status", + "limit": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "priorities": "priority", + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"cursor", "page-index", "page-size", "page-token", "plan-finish-end", "plan-finish-start", "priority-id", "priority-name", "role", "role-id", "role-ids", "state", "task-status"}, + }, + { + CLIPath: "todo task list-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task list-sub", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task remove-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task remove-executor", + Aliases: map[string]string{ + "executor-ids": "executors", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "participant-ids", "participants", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task remove-participant", + Aliases: map[string]string{ + "participant-ids": "participants", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "executor-ids", "executors", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task reset-reminder", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"at", "attachment-id", "base-time", "comment-id", "deadline", "due", "due-date-offset", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-time-stamp", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task update", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "is-done": "done", + "status": "done", + "todo-id": "task-id", + "todo-status": "done", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "id", "ids", "open-task-id", "parent-id", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "state", "task-ids", "task-status", "todo-ids"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "wiki +delete-space", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki +feed-list", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +member-add", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-list", + Aliases: map[string]string{ + "filter-roles": "filter-role", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "role-filter": "filter-role", + "roles": "filter-role", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-remove", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-update", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +move", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +move-to-drive", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + }, + Blocked: []string{"dentry-id", "knowledge-base-id", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +node-copy", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +node-create", + Aliases: map[string]string{ + "directory-id": "folder", + "display-name": "name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-name": "name", + "node-type": "type", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "query", "space", "space-id", "space-type", "storage-space-id", "wiki-space-type"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +node-delete", + Aliases: map[string]string{ + "document-id": "node", + "knowledge-base-id": "workspace", + "node-id": "node", + "target-node-id": "node", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +node-get", + Aliases: map[string]string{ + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "id": "node", + "node-id": "node", + "url": "node", + "wiki-node-id": "node", + }, + Blocked: []string{"dentry-id", "folder", "folder-id", "knowledge-base-id", "space-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +node-list", + Aliases: map[string]string{ + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "per-page": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id", "max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +node-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "node-name": "query", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "name", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id", "subject", "text", "title"}, + }, + { + CLIPath: "wiki +resolve-space", + Aliases: map[string]string{ + "keyword": "name", + "keywords": "name", + "knowledge-base-name": "name", + "q": "name", + "query": "name", + "search-word": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"id", "knowledge-base-id", "space-id", "subject", "text", "title", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +space-create", + Aliases: map[string]string{ + "description": "desc", + "display-name": "name", + "knowledge-base-name": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"config", "field-description", "node-name", "query", "title", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +space-get", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki +space-list", + Aliases: map[string]string{ + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "per-page": "limit", + "space-type": "type", + "wiki-space-type": "type", + }, + Blocked: []string{"id", "knowledge-base-id", "node-type", "offset", "page", "space", "space-id", "storage-space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +space-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "knowledge-base-name": "query", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "space-name": "query", + "take": "limit", + "top": "limit", + "wiki-name": "query", + "workspace-name": "query", + }, + Blocked: []string{"count", "cursor", "id", "knowledge-base-id", "name", "page", "space-id", "space-type", "subject", "text", "title", "type", "wiki-space-type", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +wiki-new-doc", + Aliases: map[string]string{ + "doc-title": "title", + "document-title": "title", + "knowledge-base-name": "space", + "space-name": "space", + "wiki-name": "space", + "workspace-name": "space", + }, + Blocked: []string{"id", "knowledge-base-id", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"name"}, + }, + { + CLIPath: "wiki feed list", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "max-items", "node", "node-id", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki member add", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member list", + Aliases: map[string]string{ + "filter-roles": "filter-role", + "knowledge-base-id": "workspace", + "max-result": "limit", + "per-page": "limit", + "role-filter": "filter-role", + "roles": "filter-role", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member remove", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member update", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node copy", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "knowledge-base-id": "workspace", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki node create", + Aliases: map[string]string{ + "directory-id": "folder", + "display-name": "name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-name": "name", + "node-type": "type", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "query", "space", "space-id", "space-type", "storage-space-id", "wiki-space-type"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node delete", + Aliases: map[string]string{ + "document-id": "node", + "knowledge-base-id": "workspace", + "target-node-id": "node", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node list", + Aliases: map[string]string{ + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "max-items", "node", "node-id", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node move", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "knowledge-base-id": "workspace", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki node search", + Aliases: map[string]string{ + "keywords": "query", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "node-name": "query", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "name", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id", "subject", "text", "title"}, + }, + { + CLIPath: "wiki space create", + Aliases: map[string]string{ + "display-name": "name", + "knowledge-base-name": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"config", "field-description", "node-name", "query", "title", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki space delete", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki space get", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki space list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "space-type": "type", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "id", "knowledge-base-id", "max-items", "node-type", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki space search", + Aliases: map[string]string{ + "keywords": "query", + "knowledge-base-name": "query", + "max-result": "limit", + "max-results": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "space-name": "query", + "space-type": "type", + "take": "limit", + "top": "limit", + "wiki-name": "query", + "wiki-space-type": "type", + "workspace-name": "query", + }, + Blocked: []string{"count", "cursor", "id", "knowledge-base-id", "name", "page", "space-id", "subject", "text", "title", "wiki-workspace-id", "workspace", "workspace-id"}, + }, } func loadGeneratedParamAliases() []ParamAliasEntry { diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 4b9086b4..c8688ca7 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,16 +10,16 @@ }, "concepts": { - "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event"],"risk":"green"}, - "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, - "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event"],"risk":"green"}, - "content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"}, - "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"}, - "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"}, + "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, + "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"}, + "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"}, + "content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"}, + "time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"}, + "time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"}, "base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow list","aitable workflow run","aitable workflow update"],"risk":"green"}, "table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"}, - "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update"],"risk":"green"}, + "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"}, "workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"}, "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, "dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"}, @@ -31,7 +31,7 @@ "open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"}, "referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"}, "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"}, - "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot", "calendar +freebusy", "calendar +suggestion"], "risk": "yellow"}, + "user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update"],"risk":"yellow"}, "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"}, @@ -49,7 +49,7 @@ "drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, "drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"}, "drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"}, - "workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"}, + "workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"commands":["doc +access-change","doc +access-grant","doc +access-revoke","doc +copy","doc +create","doc +create-from-template","doc +grant-and-share","doc +import","doc +list","doc +move","doc create","doc file create","doc import","doc template apply","drive +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get"],"risk":"yellow"}, "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, @@ -61,7 +61,17 @@ "calendar_person_name_list": {"denotes":"comma-separated DingTalk person names resolved by the shortcut","canonical_hint":"with","members":["with","names","attendee-names","participant-names"],"excludes":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"commands":["calendar +book","calendar +invite","calendar +suggest-time"],"risk":"yellow"}, "calendar_room_ids": {"denotes":"calendar meeting roomId list with unchanged element values; one roomId is a one-element list","canonical_hint":"rooms","members":["rooms","room-id","room-ids"],"excludes":["room","room-name","group-id","location"],"commands":["calendar event create","calendar room add","calendar room delete","calendar busy search","calendar +create","calendar +freebusy"],"risk":"yellow"}, "calendar_duration_minutes": {"denotes":"meeting duration in minutes with unchanged numeric text","canonical_hint":"duration","members":["duration","duration-minutes","meeting-duration-minutes"],"excludes":["remind-minutes","start","end","from","to"],"commands":["calendar event suggest","calendar +suggest-time","calendar +suggestion"],"risk":"green"}, - "calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"} + "calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"}, + "minutes_task_uuid": {"denotes":"single DingTalk Minutes taskUuid with unchanged value","canonical_hint":"task-uuid","members":["task-uuid","uuid"],"excludes":["ids","task-uuids","uuids","url","minutes-id","meeting-id","record-id","task-id"],"commands":["minutes +action-items","minutes +apply-permission","minutes +detail","minutes +download","minutes +export-pack","minutes +mindmap","minutes +record-pause","minutes +record-resume","minutes +record-stop","minutes +record-wrap-up","minutes +replace-batch","minutes +share","minutes +speaker-insights","minutes +speaker-replace","minutes +summary","minutes +transcript","minutes +unshare","minutes +update","minutes record pause","minutes record resume","minutes record stop","minutes replace-text","minutes speaker replace","minutes update summary","minutes update title"],"risk":"yellow"}, + "minutes_task_uuids": {"denotes":"Minutes taskUuid list with unchanged element values and unchanged list encoding","canonical_hint":"ids","members":["ids","task-uuids","uuids"],"excludes":["id","task-uuid","uuid","url","minutes-id","minutes-ids","meeting-id","meeting-ids"],"commands":["minutes +detail","minutes +download","minutes +share","minutes +unshare","minutes get batch","minutes permission add","minutes permission remove","minutes speaker summary create","minutes speaker summary get"],"risk":"yellow"}, + "minutes_hot_words": {"denotes":"personal hot-word list with unchanged values and unchanged list encoding","canonical_hint":"words","members":["words","hot-words","hotwords"],"excludes":["word","hot-word","query","keyword","keywords","search"],"commands":["minutes +prepare-asr","minutes hot-word add","minutes hot-word delete"],"risk":"yellow"}, + "minutes_upload_session_id": {"denotes":"single Minutes file-upload sessionId with unchanged value","canonical_hint":"session-id","members":["session-id","upload-session-id","upload-id"],"excludes":["id","task-id","task-uuid","minutes-id","recording-session-id"],"commands":["minutes upload cancel","minutes upload complete"],"risk":"yellow"}, + "todo_task_id": {"denotes":"single DingTalk todo taskId with unchanged value","canonical_hint":"task-id","members":["task-id","todo-id"],"excludes":["id","ids","task","task-ids","todo-ids","parent-id","comment-id","attachment-id","open-task-id"],"commands":["todo +comment","todo +complete","todo +get","todo +list-attachment","todo +list-comment","todo +list-sub","todo +reminder","todo +reopen","todo +update","todo comment add","todo comment delete","todo comment list","todo tag add","todo task add-attachment","todo task add-executor","todo task add-participant","todo task add-reminder","todo task create-sub","todo task delete","todo task done","todo task get","todo task list-attachment","todo task list-sub","todo task remove-attachment","todo task remove-executor","todo task remove-participant","todo task reset-reminder","todo task update"],"risk":"yellow"}, + "todo_due_time": {"denotes":"todo deadline ISO-8601 value, not an independent reminder time","canonical_hint":"due","members":["due","due-time","deadline"],"excludes":["remind-at","reminder-at","reminder-time","reminder-time-stamp","due-date-offset","plan-finish-start","plan-finish-end","plan-finish-date-start","plan-finish-date-end"],"commands":["todo +assign","todo +create","todo +update","todo task create","todo task create-sub","todo task update"],"risk":"yellow"}, + "todo_executor_ids": {"denotes":"todo executor userId list with unchanged comma-separated value","canonical_hint":"executors","members":["executors","executor-ids"],"excludes":["executor","executor-id","participant","participant-id","participants","participant-ids","to","user","user-id","user-ids","users","name"],"commands":["todo +create","todo task add-executor","todo task create","todo task create-sub","todo task remove-executor"],"risk":"yellow"}, + "todo_participant_ids": {"denotes":"todo participant userId list with unchanged comma-separated value","canonical_hint":"participants","members":["participants","participant-ids"],"excludes":["participant","participant-id","executor","executor-id","executors","executor-ids","to","user","user-id","user-ids","users","name"],"commands":["todo task add-participant","todo task remove-participant"],"risk":"yellow"}, + "todo_completion_state": {"denotes":"todo completion boolean string true/false with unchanged value","canonical_hint":"status","members":["status","done","is-done","todo-status"],"excludes":["state","task-status"],"commands":["todo +get-my-tasks","todo +search","todo task done","todo task list","todo task update"],"risk":"yellow"}, + "todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"} }, "command_overrides": { @@ -305,7 +315,115 @@ "aitable +import-upload": {"scoped_aliases":{"size":"file-size"},"scope_strict":true,"note":"On this import-upload command --file-size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."}, "aitable +table-copy": {"scoped_aliases":{"table-id":"source-table-id"},"ambiguous":["base","base-id","base-token"],"scope_strict":true,"note":"The only existing table role is the source table, while an unqualified Base spelling cannot choose between source-base-id and target-base-id."}, "aitable workflow run": {"block":["record-id"],"note":"The command accepts a list of one to five record IDs. A singular --record-id is deliberately blocked instead of relying on edit-distance correction; --table-id and --record-ids must remain paired for record-triggered workflows."}, - "aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."} + "aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."}, + "minutes +share": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","policy"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. Semantic permission names and numeric policy values require value conversion and are intentionally blocked across surfaces. The broad real --id is explicitly bound to the Minutes taskUuid concept."}, + "minutes +unshare": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept."}, + "minutes permission add": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","permission"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task IDs, singular member IDs, staffId/unionId/openDingTalkId domains, and semantic permission names are not promoted into these list/numeric roles."}, + "minutes permission remove": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task/member inputs and staffId/unionId/openDingTalkId domains are not promoted."}, + "minutes speaker replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +speaker-replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes audio-memo list": {"scoped_aliases":{"limit":"max","page-size":"max","max-results":"max","next-cursor":"cursor"},"block":["next-token","page-token","next-page-token"],"scope_strict":true,"note":"This command uses --max for the count bound and an integer cursor; opaque pagination tokens cannot be renamed into that integer domain."}, + "minutes tag query": {"scoped_aliases":{"id":"tag-id"},"block":["tag-ids","group-id","group-name"],"scope_strict":true,"note":"The generic identifier on this exact command is one Minutes tag ID, not a taskUuid, tag list, or group identifier."}, + "minutes replace-text": {"scoped_aliases":{"find":"search","original-text":"search","new-text":"replace","replacement-text":"replace"},"block":["text","content","body"],"scope_strict":true,"note":"Search and replacement are two distinct required roles; role-free text spellings cannot safely choose one side. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +replace-batch": {"scoped_aliases":{"pairs":"pair"},"block":["search","replace","original-text","replacement-text"],"scope_strict":true,"note":"The repeatable --pair value is encoded as source=>replacement; name normalization cannot compose it from two separate values. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +download": {"ambiguous":["destination","path","save-path"],"note":"The destination role depends on singular --output versus batch --output-dir and cannot be selected from a generic path name. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes list all": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes list mine": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes list shared": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes +search": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes +apply-permission": {"block":["policy"],"note":"Semantic permission names and numeric policy values require value conversion and cannot be passed through by renaming. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes permission apply": {"block":["ids","task-uuids","uuids","permission"],"note":"This leaf accepts one taskUuid and a numeric policy. Task lists and semantic permission names require cardinality/value conversion and cannot be passed through by renaming."}, + "minutes get info": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get summary": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get keywords": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get transcription": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get todos": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get audio": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes mind-graph create": {"block":["ids","task-uuids","uuids"],"note":"This leaf creates a mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."}, + "minutes mind-graph status": {"block":["ids","task-uuids","uuids"],"note":"This leaf queries the mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."}, + "minutes upload create": {"block":["file","file-path","path","template-ids"],"note":"The atomic create command requires file-name, byte-sized file-size, and at most one template ID. A local path needs stat/upload orchestration, and a template list cannot be renamed into the singular role."}, + "minutes upload complete": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command completes exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."}, + "minutes upload cancel": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command cancels exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."}, + "minutes +upload": {"block":["file-name","file-size","size-bytes","template-ids"],"note":"The Shortcut requires a local --file path, computes file metadata, and accepts at most one template ID; atomic metadata or template lists cannot be renamed into those roles."}, + "minutes record start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."}, + "minutes record pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes record resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes record stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."}, + "minutes +record-pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +speaker-insights": {"block":["task-ids"],"note":"--id is one Minutes taskUuid while optional --task-id is one asynchronous recovery handle; neither role accepts a list. The broad real --id is explicitly bound to the single Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +upload-and-analyze": {"scoped_aliases":{"task-uuid":"resume-id","uuid":"resume-id"},"block":["file-name","file-size","size-bytes","id","resume-ids","template-ids"],"scope_strict":true,"note":"--resume-id is one already-created taskUuid and --template-id is singular. Explicit taskUuid spellings may resume one task, while generic --id, list spellings, and atomic file metadata remain blocked against route/cardinality confusion."}, + "minutes +action-items": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +detail": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +export-pack": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +mindmap": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +record-wrap-up": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +transcript": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +update": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes update summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes update title": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "todo task create-sub": {"bind":{"parent-id":"todo_task_id"},"scoped_aliases":{"parent-task-id":"parent-id"},"ambiguous":["task"],"scope_strict":true,"note":"This command has a parent taskId and a new child title. Reviewed task-id/todo-id spellings reduce to --parent-id, while role-free --task cannot choose between the parent target and child title and must stop before execution."}, + "todo task update": {"ambiguous":["task"],"note":"This command has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."}, + "todo +update": {"ambiguous":["task"],"note":"This shortcut has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."}, + "todo +todo-done": {"scoped_aliases":{"query":"task","keyword":"task","title":"task"},"block":["task-id","todo-id"],"scope_strict":true,"note":"The real --task is a title substring query, not taskId. Query/title spellings preserve the search text; stable IDs must use todo task done."}, + "todo task create": {"scoped_aliases":{"task":"title"},"note":"This creation command has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."}, + "todo +create": {"scoped_aliases":{"task":"title"},"note":"This creation shortcut has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."}, + "todo +assign": {"scoped_aliases":{"assignee-name":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves one human name from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."}, + "todo +assign-multi": {"scoped_aliases":{"assignee-names":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves a comma-separated list of human names from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."}, + "todo +remind": {"scoped_aliases":{"deadline":"at","due":"at","due-time":"at","title":"task"},"block":["remind-at","reminder-at","reminder-rules","reminder-time","reminder-time-stamp"],"scope_strict":true,"note":"This shortcut has one title role and its real --at writes the task deadline dueTime. Exact title/deadline spellings preserve those values; independent reminder spellings are rejected and must route to todo +reminder or todo task add-reminder/reset-reminder."}, + "todo +search": {"scoped_aliases":{"subject":"query","title":"query","task":"query"},"block":["limit","max-result","max-results","page","page-size","per-page","size","take","top"],"note":"This shortcut searches the task card subject/title substring across complete traversal. Subject/title/task spellings preserve query text; result-count and page-size names are not aliases for the real --max-pages safety cap."}, + "todo +complete": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +complete command. A status flag would falsely imply caller-selectable state; use +reopen or todo task done for the opposite state."}, + "todo +reopen": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +reopen command. A status flag would falsely imply caller-selectable state; use +complete or todo task done for the opposite state."}, + "todo +reminder": {"scoped_aliases":{"remind-at":"at","reminder-at":"at","reminder-time":"at","reminder-time-stamp":"at"},"block":["deadline","due","due-time","reminder-rules"],"note":"The real --at is the ISO-8601 customTime reminder timestamp. Reminder-time spellings preserve that value; task deadlines and a JSON reminder-rules array are different roles or structures."}, + "todo task add-reminder": {"scoped_aliases":{"at":"reminder-time-stamp","reminder-at":"reminder-time-stamp","reminder-time":"reminder-time-stamp"},"block":["deadline","due","due-time","reminder-rules"],"note":"This command writes one independent reminder rule. --at is the same ISO-8601 customTime value as --reminder-time-stamp; a task deadline or JSON rules array cannot be reduced to one atomic reminder field. The inherited real --remind-at remains command-owned and cannot be intercepted by PreParse."}, + "todo task reset-reminder": {"block":["at","base-time","deadline","due","due-date-offset","due-time","reminder-time-stamp"],"note":"This command accepts one --reminder-rules JSON array. Atomic reminder fields or task deadlines require value construction and are not name-only aliases. The inherited real --remind-at cannot be blocked by PreParse."}, + "todo task list": {"scoped_aliases":{"limit":"size","max-results":"size","page-number":"page","priorities":"priority"},"block":["plan-finish-end","plan-finish-start","priority-id","priority-name"],"scope_strict":true,"note":"Here --size is the total returned-item target and auto-pages beyond 20, so only total-limit spellings are mapped. --page-number preserves the one-based page value. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The sibling shortcut's plan-finish-start/end use Unix milliseconds and are blocked because this command requires ISO-8601 date strings."}, + "todo +get-my-tasks": {"scoped_aliases":{"page-number":"page","page-size":"size","per-page":"size","priorities":"priority"},"block":["limit","max-result","max-results","plan-finish-date-end","plan-finish-date-start","priority-id","priority-name","take","top"],"note":"This shortcut's --size is per-page, and --all ignores pageSize; only explicit per-page spellings are aliases. Total-limit spellings are blocked. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The command requires Unix millisecond plan-finish-start/end values, so the stable command's date-named ISO-8601 flags cannot be converted by name-only normalization."}, + "todo +list-comment": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."}, + "todo comment list": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."}, + "todo +get-related-tasks": {"scoped_aliases":{"todo-status":"status"},"block":["done","is-done"],"note":"This shortcut accepts the service enum status value such as TODO, not a boolean completion flag. The product-qualified spelling is safe; boolean spellings are rejected."}, + "todo tag add": {"block":["tag-ids","tag-names","tags"],"note":"The command requires existing tag code values. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."}, + "todo tag delete": {"block":["tag-ids","tag-names","tags"],"note":"The command deletes tag definitions by existing tag code. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."}, + "todo tag update": {"block":["tag-codes","tag-ids","tag-names","tags"],"note":"The real --user-tags is a JSON array of tag objects. A flat list of codes, names, or IDs cannot be renamed into that structure."}, + "wiki +resolve-space": {"bind":{"name":"search_query"},"scoped_aliases":{"knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--name is a knowledge-space name search string, not workspaceId. Search spellings preserve the unchanged text; ID spellings stop before lookup."}, + "wiki +wiki-new-doc": {"scoped_aliases":{"doc-title":"title","document-title":"title","knowledge-base-name":"space","space-name":"space","wiki-name":"space","workspace-name":"space"},"ambiguous":["name"],"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--space is a knowledge-space name search string and --title is the new document title. Role-free --name cannot choose between them, and workspace/storage IDs are not accepted by this name-resolving shortcut."}, + "wiki +node-get": {"scoped_aliases":{"doc-id":"node","document-id":"node","document-url":"node","id":"node","node-id":"node","url":"node","wiki-node-id":"node"},"block":["dentry-id","folder","folder-id","knowledge-base-id","space-id","workspace","workspace-id"],"scope_strict":true,"note":"This exact shortcut has one Wiki node target and explicitly accepts nodeId or URL. Numeric dentryId, folder and workspace identifiers are different roles."}, + "wiki +node-copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before copy."}, + "wiki +move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before move."}, + "wiki +move-to-drive": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The source is one Wiki node while --folder is a Drive destination folder. --node-id preserves the source value; workspace and storage-space identifiers are not substitutes for either role."}, + "wiki +node-delete": {"scoped_aliases":{"document-id":"node","node-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The destructive target node and the workspace confirmation scope are distinct required IDs. --node-id is explicit and value-preserving; generic --id cannot choose one and must stop before confirmation."}, + "wiki node copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."}, + "wiki node move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."}, + "wiki node delete": {"scoped_aliases":{"document-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The node target and workspace authorization scope are distinct IDs. Native hidden node/workspace aliases remain native; generic --id must stop before deletion."}, + "wiki +node-list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder","next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","parent-folder-id":"folder","parent-node-id":"folder","per-page":"limit"},"ambiguous":["id","max-result","max-results","size","take","top"],"block":["dentry-id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Workspace and optional parent folder are separate IDs. Per-page --limit and aggregate --max-items are separate limits; generic maximum spellings cannot choose one."}, + "wiki +node-create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","parent-folder-id":"folder","parent-node-id":"folder","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Workspace, parent folder, node display name and node type are distinct roles. Node-type aliases stay local; search and space-type spellings are rejected."}, + "wiki node list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder"},"ambiguous":["id"],"block":["dentry-id","max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden parent/pagination/workspace aliases remain native. This leaf is single-page; Shortcut auto-page controls must not be accepted on the native command."}, + "wiki node create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Native hidden parent/workspace aliases remain native. Workspace, parent folder, display name and node type remain distinct roles."}, + "wiki +node-search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"The command searches within one workspace by unchanged keyword text. A node ID or role-free name is not a workspace-scoped search parameter."}, + "wiki node search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"Native hidden query/pagination/workspace aliases remain native. Search text, workspace and node identifiers are distinct roles."}, + "wiki +space-create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"This creates a new knowledge-base container. Name and plain description aliases preserve text; existing workspace IDs, search queries and node titles are different roles."}, + "wiki space create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden --description remains native. This command creates a container; existing workspace IDs, search queries and node titles are not creation names."}, + "wiki +space-list": {"scoped_aliases":{"next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","per-page":"limit","space-type":"type","wiki-space-type":"type"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","knowledge-base-id","node-type","offset","page","space","space-id","storage-space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut lists only orgWikiSpace/myWikiSpace and has separate per-page and aggregate limits. Drive storage types belong to native wiki space list."}, + "wiki space list": {"scoped_aliases":{"space-type":"type"},"block":["id","knowledge-base-id","max-items","node-type","offset","page","page-all","page-delay","page-limit","space","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This composite native leaf can list Wiki or Drive storage spaces by --type but is single-page. A target workspace ID and Shortcut auto-page controls are not accepted."}, + "wiki +space-search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","wiki-name":"query","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","space-type","type","wiki-space-type","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut searches organization knowledge bases by keyword and does not expose native --type myWikiSpace routing. IDs and type selectors must not be rewritten into query text."}, + "wiki space search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","space-type":"type","wiki-name":"query","wiki-space-type":"type","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden query/pagination aliases remain native. Name spellings are unchanged search text, while --type myWikiSpace is a distinct route selector."}, + "wiki +space-get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"This exact command has one knowledge-base workspace target, so role-free --id is safe locally. Drive storage-space, node and folder IDs remain separate domains."}, + "wiki space get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. This exact leaf has one knowledge-base workspace target; other ID domains are blocked."}, + "wiki +delete-space": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"The destructive command has one confirmed knowledge-base workspace target. Drive storage-space, node and folder IDs must stop before confirmation."}, + "wiki space delete": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. The destructive target is one knowledge-base workspace, not a Drive space or child node."}, + "wiki +feed-list": {"scoped_aliases":{"page-size":"limit","per-page":"limit"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Per-page --limit and aggregate --max-items are separate bounds. Feed scope is one workspace; node and storage-space IDs are different roles."}, + "wiki feed list": {"scoped_aliases":{"id":"workspace"},"block":["max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. This leaf is single-page, and Shortcut auto-page controls are not accepted."}, + "wiki +member-add": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member add": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-update": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member update": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."}, + "wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."} }, "validation_fixture": { @@ -842,7 +960,150 @@ {"command":"calendar +update","emitted":"remove-user-ids","expect":"remove-attendees","via":"override:scoped_strict"}, {"command":"calendar +update","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, {"command":"calendar +update","emitted":"attendees","expect":"did-you-mean:ambiguous","via":"guard:add-vs-remove-role"}, - {"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"} + {"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"}, + {"command":"minutes update title","emitted":"task-uuid","expect":"id","via":"concept:minutes_task_uuid"}, + {"command":"minutes +record-pause","emitted":"uuid","expect":"id","via":"concept:minutes_task_uuid"}, + {"command":"minutes +latest","emitted":"query","expect":"keyword","via":"concept:search_query"}, + {"command":"minutes +transcript","emitted":"query","expect":"keyword","via":"concept:search_query+mutually-exclusive-id"}, + {"command":"minutes +detail","emitted":"task-uuids","expect":"ids","via":"concept:minutes_task_uuids"}, + {"command":"minutes get batch","emitted":"uuids","expect":"ids","via":"concept:minutes_task_uuids"}, + {"command":"minutes +list-all","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"minutes +search","emitted":"next-token","expect":"cursor","via":"concept:page_cursor"}, + {"command":"minutes list mine","emitted":"start-time","expect":"start","via":"concept:time_start"}, + {"command":"minutes list all","emitted":"end-time","expect":"end","via":"concept:time_end"}, + {"command":"minutes +summary","emitted":"text","expect":"content","via":"concept:content_text"}, + {"command":"minutes hot-word add","emitted":"hot-words","expect":"words","via":"concept:minutes_hot_words"}, + {"command":"minutes upload complete","emitted":"upload-id","expect":"session-id","via":"concept:minutes_upload_session_id"}, + {"command":"minutes +share","emitted":"user-ids","expect":"member-uids","via":"override:bind-user-id-list"}, + {"command":"minutes +share","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:member-list-cardinality"}, + {"command":"minutes +speaker-replace","emitted":"user-id","expect":"target-uid","via":"override:bind-target-user"}, + {"command":"minutes +speaker-replace","emitted":"staff-id","expect":"did-you-mean:blocked","via":"guard:user-id-value-domain"}, + {"command":"minutes audio-memo list","emitted":"limit","expect":"max","via":"override:scoped-page-size"}, + {"command":"minutes audio-memo list","emitted":"next-token","expect":"did-you-mean:blocked","via":"guard:integer-cursor-vs-token"}, + {"command":"minutes tag query","emitted":"id","expect":"tag-id","via":"override:scoped-tag-id"}, + {"command":"minutes replace-text","emitted":"original-text","expect":"search","via":"override:scoped-search-role"}, + {"command":"minutes replace-text","emitted":"text","expect":"did-you-mean:blocked","via":"guard:search-vs-replacement-role"}, + {"command":"minutes +replace-batch","emitted":"pairs","expect":"pair","via":"override:scoped-pair-cardinality"}, + {"command":"minutes +replace-batch","emitted":"search","expect":"did-you-mean:blocked","via":"guard:requires-pair-value-composition"}, + {"command":"minutes +download","emitted":"destination","expect":"did-you-mean:ambiguous","via":"guard:output-role"}, + {"command":"minutes +detail","emitted":"url","expect":"did-you-mean:blocked","via":"guard:url-needs-value-extraction"}, + {"command":"minutes +apply-permission","emitted":"policy","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"}, + {"command":"minutes permission apply","emitted":"permission","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"}, + {"command":"minutes upload create","emitted":"file","expect":"did-you-mean:blocked","via":"guard:file-path-needs-orchestration"}, + {"command":"minutes +upload","emitted":"file-size","expect":"did-you-mean:blocked","via":"guard:file-metadata-vs-path"}, + {"command":"minutes record start","emitted":"upload-id","expect":"did-you-mean:blocked","via":"guard:session-value-domain"}, + {"command":"minutes +upload-and-analyze","emitted":"task-uuid","expect":"resume-id","via":"override:resume-task-uuid"}, + {"command":"minutes get info","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get summary","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get keywords","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get transcription","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get todos","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get audio","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes mind-graph create","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes mind-graph status","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes permission apply","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes permission add","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"}, + {"command":"minutes permission remove","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"}, + {"command":"minutes upload complete","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"}, + {"command":"minutes upload cancel","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"}, + {"command":"minutes record start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes speaker replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"}, + {"command":"minutes +speaker-replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"}, + {"command":"minutes tag query","emitted":"tag-ids","expect":"did-you-mean:blocked","via":"guard:single-tag"}, + {"command":"minutes +speaker-insights","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-async-task"}, + {"command":"minutes +upload-and-analyze","emitted":"resume-ids","expect":"did-you-mean:blocked","via":"guard:single-resume-task"}, + {"command":"minutes upload create","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"minutes +upload","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"minutes +upload-and-analyze","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"todo task get","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo task get","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task get","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task get","emitted":"todo-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task create-sub","emitted":"task-id","expect":"parent-id","via":"concept:todo_task_id+bind"}, + {"command":"todo task create-sub","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-parent-task-id"}, + {"command":"todo task create-sub","emitted":"parent-task-id","expect":"parent-id","via":"override:parent-task-id"}, + {"command":"todo task create-sub","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:parent-target-vs-child-title"}, + {"command":"todo task create","emitted":"task","expect":"title","via":"override:creation-title"}, + {"command":"todo +create","emitted":"task","expect":"title","via":"override:creation-title"}, + {"command":"todo +assign","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo +assign-multi","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo +assign","emitted":"assignee-name","expect":"to","via":"override:human-name"}, + {"command":"todo +assign-multi","emitted":"assignee-names","expect":"to","via":"override:human-name-list"}, + {"command":"todo +update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"}, + {"command":"todo task update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"}, + {"command":"todo +remind","emitted":"due","expect":"at","via":"override:deadline-to-at"}, + {"command":"todo +remind","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo task create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"}, + {"command":"todo +create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"}, + {"command":"todo task add-executor","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"}, + {"command":"todo task add-executor","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-executor"}, + {"command":"todo task add-executor","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:executor-vs-participant-role"}, + {"command":"todo +create","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"}, + {"command":"todo task add-participant","emitted":"participant-ids","expect":"participants","via":"concept:todo_participant_ids"}, + {"command":"todo task add-participant","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-participant"}, + {"command":"todo task add-participant","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:participant-vs-executor-role"}, + {"command":"todo task update","emitted":"status","expect":"done","via":"concept:todo_completion_state"}, + {"command":"todo task update","emitted":"is-done","expect":"done","via":"concept:todo_completion_state"}, + {"command":"todo task done","emitted":"done","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +search","emitted":"done","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +search","emitted":"todo-status","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +get-related-tasks","emitted":"todo-status","expect":"status","via":"override:enum-status"}, + {"command":"todo +get-related-tasks","emitted":"done","expect":"did-you-mean:blocked","via":"guard:boolean-vs-enum-status"}, + {"command":"todo +due-today","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-my-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-related-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo task list","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-my-tasks","emitted":"priorities","expect":"priority","via":"override:priority-list"}, + {"command":"todo comment add","emitted":"text","expect":"content","via":"concept:content_text"}, + {"command":"todo +comment","emitted":"body","expect":"content","via":"concept:content_text"}, + {"command":"todo comment list","emitted":"page-size","expect":"size","via":"concept:pagination_size"}, + {"command":"todo +get-my-tasks","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"todo +get-my-tasks","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo +get-my-tasks","emitted":"page-size","expect":"size","via":"override:per-page-size"}, + {"command":"todo +get-my-tasks","emitted":"max-results","expect":"did-you-mean:blocked","via":"guard:total-vs-per-page"}, + {"command":"todo +list-comment","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo comment list","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo task list","emitted":"limit","expect":"size","via":"override:limit"}, + {"command":"todo task list","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo +todo-done","emitted":"query","expect":"task","via":"override:query"}, + {"command":"todo +search","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"todo +search","emitted":"title","expect":"query","via":"override:title-query"}, + {"command":"todo +search","emitted":"subject","expect":"query","via":"override:subject-query"}, + {"command":"todo +reminder","emitted":"reminder-time-stamp","expect":"at","via":"override:custom-reminder-time"}, + {"command":"todo task add-reminder","emitted":"at","expect":"reminder-time-stamp","via":"override:custom-reminder-time"}, + {"command":"todo +update","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +complete","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +reopen","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +reminder","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +todo-done","emitted":"task-id","expect":"did-you-mean:blocked","via":"guard:title-query-vs-task-id"}, + {"command":"todo task list","emitted":"plan-finish-start","expect":"did-you-mean:blocked","via":"guard:iso-vs-unix-millis"}, + {"command":"todo +get-my-tasks","emitted":"plan-finish-date-start","expect":"did-you-mean:blocked","via":"guard:unix-millis-vs-iso"}, + {"command":"todo +remind","emitted":"reminder-time-stamp","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"}, + {"command":"todo +reminder","emitted":"deadline","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"}, + {"command":"todo task reset-reminder","emitted":"due-date-offset","expect":"did-you-mean:blocked","via":"guard:atomic-vs-json-rules"}, + {"command":"todo tag update","emitted":"tag-codes","expect":"did-you-mean:blocked","via":"guard:flat-list-vs-json"}, + {"command":"todo +search","emitted":"page-size","expect":"did-you-mean:blocked","via":"guard:max-pages-vs-page-size"}, + {"command":"todo +complete","emitted":"status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +complete","emitted":"is-done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +reopen","emitted":"done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +reopen","emitted":"todo-status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"wiki +node-copy","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +move","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +move-to-drive","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +node-delete","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +member-add","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-add","emitted":"uid","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-update","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-update","emitted":"uid","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-remove","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"} ] } } From f7e2efaaa230dabaff338c7d11d7424199410a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=8F=E6=99=BA?= Date: Thu, 20 Aug 2026 13:50:18 +0800 Subject: [PATCH 204/267] ci: retrigger checks From f6d1e685e0476c2da464deb209e9e00166a4fc12 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 14:52:30 +0800 Subject: [PATCH 205/267] chore: prepare v1.0.59-beta.5 changelog --- .../1050-shortcut-functional-hardening.md | 0 .../1.0.59-beta.5}/chat-personal-emotion.md | 0 .../minutes-todo-wiki-param-aliases.md | 0 CHANGELOG.md | 13 +++++++++++++ 4 files changed, 13 insertions(+) rename .changes/{ => released/1.0.59-beta.5}/1050-shortcut-functional-hardening.md (100%) rename .changes/{ => released/1.0.59-beta.5}/chat-personal-emotion.md (100%) rename .changes/{ => released/1.0.59-beta.5}/minutes-todo-wiki-param-aliases.md (100%) diff --git a/.changes/1050-shortcut-functional-hardening.md b/.changes/released/1.0.59-beta.5/1050-shortcut-functional-hardening.md similarity index 100% rename from .changes/1050-shortcut-functional-hardening.md rename to .changes/released/1.0.59-beta.5/1050-shortcut-functional-hardening.md diff --git a/.changes/chat-personal-emotion.md b/.changes/released/1.0.59-beta.5/chat-personal-emotion.md similarity index 100% rename from .changes/chat-personal-emotion.md rename to .changes/released/1.0.59-beta.5/chat-personal-emotion.md diff --git a/.changes/minutes-todo-wiki-param-aliases.md b/.changes/released/1.0.59-beta.5/minutes-todo-wiki-param-aliases.md similarity index 100% rename from .changes/minutes-todo-wiki-param-aliases.md rename to .changes/released/1.0.59-beta.5/minutes-todo-wiki-param-aliases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4377f4b1..4fa7df60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.5] - 2026-08-20 + +### Added + +- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting. + +- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products. + +### Fixed + +- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations. + + ## [1.0.59-beta.4] - 2026-08-20 ### Added From cefc5c005cb0b9f6e1e5dd192a7e0f53fa52cd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 15:14:39 +0800 Subject: [PATCH 206/267] =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0dws?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=BA=E4=B8=80=E4=B8=AA=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changes/oa-approval-attachment-upload.md | 3 +- .review_diff_oa_attachment.txt | 857 ++++++++++++++++++ .../app/schema_oa_attachment_contract_test.go | 71 +- internal/helpers/oa.go | 241 +++-- internal/helpers/oa_attachment_test.go | 315 +++++-- .../helpers/polling_coverage_more_test.go | 8 + skills/mono/references/products/oa.md | 35 +- skills/multi/dingtalk-misc/references/oa.md | 35 +- .../oa_attachment_delivery_policy_test.go | 5 +- 9 files changed, 1303 insertions(+), 267 deletions(-) create mode 100644 .review_diff_oa_attachment.txt diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md index f2bbe2fd..6d4089c7 100644 --- a/.changes/oa-approval-attachment-upload.md +++ b/.changes/oa-approval-attachment-upload.md @@ -2,5 +2,4 @@ category: Added --- -- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`). -- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`). +- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. diff --git a/.review_diff_oa_attachment.txt b/.review_diff_oa_attachment.txt new file mode 100644 index 00000000..42ef58a9 --- /dev/null +++ b/.review_diff_oa_attachment.txt @@ -0,0 +1,857 @@ +diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md +index f2bbe2fd..6d4089c7 100644 +--- a/.changes/oa-approval-attachment-upload.md ++++ b/.changes/oa-approval-attachment-upload.md +@@ -2,5 +2,4 @@ + category: Added + --- + +-- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`). +-- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`). ++- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. +diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go +index e175c275..33a0f359 100644 +--- a/internal/app/schema_oa_attachment_contract_test.go ++++ b/internal/app/schema_oa_attachment_contract_test.go +@@ -54,30 +54,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t + "spaceId": "integer", "agentId": "integer", "class": "string", + }, + }, +- { +- cliPath: "oa approval attachment init-upload", +- canonical: "oa.init_attachment_upload_info", +- rpc: "init_attachment_upload_info", +- description: "初始化审批附件上传信息,返回 OSS 上传凭证", +- effect: "read", +- resultType: "object", +- resultFields: map[string]string{ +- "uploadKey": "string", "resourceUrls": "array", "storageDriver": "string", +- "expirationSeconds": "integer", "region": "string", "class": "string", +- }, +- }, +- { +- cliPath: "oa approval attachment commit-upload", +- canonical: "oa.commit_attachment_upload_info", +- rpc: "commit_attachment_upload_info", +- description: "提交附件上传信息,完成附件上传流程", +- effect: "write", +- resultType: "object", +- resultFields: map[string]string{ +- "spaceId": "integer", "fileName": "string", "fileSize": "integer", +- "class": "string", "fileType": "string", "fileId": "string", +- }, +- }, + } + + for _, test := range tests { +@@ -160,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t + } + } + ++// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的 ++// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT, ++// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。 ++func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) { ++ snapshot := fullSchemaSnapshotForTest(t) ++ tool := snapshot.Tools["oa.attachment_upload"] ++ if tool == nil { ++ t.Fatal("oa.attachment_upload is missing from final Schema") ++ } ++ if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" { ++ t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got) ++ } ++ if got := schemaContractString(tool["interface_mode"]); got != "composite" { ++ t.Fatalf("interface_mode = %q, want composite", got) ++ } ++ if got := schemaContractString(tool["availability"]); got != "available" { ++ t.Fatalf("availability = %q, want available", got) ++ } ++ if got := schemaContractString(tool["interface_reason"]); got == "" { ++ t.Fatal("composite upload command must document an interface reason") ++ } ++ if got := schemaContractString(tool["effect"]); got != "write" { ++ t.Fatalf("effect = %q, want write", got) ++ } ++ if got := schemaContractString(tool["risk"]); got != "low" { ++ t.Fatalf("risk = %q, want low", got) ++ } ++ if got := schemaContractString(tool["confirmation"]); got != "not_required" { ++ t.Fatalf("confirmation = %q, want not_required", got) ++ } ++ parameters := schemaContractMap(tool["parameters"]) ++ for _, flag := range []string{"file", "file-name", "md5"} { ++ if parameters[flag] == nil { ++ t.Fatalf("upload --%s is missing from final Schema", flag) ++ } ++ } ++ if required, _ := parameters["file"]["required"].(bool); !required { ++ t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"]) ++ } ++ result := schemaContractMap(tool["result"]) ++ dataSchema := schemaContractMap(result["data_schema"]) ++ properties := schemaContractMap(dataSchema["properties"]) ++ if properties["fileId"] == nil { ++ t.Fatal("upload Result data_schema is missing fileId") ++ } ++} ++ + func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any { + t.Helper() + result, ok := tool["result"].(map[string]any) +diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go +index 4326b493..d1404700 100644 +--- a/internal/helpers/oa.go ++++ b/internal/helpers/oa.go +@@ -2,12 +2,16 @@ package helpers + + import ( + "bytes" ++ "context" + "encoding/json" + "errors" + "fmt" + "io" ++ "os" ++ "path/filepath" + "strconv" + "strings" ++ "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" +@@ -125,7 +129,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { + } + + func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { +- data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) ++ return callOAAttachmentResultCtx(cmd.Context(), tool, args) ++} ++ ++// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context ++// (例如 upload 命令的 10 分钟超时):调用 oa/,提取 result 并包装为 output.Success。 ++func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) { ++ data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args) + if err != nil { + return nil, err + } +@@ -140,13 +150,106 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any + return output.Success(result), nil + } + +-// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的 +-// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。 +-func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error { +- if size, _ := cmd.Flags().GetInt("file-size"); size < 0 { +- return apperrors.NewValidation("--file-size 必须大于等于 0") ++// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 ++// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、 ++// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。 ++func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) { ++ var data map[string]any ++ if err = json.Unmarshal([]byte(text), &data); err != nil { ++ return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err)) + } +- return nil ++ result, ok := data["result"].(map[string]any) ++ if !ok { ++ return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result") ++ } ++ ++ uploadKey, _ = result["uploadKey"].(string) ++ if strings.TrimSpace(uploadKey) == "" { ++ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey") ++ } ++ ++ rawURLs, ok := result["resourceUrls"].([]any) ++ if !ok || len(rawURLs) == 0 { ++ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls") ++ } ++ resourceURL, _ = rawURLs[0].(string) ++ if strings.TrimSpace(resourceURL) == "" { ++ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空") ++ } ++ ++ headers = make(map[string]string) ++ if rawHeaders, ok := result["headers"].(map[string]any); ok { ++ for key, value := range rawHeaders { ++ if str, ok := value.(string); ok { ++ headers[key] = str ++ } ++ } ++ } ++ return resourceURL, headers, uploadKey, nil ++} ++ ++// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节 ++// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并 ++// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。 ++func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { ++ filePath := strings.TrimSpace(mustGetFlag(cmd, "file")) ++ if filePath == "" { ++ return apperrors.NewValidation("--file 不能为空") ++ } ++ info, err := os.Stat(filePath) ++ if err != nil { ++ return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err)) ++ } ++ if info.IsDir() { ++ return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath)) ++ } ++ fileSize := info.Size() ++ ++ fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name")) ++ if fileName == "" { ++ fileName = filepath.Base(filePath) ++ } ++ ++ md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5")) ++ if md5Hex == "" { ++ md5Hex, err = fileMD5Hex(filePath) ++ if err != nil { ++ return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err)) ++ } ++ } ++ ++ ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) ++ defer cancel() ++ ++ // Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。 ++ initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{ ++ "fileName": fileName, ++ "fileSize": float64(fileSize), ++ "md5": md5Hex, ++ }) ++ if err != nil { ++ return err ++ } ++ resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText) ++ if err != nil { ++ return err ++ } ++ ++ // Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。 ++ if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil { ++ return err ++ } ++ ++ // Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。 ++ result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{ ++ "fileName": fileName, ++ "uploadKey": uploadKey, ++ "fileSize": float64(fileSize), ++ }) ++ if err != nil { ++ return err ++ } ++ return output.StoreResult(cmd.Context(), result) + } + + func newOAAttachmentCommand() *cobra.Command { +@@ -327,113 +430,64 @@ func newOAAttachmentCommand() *cobra.Command { + }, + }) + +- initUploadCmd := NewLeafCommand(LeafSpec{ +- Use: "init-upload", +- Short: "初始化审批附件上传信息,使用获取到的信息上传文件", +- Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400", +- Server: "oa", +- Tool: "init_attachment_upload_info", +- OutputRollout: output.RolloutUnifiedActive, +- ResultCall: callOAAttachmentResult, +- Flags: []LeafFlag{ +- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, +- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, +- {Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true}, +- }, +- Validate: validateOAAttachmentFileSize, +- Safety: contract.SafetySpec{ +- Effect: "read", Risk: "low", +- Confirmation: "not_required", Idempotency: "idempotent", +- }, +- Contract: LeafContract{ +- Identity: contract.ToolIdentitySpec{ +- ProductID: "oa", +- Name: "init_attachment_upload_info", +- CanonicalPath: "oa.init_attachment_upload_info", +- CLIPath: "oa approval attachment init-upload", +- PrimaryCLIPath: "oa approval attachment init-upload", +- }, +- Description: "初始化审批附件上传信息,返回 OSS 上传凭证", +- Result: &contract.ResultSpec{ +- Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, +- DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`), +- }, +- Interface: &contract.InterfaceSpec{ +- Mode: "mcp", +- Availability: "available", +- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"}, +- }, +- Selection: contract.SelectionSpec{ +- AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址", +- UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"}, +- AvoidWhen: []string{ +- "文件已上传完成需要提交确认时使用 attachment commit-upload", +- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", +- }, +- Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"}, +- }, +- Parameters: []contract.ParamDecl{ +- {Name: "file-name", Property: "fileName", InterfaceType: "string"}, +- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, +- {Name: "md5", Property: "md5", InterfaceType: "string"}, +- }, +- }, +- }) ++ uploadCmd := &cobra.Command{ ++ Use: "upload", ++ Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)", ++ Long: `上传本地文件为审批附件(三步自动完成)。 + +- commitUploadCmd := NewLeafCommand(LeafSpec{ +- Use: "commit-upload", +- Short: "提交附件上传信息", +- Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400", +- Server: "oa", +- Tool: "commit_attachment_upload_info", ++流程: ++ 1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info) ++ 2. HTTP PUT 上传文件二进制到 OSS ++ 3. 提交上传信息完成入库 (oa/commit_attachment_upload_info) ++ ++--file-name 不传时默认用文件名;--md5 不传时自动计算。`, ++ Example: ` dws oa approval attachment upload --file ./合同.pdf ++ dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx ++ dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`, ++ RunE: runOAAttachmentUpload, ++ } ++ DeclareLeafMetadata(uploadCmd, LeafSpec{ + OutputRollout: output.RolloutUnifiedActive, +- ResultCall: callOAAttachmentResult, +- Flags: []LeafFlag{ +- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, +- {Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true}, +- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, +- }, +- Validate: validateOAAttachmentFileSize, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", +- Confirmation: "not_required", Idempotency: "idempotent", ++ Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", +- Name: "commit_attachment_upload_info", +- CanonicalPath: "oa.commit_attachment_upload_info", +- CLIPath: "oa approval attachment commit-upload", +- PrimaryCLIPath: "oa approval attachment commit-upload", ++ Name: "attachment_upload", ++ CanonicalPath: "oa.attachment_upload", ++ CLIPath: "oa approval attachment upload", ++ PrimaryCLIPath: "oa approval attachment upload", + }, +- Description: "提交附件上传信息,完成附件上传流程", ++ Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库", ++ DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`), + }, + Interface: &contract.InterfaceSpec{ +- Mode: "mcp", ++ Mode: "composite", + Availability: "available", +- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"}, ++ Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref", + }, + Selection: contract.SelectionSpec{ +- AgentSummary: "提交附件上传信息,确认文件上传完成", +- UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"}, ++ AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步", ++ UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"}, + AvoidWhen: []string{ +- "尚未初始化上传信息时使用 attachment init-upload", +- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", ++ "仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview", ++ "仅需获取已有附件临时下载链接时使用 attachment download-url", + }, +- Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400"}, +- }, +- Parameters: []contract.ParamDecl{ +- {Name: "file-name", Property: "fileName", InterfaceType: "string"}, +- {Name: "upload-key", Property: "uploadKey", InterfaceType: "string"}, +- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, ++ Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"}, + }, + }, + }) ++ uploadCmd.Flags().String("file", "", "本地文件路径 (必填)") ++ uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)") ++ uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)") ++ _ = uploadCmd.MarkFlagRequired("file") + +- attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd) ++ attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd) + return attachmentCmd + } + +diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go +index 8eb9e1f3..7b66ad58 100644 +--- a/internal/helpers/oa_attachment_test.go ++++ b/internal/helpers/oa_attachment_test.go +@@ -10,7 +10,9 @@ import ( + "errors" + "io" + "os" ++ "path/filepath" + "reflect" ++ "strconv" + "strings" + "testing" + +@@ -338,123 +340,174 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin + } + } + +-func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) { +- const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}` +- commandArgs := []string{ +- "approval", "attachment", "init-upload", ++const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}` ++ ++func oaUploadCommitResponse(size int64) string { ++ return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` + ++ strconv.FormatInt(size, 10) + ++ `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` ++} ++ ++// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。 ++func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) { ++ t.Helper() ++ path := filepath.Join(t.TempDir(), name) ++ if err := os.WriteFile(path, []byte(content), 0o600); err != nil { ++ t.Fatalf("write temp file: %v", err) ++ } ++ return path, int64(len(content)) ++} ++ ++// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的 ++// resourceURL 与签名 headers。 ++type capturedPut struct { ++ calls int ++ url string ++ headers map[string]string ++ filePath string ++ fileSize int64 ++} ++ ++func mockOAAttachmentPut(t *testing.T) *capturedPut { ++ t.Helper() ++ put := &capturedPut{} ++ SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { ++ put.calls++ ++ put.url = url ++ put.headers = headers ++ put.filePath = filePath ++ put.fileSize = fileSize ++ return nil ++ }) ++ t.Cleanup(func() { SetHTTPPutFile(nil) }) ++ return put ++} ++ ++func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) { ++ const content = "pdf-bytes-content" ++ filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content) ++ put := mockOAAttachmentPut(t) ++ ++ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ ++ {text: oaUploadInitResponse}, ++ {text: oaUploadCommitResponse(fileSize)}, ++ }} ++ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, ++ "approval", "attachment", "upload", ++ "--file", filePath, + "--file-name", "合同.pdf", +- "--file-size", "102400", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", ++ ) ++ if err != nil { ++ t.Fatalf("execute command: %v", err) + } +- wantArgs := map[string]any{ ++ ++ if caller.calls != 2 { ++ t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls) ++ } ++ if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" { ++ t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0]) ++ } ++ wantInit := map[string]any{ + "fileName": "合同.pdf", +- "fileSize": 102400, ++ "fileSize": float64(fileSize), + "md5": "d41d8cd98f00b204e9800998ecf8427e", + } ++ if !reflect.DeepEqual(caller.argsLog[0], wantInit) { ++ t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit) ++ } + +- t.Run("json", func(t *testing.T) { +- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} +- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) +- if err != nil { +- t.Fatalf("execute command: %v", err) +- } +- if caller.server != "oa" || caller.tool != "init_attachment_upload_info" { +- t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool) +- } +- if !reflect.DeepEqual(caller.args, wantArgs) { +- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) +- } +- var envelope map[string]any +- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { +- t.Fatalf("decode unified output: %v", err) +- } +- if envelope["ok"] != true || envelope["outcome"] != "success" { +- t.Fatalf("unified envelope = %#v", envelope) +- } +- data, ok := envelope["data"].(map[string]any) +- if !ok || data["uploadKey"] != "key-123" { +- t.Fatalf("unified data = %#v", envelope["data"]) +- } +- }) +- +- t.Run("optional md5 omitted", func(t *testing.T) { +- caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} +- if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, +- "approval", "attachment", "init-upload", +- "--file-name", "report.xlsx", +- "--file-size", "2048", +- ); err != nil { +- t.Fatalf("execute command: %v", err) +- } +- if _, exists := caller.args["md5"]; exists { +- t.Fatalf("optional md5 should be omitted: %#v", caller.args) +- } +- }) +-} ++ if put.calls != 1 { ++ t.Fatalf("httpPutFile called %d times, want 1", put.calls) ++ } ++ if put.url != "https://oss.example.test/upload" { ++ t.Fatalf("PUT url = %q, want parsed resourceURL", put.url) ++ } ++ if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" { ++ t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers) ++ } ++ if put.filePath != filePath || put.fileSize != fileSize { ++ t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize) ++ } + +-func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) { +- const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` +- commandArgs := []string{ +- "approval", "attachment", "commit-upload", +- "--file-name", "合同.pdf", +- "--upload-key", "key-123", +- "--file-size", "102400", ++ if caller.toolLog[1] != "commit_attachment_upload_info" { ++ t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1]) + } +- wantArgs := map[string]any{ ++ wantCommit := map[string]any{ + "fileName": "合同.pdf", + "uploadKey": "key-123", +- "fileSize": 102400, ++ "fileSize": float64(fileSize), ++ } ++ if !reflect.DeepEqual(caller.argsLog[1], wantCommit) { ++ t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit) + } + +- t.Run("json", func(t *testing.T) { +- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} +- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) +- if err != nil { +- t.Fatalf("execute command: %v", err) +- } +- if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" { +- t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool) +- } +- if !reflect.DeepEqual(caller.args, wantArgs) { +- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) +- } +- var envelope map[string]any +- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { +- t.Fatalf("decode unified output: %v", err) +- } +- if envelope["ok"] != true || envelope["outcome"] != "success" { +- t.Fatalf("unified envelope = %#v", envelope) +- } +- data, ok := envelope["data"].(map[string]any) +- if !ok || data["fileId"] != "file-abc" { +- t.Fatalf("unified data = %#v", envelope["data"]) +- } +- }) ++ var envelope map[string]any ++ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { ++ t.Fatalf("decode unified output: %v", err) ++ } ++ if envelope["ok"] != true || envelope["outcome"] != "success" { ++ t.Fatalf("unified envelope = %#v", envelope) ++ } ++ data, ok := envelope["data"].(map[string]any) ++ if !ok || data["fileId"] != "file-abc" { ++ t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"]) ++ } ++} ++ ++func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) { ++ const content = "auto-md5-content" ++ filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content) ++ wantMD5, err := fileMD5Hex(filePath) ++ if err != nil { ++ t.Fatalf("compute expected md5: %v", err) ++ } ++ mockOAAttachmentPut(t) ++ ++ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ ++ {text: oaUploadInitResponse}, ++ {text: oaUploadCommitResponse(fileSize)}, ++ }} ++ // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。 ++ if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, ++ "approval", "attachment", "upload", ++ "--file", filePath, ++ ); err != nil { ++ t.Fatalf("execute command: %v", err) ++ } ++ ++ if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" { ++ t.Fatalf("default fileName = %#v, want basename report.xlsx", got) ++ } ++ if got := caller.argsLog[0]["md5"]; got != wantMD5 { ++ t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5) ++ } ++ if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) { ++ t.Fatalf("fileSize = %#v, want number %d", got, fileSize) ++ } + } + + func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { ++ existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x") + tests := []struct { + name string + args []string + }{ +- {name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}}, +- {name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}}, +- {name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}}, +- {name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}}, +- {name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}}, +- {name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}}, +- {name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}}, ++ {name: "missing file", args: []string{"approval", "attachment", "upload"}}, ++ {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}}, ++ {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { ++ mockOAAttachmentPut(t) + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, test.args...) + if err == nil { + t.Fatalf("%s unexpectedly succeeded", test.name) + } + if caller.calls != 0 { +- t.Fatalf("missing required flag made %d MCP call(s)", caller.calls) ++ t.Fatalf("invalid upload made %d MCP call(s)", caller.calls) + } + }) + } +diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go +index 8b58bdbf..5cc02f26 100644 +--- a/internal/helpers/polling_coverage_more_test.go ++++ b/internal/helpers/polling_coverage_more_test.go +@@ -27,6 +27,11 @@ type scriptedToolCaller struct { + server string + tool string + args map[string]any ++ // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit) ++ // can assert each invocation instead of only the last one captured above. ++ serverLog []string ++ toolLog []string ++ argsLog []map[string]any + } + + func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { +@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri + c.server = serverID + c.tool = toolName + c.args = args ++ c.serverLog = append(c.serverLog, serverID) ++ c.toolLog = append(c.toolLog, toolName) ++ c.argsLog = append(c.argsLog, args) + if len(c.steps) == 0 { + return &edition.ToolResult{}, nil + } +diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md +index a0cb242b..6add42e9 100644 +--- a/skills/mono/references/products/oa.md ++++ b/skills/mono/references/products/oa.md +@@ -34,8 +34,7 @@ Flags: + - 需要单个附件的临时下载链接:`attachment download-url` + - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` + - 需要在审批场景内批量预览附件:`attachment authorize-preview` +-- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` +-- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` ++- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` + + #### 获取审批附件临时下载链接 + +@@ -80,35 +79,20 @@ Flags: + + 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + +-#### 初始化审批附件上传 ++#### 上传本地文件为审批附件 + + ``` + Usage: +- dws oa approval attachment init-upload [flags] ++ dws oa approval attachment upload [flags] + Example: +- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json ++ dws oa approval attachment upload --file ./合同.pdf --format json + Flags: +- --file-name string 完整文件名,例如 合同.pdf (必填) +- --file-size int 文件字节数,必须大于等于 0 (必填) +- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) ++ --file string 本地文件路径 (必填) ++ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) ++ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) + ``` + +-该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 +- +-#### 提交附件上传信息 +- +-``` +-Usage: +- dws oa approval attachment commit-upload [flags] +-Example: +- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json +-Flags: +- --file-name string 完整文件名,例如 合同.pdf (必填) +- --upload-key string 初始化接口返回的 uploadKey (必填) +- --file-size int 文件字节数,必须大于等于 0 (必填) +-``` +- +-该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 ++该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 + + ### 同意审批 + +@@ -753,8 +737,7 @@ Flags: + 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) + 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) + 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +-用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) +-用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) ++用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) + 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` + 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` + 用户说"撤回审批/取消审批" → `approval revoke` +diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md +index 959fb48f..e8800eeb 100644 +--- a/skills/multi/dingtalk-misc/references/oa.md ++++ b/skills/multi/dingtalk-misc/references/oa.md +@@ -38,8 +38,7 @@ Flags: + - 需要单个附件的临时下载链接:`attachment download-url` + - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` + - 需要在审批场景内批量预览附件:`attachment authorize-preview` +-- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` +-- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` ++- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` + + #### 获取审批附件临时下载链接 + +@@ -84,35 +83,20 @@ Flags: + + 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + +-#### 初始化审批附件上传 ++#### 上传本地文件为审批附件 + + ``` + Usage: +- dws oa approval attachment init-upload [flags] ++ dws oa approval attachment upload [flags] + Example: +- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json ++ dws oa approval attachment upload --file ./合同.pdf --format json + Flags: +- --file-name string 完整文件名,例如 合同.pdf (必填) +- --file-size int 文件字节数,必须大于等于 0 (必填) +- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) ++ --file string 本地文件路径 (必填) ++ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) ++ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) + ``` + +-该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 +- +-#### 提交附件上传信息 +- +-``` +-Usage: +- dws oa approval attachment commit-upload [flags] +-Example: +- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json +-Flags: +- --file-name string 完整文件名,例如 合同.pdf (必填) +- --upload-key string 初始化接口返回的 uploadKey (必填) +- --file-size int 文件字节数,必须大于等于 0 (必填) +-``` +- +-该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 ++该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 + + ### 同意审批 + +@@ -756,8 +740,7 @@ Flags: + 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) + 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) + 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +-用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) +-用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) ++用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) + 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` + 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` + 用户说"撤回审批/取消审批" → `approval revoke` +diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go +index 25dd8b64..ba74452e 100644 +--- a/test/unit/oa_attachment_delivery_policy_test.go ++++ b/test/unit/oa_attachment_delivery_policy_test.go +@@ -37,8 +37,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { + "download-url": "get_attachment_download_url", + "authorize-download": "auth_download_file", + "authorize-preview": "auth_preview_attachment", +- "init-upload": "init_attachment_upload_info", +- "commit-upload": "commit_attachment_upload_info", + } + gotTools := make(map[string]string, len(wantTools)) + foundConstructor := false +@@ -80,8 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { + "dws oa approval attachment download-url", + "dws oa approval attachment authorize-download", + "dws oa approval attachment authorize-preview", +- "dws oa approval attachment init-upload", +- "dws oa approval attachment commit-upload", ++ "dws oa approval attachment upload", + "临时下载链接", + "最多 10", + "最多 20", diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go index e175c275..33a0f359 100644 --- a/internal/app/schema_oa_attachment_contract_test.go +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -54,30 +54,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t "spaceId": "integer", "agentId": "integer", "class": "string", }, }, - { - cliPath: "oa approval attachment init-upload", - canonical: "oa.init_attachment_upload_info", - rpc: "init_attachment_upload_info", - description: "初始化审批附件上传信息,返回 OSS 上传凭证", - effect: "read", - resultType: "object", - resultFields: map[string]string{ - "uploadKey": "string", "resourceUrls": "array", "storageDriver": "string", - "expirationSeconds": "integer", "region": "string", "class": "string", - }, - }, - { - cliPath: "oa approval attachment commit-upload", - canonical: "oa.commit_attachment_upload_info", - rpc: "commit_attachment_upload_info", - description: "提交附件上传信息,完成附件上传流程", - effect: "write", - resultType: "object", - resultFields: map[string]string{ - "spaceId": "integer", "fileName": "string", "fileSize": "integer", - "class": "string", "fileType": "string", "fileId": "string", - }, - }, } for _, test := range tests { @@ -160,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t } } +// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的 +// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT, +// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。 +func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + tool := snapshot.Tools["oa.attachment_upload"] + if tool == nil { + t.Fatal("oa.attachment_upload is missing from final Schema") + } + if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" { + t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Fatalf("interface_mode = %q, want composite", got) + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Fatalf("availability = %q, want available", got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Fatal("composite upload command must document an interface reason") + } + if got := schemaContractString(tool["effect"]); got != "write" { + t.Fatalf("effect = %q, want write", got) + } + if got := schemaContractString(tool["risk"]); got != "low" { + t.Fatalf("risk = %q, want low", got) + } + if got := schemaContractString(tool["confirmation"]); got != "not_required" { + t.Fatalf("confirmation = %q, want not_required", got) + } + parameters := schemaContractMap(tool["parameters"]) + for _, flag := range []string{"file", "file-name", "md5"} { + if parameters[flag] == nil { + t.Fatalf("upload --%s is missing from final Schema", flag) + } + } + if required, _ := parameters["file"]["required"].(bool); !required { + t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"]) + } + result := schemaContractMap(tool["result"]) + dataSchema := schemaContractMap(result["data_schema"]) + properties := schemaContractMap(dataSchema["properties"]) + if properties["fileId"] == nil { + t.Fatal("upload Result data_schema is missing fileId") + } +} + func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any { t.Helper() result, ok := tool["result"].(map[string]any) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 4326b493..b4c1cf00 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -2,12 +2,16 @@ package helpers import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "os" + "path/filepath" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -125,7 +129,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { } func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { - data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) + return callOAAttachmentResultCtx(cmd.Context(), tool, args) +} + +// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context +// (例如 upload 命令的 10 分钟超时):调用 oa/,提取 result 并包装为 output.Success。 +func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) { + data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args) if err != nil { return nil, err } @@ -140,13 +150,111 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any return output.Success(result), nil } -// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的 -// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。 -func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error { - if size, _ := cmd.Flags().GetInt("file-size"); size < 0 { - return apperrors.NewValidation("--file-size 必须大于等于 0") +// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 +// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、 +// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。 +func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) { + var data map[string]any + if err = json.Unmarshal([]byte(text), &data); err != nil { + return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err)) } - return nil + result, ok := data["result"].(map[string]any) + if !ok { + return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result") + } + + uploadKey, _ = result["uploadKey"].(string) + if strings.TrimSpace(uploadKey) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey") + } + + rawURLs, ok := result["resourceUrls"].([]any) + if !ok || len(rawURLs) == 0 { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls") + } + resourceURL, _ = rawURLs[0].(string) + if strings.TrimSpace(resourceURL) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空") + } + + rawHeaders, ok := result["headers"].(map[string]any) + if !ok { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers") + } + headers = make(map[string]string) + for key, value := range rawHeaders { + if str, ok := value.(string); ok { + headers[key] = str + } + } + if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date") + } + return resourceURL, headers, uploadKey, nil +} + +// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节 +// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并 +// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。 +func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { + filePath := strings.TrimSpace(mustGetFlag(cmd, "file")) + if filePath == "" { + return apperrors.NewValidation("--file 不能为空") + } + info, err := os.Stat(filePath) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err)) + } + if info.IsDir() { + return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath)) + } + fileSize := info.Size() + + fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + + md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5")) + if md5Hex == "" { + md5Hex, err = fileMD5Hex(filePath) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err)) + } + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) + defer cancel() + + // Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。 + initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{ + "fileName": fileName, + "fileSize": float64(fileSize), + "md5": md5Hex, + }) + if err != nil { + return err + } + resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText) + if err != nil { + return err + } + + // Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。 + if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil { + return err + } + + // Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。 + result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{ + "fileName": fileName, + "uploadKey": uploadKey, + "fileSize": float64(fileSize), + }) + if err != nil { + return err + } + return output.StoreResult(cmd.Context(), result) } func newOAAttachmentCommand() *cobra.Command { @@ -327,113 +435,64 @@ func newOAAttachmentCommand() *cobra.Command { }, }) - initUploadCmd := NewLeafCommand(LeafSpec{ - Use: "init-upload", - Short: "初始化审批附件上传信息,使用获取到的信息上传文件", - Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400", - Server: "oa", - Tool: "init_attachment_upload_info", - OutputRollout: output.RolloutUnifiedActive, - ResultCall: callOAAttachmentResult, - Flags: []LeafFlag{ - {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, - {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, - {Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true}, - }, - Validate: validateOAAttachmentFileSize, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: LeafContract{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "init_attachment_upload_info", - CanonicalPath: "oa.init_attachment_upload_info", - CLIPath: "oa approval attachment init-upload", - PrimaryCLIPath: "oa approval attachment init-upload", - }, - Description: "初始化审批附件上传信息,返回 OSS 上传凭证", - Result: &contract.ResultSpec{ - Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`), - }, - Interface: &contract.InterfaceSpec{ - Mode: "mcp", - Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"}, - }, - Selection: contract.SelectionSpec{ - AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址", - UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"}, - AvoidWhen: []string{ - "文件已上传完成需要提交确认时使用 attachment commit-upload", - "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", - }, - Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"}, - }, - Parameters: []contract.ParamDecl{ - {Name: "file-name", Property: "fileName", InterfaceType: "string"}, - {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, - {Name: "md5", Property: "md5", InterfaceType: "string"}, - }, - }, - }) + uploadCmd := &cobra.Command{ + Use: "upload", + Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)", + Long: `上传本地文件为审批附件(三步自动完成)。 - commitUploadCmd := NewLeafCommand(LeafSpec{ - Use: "commit-upload", - Short: "提交附件上传信息", - Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400", - Server: "oa", - Tool: "commit_attachment_upload_info", +流程: + 1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info) + 2. HTTP PUT 上传文件二进制到 OSS + 3. 提交上传信息完成入库 (oa/commit_attachment_upload_info) + +--file-name 不传时默认用文件名;--md5 不传时自动计算。`, + Example: ` dws oa approval attachment upload --file ./合同.pdf + dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx + dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`, + RunE: runOAAttachmentUpload, + } + DeclareLeafMetadata(uploadCmd, LeafSpec{ OutputRollout: output.RolloutUnifiedActive, - ResultCall: callOAAttachmentResult, - Flags: []LeafFlag{ - {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, - {Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true}, - {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, - }, - Validate: validateOAAttachmentFileSize, Safety: contract.SafetySpec{ Effect: "write", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", + Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "oa", - Name: "commit_attachment_upload_info", - CanonicalPath: "oa.commit_attachment_upload_info", - CLIPath: "oa approval attachment commit-upload", - PrimaryCLIPath: "oa approval attachment commit-upload", + Name: "attachment_upload", + CanonicalPath: "oa.attachment_upload", + CLIPath: "oa approval attachment upload", + PrimaryCLIPath: "oa approval attachment upload", }, - Description: "提交附件上传信息,完成附件上传流程", + Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库", + DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, Result: &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`), }, Interface: &contract.InterfaceSpec{ - Mode: "mcp", + Mode: "composite", Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"}, + Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref", }, Selection: contract.SelectionSpec{ - AgentSummary: "提交附件上传信息,确认文件上传完成", - UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"}, + AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步", + UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"}, AvoidWhen: []string{ - "尚未初始化上传信息时使用 attachment init-upload", - "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", + "仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview", + "仅需获取已有附件临时下载链接时使用 attachment download-url", }, - Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400"}, - }, - Parameters: []contract.ParamDecl{ - {Name: "file-name", Property: "fileName", InterfaceType: "string"}, - {Name: "upload-key", Property: "uploadKey", InterfaceType: "string"}, - {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, + Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"}, }, }, }) + uploadCmd.Flags().String("file", "", "本地文件路径 (必填)") + uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)") + uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)") + _ = uploadCmd.MarkFlagRequired("file") - attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd) + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd) return attachmentCmd } diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 8eb9e1f3..5f58206f 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -10,7 +10,9 @@ import ( "errors" "io" "os" + "path/filepath" "reflect" + "strconv" "strings" "testing" @@ -338,123 +340,248 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin } } -func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) { - const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}` - commandArgs := []string{ - "approval", "attachment", "init-upload", - "--file-name", "合同.pdf", - "--file-size", "102400", - "--md5", "d41d8cd98f00b204e9800998ecf8427e", +const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}` + +func oaUploadCommitResponse(size int64) string { + return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` + + strconv.FormatInt(size, 10) + + `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` +} + +// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。 +func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) } - wantArgs := map[string]any{ + return path, int64(len(content)) +} + +// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的 +// resourceURL 与签名 headers。 +type capturedPut struct { + calls int + url string + headers map[string]string + filePath string + fileSize int64 +} + +func mockOAAttachmentPut(t *testing.T) *capturedPut { + t.Helper() + put := &capturedPut{} + SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + put.calls++ + put.url = url + put.headers = headers + put.filePath = filePath + put.fileSize = fileSize + return nil + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + return put +} + +func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) { + const content = "pdf-bytes-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content) + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: oaUploadCommitResponse(fileSize)}, + }} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "合同.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls) + } + if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" { + t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0]) + } + wantInit := map[string]any{ "fileName": "合同.pdf", - "fileSize": 102400, + "fileSize": float64(fileSize), "md5": "d41d8cd98f00b204e9800998ecf8427e", } - - t.Run("json", func(t *testing.T) { - caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} - stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) - if err != nil { - t.Fatalf("execute command: %v", err) - } - if caller.server != "oa" || caller.tool != "init_attachment_upload_info" { - t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool) - } - if !reflect.DeepEqual(caller.args, wantArgs) { - t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) - } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode unified output: %v", err) - } - if envelope["ok"] != true || envelope["outcome"] != "success" { - t.Fatalf("unified envelope = %#v", envelope) - } - data, ok := envelope["data"].(map[string]any) - if !ok || data["uploadKey"] != "key-123" { - t.Fatalf("unified data = %#v", envelope["data"]) - } - }) - - t.Run("optional md5 omitted", func(t *testing.T) { - caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} - if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, - "approval", "attachment", "init-upload", - "--file-name", "report.xlsx", - "--file-size", "2048", - ); err != nil { - t.Fatalf("execute command: %v", err) - } - if _, exists := caller.args["md5"]; exists { - t.Fatalf("optional md5 should be omitted: %#v", caller.args) - } - }) -} - -func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) { - const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` - commandArgs := []string{ - "approval", "attachment", "commit-upload", - "--file-name", "合同.pdf", - "--upload-key", "key-123", - "--file-size", "102400", + if !reflect.DeepEqual(caller.argsLog[0], wantInit) { + t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit) } - wantArgs := map[string]any{ + + if put.calls != 1 { + t.Fatalf("httpPutFile called %d times, want 1", put.calls) + } + if put.url != "https://oss.example.test/upload" { + t.Fatalf("PUT url = %q, want parsed resourceURL", put.url) + } + if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" { + t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers) + } + if put.filePath != filePath || put.fileSize != fileSize { + t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize) + } + + if caller.toolLog[1] != "commit_attachment_upload_info" { + t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1]) + } + wantCommit := map[string]any{ "fileName": "合同.pdf", "uploadKey": "key-123", - "fileSize": 102400, + "fileSize": float64(fileSize), + } + if !reflect.DeepEqual(caller.argsLog[1], wantCommit) { + t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit) } - t.Run("json", func(t *testing.T) { - caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} - stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) - if err != nil { - t.Fatalf("execute command: %v", err) - } - if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" { - t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool) - } - if !reflect.DeepEqual(caller.args, wantArgs) { - t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) - } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode unified output: %v", err) - } - if envelope["ok"] != true || envelope["outcome"] != "success" { - t.Fatalf("unified envelope = %#v", envelope) - } - data, ok := envelope["data"].(map[string]any) - if !ok || data["fileId"] != "file-abc" { - t.Fatalf("unified data = %#v", envelope["data"]) - } - }) + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["fileId"] != "file-abc" { + t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"]) + } } -func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { +func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) { + const content = "auto-md5-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content) + wantMD5, err := fileMD5Hex(filePath) + if err != nil { + t.Fatalf("compute expected md5: %v", err) + } + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: oaUploadCommitResponse(fileSize)}, + }} + // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。 + if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + ); err != nil { + t.Fatalf("execute command: %v", err) + } + + if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" { + t.Fatalf("default fileName = %#v, want basename report.xlsx", got) + } + if got := caller.argsLog[0]["md5"]; got != wantMD5 { + t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5) + } + if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) { + t.Fatalf("fileSize = %#v, want number %d", got, fileSize) + } +} + +func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) { tests := []struct { - name string - args []string + name string + response string + wantErr string }{ - {name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}}, - {name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}}, - {name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}}, - {name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}}, - {name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}}, - {name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}}, - {name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}}, + { + name: "missing result", + response: `{"success":true}`, + wantErr: "缺少 result", + }, + { + name: "missing uploadKey", + response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 uploadKey", + }, + { + name: "empty uploadKey", + response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 uploadKey", + }, + { + name: "empty resourceUrls", + response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 resourceUrls", + }, + { + name: "missing headers", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`, + wantErr: "缺少 headers", + }, + { + name: "headers missing Authorization", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`, + wantErr: "Authorization", + }, + { + name: "headers missing x-oss-date", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`, + wantErr: "x-oss-date", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data") + put := mockOAAttachmentPut(t) + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: test.response}, + // second step should never be reached + {text: `{"result":{},"success":true}`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "test.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { + existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x") + tests := []struct { + name string + args []string + }{ + {name: "missing file", args: []string{"approval", "attachment", "upload"}}, + {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}}, + {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockOAAttachmentPut(t) caller := &scriptedToolCaller{} err := executeOACommand(t, caller, test.args...) if err == nil { t.Fatalf("%s unexpectedly succeeded", test.name) } if caller.calls != 0 { - t.Fatalf("missing required flag made %d MCP call(s)", caller.calls) + t.Fatalf("invalid upload made %d MCP call(s)", caller.calls) } }) } diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go index 8b58bdbf..5cc02f26 100644 --- a/internal/helpers/polling_coverage_more_test.go +++ b/internal/helpers/polling_coverage_more_test.go @@ -27,6 +27,11 @@ type scriptedToolCaller struct { server string tool string args map[string]any + // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit) + // can assert each invocation instead of only the last one captured above. + serverLog []string + toolLog []string + argsLog []map[string]any } func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { @@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri c.server = serverID c.tool = toolName c.args = args + c.serverLog = append(c.serverLog, serverID) + c.toolLog = append(c.toolLog, toolName) + c.argsLog = append(c.argsLog, args) if len(c.steps) == 0 { return &edition.ToolResult{}, nil } diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index a0cb242b..6add42e9 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -34,8 +34,7 @@ Flags: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` -- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` -- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` +- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` #### 获取审批附件临时下载链接 @@ -80,35 +79,20 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 -#### 初始化审批附件上传 +#### 上传本地文件为审批附件 ``` Usage: - dws oa approval attachment init-upload [flags] + dws oa approval attachment upload [flags] Example: - dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json + dws oa approval attachment upload --file ./合同.pdf --format json Flags: - --file-name string 完整文件名,例如 合同.pdf (必填) - --file-size int 文件字节数,必须大于等于 0 (必填) - --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) + --file string 本地文件路径 (必填) + --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) ``` -该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 - -#### 提交附件上传信息 - -``` -Usage: - dws oa approval attachment commit-upload [flags] -Example: - dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json -Flags: - --file-name string 完整文件名,例如 合同.pdf (必填) - --upload-key string 初始化接口返回的 uploadKey (必填) - --file-size int 文件字节数,必须大于等于 0 (必填) -``` - -该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 +该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 ### 同意审批 @@ -753,8 +737,7 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) -用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) -用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) +用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 959fb48f..e8800eeb 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -38,8 +38,7 @@ Flags: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` -- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` -- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` +- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` #### 获取审批附件临时下载链接 @@ -84,35 +83,20 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 -#### 初始化审批附件上传 +#### 上传本地文件为审批附件 ``` Usage: - dws oa approval attachment init-upload [flags] + dws oa approval attachment upload [flags] Example: - dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json + dws oa approval attachment upload --file ./合同.pdf --format json Flags: - --file-name string 完整文件名,例如 合同.pdf (必填) - --file-size int 文件字节数,必须大于等于 0 (必填) - --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) + --file string 本地文件路径 (必填) + --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) ``` -该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 - -#### 提交附件上传信息 - -``` -Usage: - dws oa approval attachment commit-upload [flags] -Example: - dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json -Flags: - --file-name string 完整文件名,例如 合同.pdf (必填) - --upload-key string 初始化接口返回的 uploadKey (必填) - --file-size int 文件字节数,必须大于等于 0 (必填) -``` - -该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 +该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 ### 同意审批 @@ -756,8 +740,7 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) -用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) -用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) +用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go index 25dd8b64..ba74452e 100644 --- a/test/unit/oa_attachment_delivery_policy_test.go +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -37,8 +37,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { "download-url": "get_attachment_download_url", "authorize-download": "auth_download_file", "authorize-preview": "auth_preview_attachment", - "init-upload": "init_attachment_upload_info", - "commit-upload": "commit_attachment_upload_info", } gotTools := make(map[string]string, len(wantTools)) foundConstructor := false @@ -80,8 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { "dws oa approval attachment download-url", "dws oa approval attachment authorize-download", "dws oa approval attachment authorize-preview", - "dws oa approval attachment init-upload", - "dws oa approval attachment commit-upload", + "dws oa approval attachment upload", "临时下载链接", "最多 10", "最多 20", From 615a775fdf1d9c319c2463abda4bd73fb22279c6 Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 15:34:45 +0800 Subject: [PATCH 207/267] feat(sheet): add revision changeset inspection --- .changes/sheet-revision-changesets.md | 5 + internal/helpers/sheet.go | 7 +- internal/helpers/sheet_revision.go | 479 +++++++++++++ internal/helpers/sheet_revision_test.go | 665 ++++++++++++++++++ internal/helpers/sheet_version.go | 19 +- internal/helpers/sheet_version_test.go | 43 ++ skills/mono/references/products/sheet.md | 18 +- .../products/sheet/sheet-comment.md | 2 +- .../products/sheet/sheet-dropdown.md | 4 +- .../references/products/sheet/sheet-export.md | 32 +- .../references/products/sheet/sheet-filter.md | 2 +- .../references/products/sheet/sheet-import.md | 2 +- .../products/sheet/sheet-media-image.md | 4 +- .../products/sheet/sheet-pivot-table.md | 4 +- .../products/sheet/sheet-read-data.md | 5 +- .../sheet/sheet-revision-changeset.md | 306 ++++++++ .../products/sheet/sheet-style-format.md | 6 +- .../products/sheet/sheet-version.md | 30 +- .../products/sheet/sheet-workbook.md | 8 +- .../multi/dingtalk-misc/references/sheet.md | 18 +- .../references/sheet/sheet-comment.md | 2 +- .../references/sheet/sheet-dropdown.md | 4 +- .../references/sheet/sheet-export.md | 32 +- .../references/sheet/sheet-filter.md | 2 +- .../references/sheet/sheet-import.md | 2 +- .../references/sheet/sheet-media-image.md | 4 +- .../references/sheet/sheet-pivot-table.md | 4 +- .../references/sheet/sheet-read-data.md | 5 +- .../sheet/sheet-revision-changeset.md | 306 ++++++++ .../references/sheet/sheet-style-format.md | 6 +- .../references/sheet/sheet-version.md | 30 +- .../references/sheet/sheet-workbook.md | 8 +- 32 files changed, 1946 insertions(+), 118 deletions(-) create mode 100644 .changes/sheet-revision-changesets.md create mode 100644 internal/helpers/sheet_revision.go create mode 100644 internal/helpers/sheet_revision_test.go create mode 100644 internal/helpers/sheet_version_test.go create mode 100644 skills/mono/references/products/sheet/sheet-revision-changeset.md create mode 100644 skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md diff --git a/.changes/sheet-revision-changesets.md b/.changes/sheet-revision-changesets.md new file mode 100644 index 00000000..e6978c30 --- /dev/null +++ b/.changes/sheet-revision-changesets.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets. diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 01246531..682c4f4f 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "sheet", Selection: contract.ProductSelectionDecl{ - AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。", + AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。", UseWhen: []string{ "用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时", "用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时", + "用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时", }, AvoidWhen: []string{ "目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet", @@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command { dws sheet chart create 创建浮动图表 dws sheet chart update 更新浮动图表 dws sheet chart delete 删除浮动图表 + dws sheet revision-get 获取工作簿当前 revision + dws sheet changeset-get 获取工作簿 revision 区间内的 changeset dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载) dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘) dws sheet import 导入 xlsx/xls 为在线电子表格 @@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command { templateCmd := newSheetTemplateCmd() tableCmds := newTableCmds() pivotTableCmd := newPivotTableCmd() + revisionCmds := newSheetRevisionCmds() batchUpdateCmd := newBatchUpdateCmd() DeclareLeafMetadata(batchUpdateCmd, LeafSpec{ @@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command { standaloneCmds = append(standaloneCmds, mediaCmds...) standaloneCmds = append(standaloneCmds, floatImageCmds...) standaloneCmds = append(standaloneCmds, tableCmds...) + standaloneCmds = append(standaloneCmds, revisionCmds...) standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd) // Register cross-product aliases diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go new file mode 100644 index 00000000..ac2a4697 --- /dev/null +++ b/internal/helpers/sheet_revision.go @@ -0,0 +1,479 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "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" +) + +const ( + sheetRevisionGetRemoteTool = "get_sheet_revision" + sheetChangesetGetRemoteTool = "get_sheet_changeset" + sheetChangesetMaxSpan = int64(20) + sheetChangesetJSONMaxBytes = 2 * 1024 * 1024 +) + +var sheetRevisionResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"工作簿当前持久化 Delta revision", + "properties":{ + "success":{"type":"boolean","description":"服务端业务调用是否成功"}, + "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"}, + "revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"} + }, + "required":["success","logId","revision"], + "additionalProperties":true + }`), +} + +var sheetChangesetResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset", + "properties":{ + "success":{"type":"boolean","description":"服务端业务调用是否成功"}, + "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"}, + "schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]}, + "changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]}, + "latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"}, + "startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"}, + "endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"}, + "summary":{ + "type":"object", + "description":"本次区间内语义 change 的完整性和影响范围汇总", + "properties":{ + "changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"}, + "completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"}, + "partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"}, + "unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"}, + "containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"}, + "containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"}, + "affectedSheets":{ + "type":"array", + "description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入", + "items":{ + "type":"object", + "properties":{ + "sheetId":{"type":"string","description":"受影响工作表的稳定 ID"}, + "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"}, + "ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}} + }, + "required":["sheetId","ranges"], + "additionalProperties":true + } + } + }, + "required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"], + "additionalProperties":true + }, + "changesets":{ + "type":"array", + "description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件", + "items":{ + "type":"object", + "properties":{ + "revision":{"type":"number","description":"该 changeset 对应的 Delta revision"}, + "createTime":{"type":"string","description":"该 revision 的创建时间"}, + "isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"}, + "eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]}, + "detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]}, + "reset":{ + "type":"object", + "description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象", + "properties":{ + "type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]}, + "targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"}, + "targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]} + }, + "required":["type","targetStatus"], + "additionalProperties":true + }, + "changes":{ + "type":"array", + "description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组", + "items":{ + "type":"object", + "properties":{ + "type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]}, + "targets":{ + "type":"array", + "description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分", + "items":{ + "type":"object", + "properties":{ + "scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]}, + "sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"}, + "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"}, + "sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]}, + "a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"}, + "role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]} + }, + "required":["scope"], + "additionalProperties":true + } + }, + "details":{ + "type":"object", + "description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断", + "properties":{ + "cell":{ + "type":"object", + "description":"前向单元格内容;整体清除时为 {cleared:true}", + "properties":{ + "cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]}, + "value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true}, + "formula":{"type":"string","description":"本次修改写入的可读公式文本"}, + "formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]}, + "cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}, + "link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true} + }, + "additionalProperties":true + }, + "cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}, + "tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}, + "border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true}, + "style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}, + "properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true}, + "changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true}, + "fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]}, + "copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"}, + "styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]}, + "step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true}, + "pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]}, + "isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"}, + "iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]}, + "includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}}, + "contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true}, + "clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}}, + "preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}}, + "relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}}, + "styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}}, + "dataValidation":{ + "type":"object", + "description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回", + "properties":{ + "type":{"type":"string","description":"数据验证规则类型"}, + "templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"}, + "sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]}, + "options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}, + "sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true}, + "sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]}, + "sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"}, + "enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"}, + "criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true}, + "settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true} + }, + "required":["type"], + "additionalProperties":true + } + }, + "additionalProperties":true + }, + "detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]}, + "omissions":{ + "type":"array", + "description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组", + "items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true} + } + }, + "required":["type","targets","details","detailsStatus","omissions"], + "additionalProperties":true + } + } + }, + "required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"], + "additionalProperties":true + } + } + }, + "required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"], + "additionalProperties":true + }`), +} + +func newSheetRevisionCmds() []*cobra.Command { + return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()} +} + +func newSheetRevisionGetCmd() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "revision-get", + Short: "获取表格工作簿当前 revision", + Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。", + Example: " dws sheet revision-get --node --format json", + OutputRollout: output.RolloutUnifiedActive, + Server: "sheet", + Tool: sheetRevisionGetRemoteTool, + Flags: []LeafFlag{ + {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"}, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: sheetRevisionGetRemoteTool, + CanonicalPath: "sheet." + sheetRevisionGetRemoteTool, + CLIPath: "sheet revision-get", + PrimaryCLIPath: "sheet revision-get", + }, + Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。", + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation, RemoteReads: false}, + Result: sheetRevisionResult, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。", + UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"}, + AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"}, + Examples: []string{"dws sheet revision-get --node --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"}, + }, + }, + ResultCall: callSheetRevisionResult, + }) +} + +func newSheetChangesetGetCmd() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "changeset-get", + Short: "获取表格工作簿 revision 区间内的 changeset", + Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。 + +该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`, + Example: ` dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json + dws sheet changeset-get --node --start-revision 120 --format json`, + OutputRollout: output.RolloutUnifiedActive, + Server: "sheet", + Tool: sheetChangesetGetRemoteTool, + Flags: []LeafFlag{ + {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"}, + { + Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision", + Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg, + }, + { + Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision", + Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg, + }, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: sheetChangesetGetRemoteTool, + CanonicalPath: "sheet." + sheetChangesetGetRemoteTool, + CLIPath: "sheet changeset-get", + PrimaryCLIPath: "sheet changeset-get", + }, + Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。", + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation, RemoteReads: false}, + Result: sheetChangesetResult, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。", + UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"}, + AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"}, + Examples: []string{"dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"}, + {Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"}, + {Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"}, + }, + }, + Validate: validateSheetChangesetRange, + ResultCall: callSheetRevisionResult, + }) +} + +func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "executed": false, + "tool": tool, + "arguments": args, + }, output.WithDryRun()), nil + } + raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args) + if err != nil { + return nil, err + } + data, err := decodeSheetRevisionResult(tool, raw) + if err != nil { + return nil, err + } + return output.Success(data), nil +} + +func decodeSheetRevisionResult(tool, raw string) (any, error) { + if strings.TrimSpace(raw) == "" { + return map[string]any{}, nil + } + data, err := decodeSheetSingleJSON(raw) + if err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + if tool == sheetChangesetGetRemoteTool { + if err := normalizeSheetChangesetTransport(data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v%s", tool, err, sheetResultLogIDSuffix(data))) + } + } + return data, nil +} + +func sheetResultLogIDSuffix(data any) string { + object, ok := data.(map[string]any) + if !ok { + return "" + } + logID, ok := object["logId"].(string) + if !ok || strings.TrimSpace(logID) == "" { + return "" + } + return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID)) +} + +func decodeSheetSingleJSON(raw string) (any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var data any + if err := decoder.Decode(&data); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + err = fmt.Errorf("包含多个 JSON 值") + } + return nil, err + } + return data, nil +} + +func normalizeSheetChangesetTransport(data any) error { + object, ok := data.(map[string]any) + if !ok { + return fmt.Errorf("业务结果不是 JSON 对象") + } + + encoded, hasEncoded := object["changesetsJson"] + if hasEncoded { + changesetsJSON, ok := encoded.(string) + if !ok { + return fmt.Errorf("changesetsJson 不是字符串") + } + if strings.TrimSpace(changesetsJSON) == "" { + return fmt.Errorf("changesetsJson 为空") + } + if len(changesetsJSON) > sheetChangesetJSONMaxBytes { + return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes) + } + decoded, err := decodeSheetSingleJSON(changesetsJSON) + if err != nil { + return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err) + } + changesets, ok := decoded.([]any) + if !ok { + return fmt.Errorf("changesetsJson 根节点不是数组") + } + object["changesets"] = changesets + delete(object, "changesetsJson") + return nil + } + + if legacy, exists := object["changesets"]; exists { + if _, ok := legacy.([]any); !ok { + return fmt.Errorf("changesets 不是数组") + } + return nil + } + + if success, _ := object["success"].(bool); success { + return fmt.Errorf("成功响应缺少 changesetsJson") + } + return nil +} + +func sheetRevisionNumberArg(raw string) (any, error) { + return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) +} + +func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error { + start, err := parseSheetRevisionFlag(cmd, "start-revision") + if err != nil { + return err + } + if start < 0 { + return apperrors.NewValidation("--start-revision 必须是非负整数") + } + + endRaw, _ := cmd.Flags().GetString("end-revision") + if strings.TrimSpace(endRaw) == "" { + return nil + } + end, err := parseSheetRevisionFlag(cmd, "end-revision") + if err != nil { + return err + } + if end < 0 { + return apperrors.NewValidation("--end-revision 必须是非负整数") + } + if end < start { + return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision") + } + if end-start > sheetChangesetMaxSpan { + return apperrors.NewValidation(fmt.Sprintf( + "单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d", + sheetChangesetMaxSpan, start+sheetChangesetMaxSpan, + )) + } + return nil +} + +func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) { + raw, _ := cmd.Flags().GetString(name) + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name)) + } + return value, nil +} diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go new file mode 100644 index 00000000..0849efbd --- /dev/null +++ b/internal/helpers/sheet_revision_test.go @@ -0,0 +1,665 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "io" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type sheetRevisionTestCall struct { + server string + tool string + args map[string]any +} + +type sheetRevisionTestCaller struct { + responses map[string]string + calls []sheetRevisionTestCall + dryRun bool +} + +func (c *sheetRevisionTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + c.calls = append(c.calls, sheetRevisionTestCall{server: server, tool: tool, args: cloned}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.responses[tool]}}}, nil +} + +func (*sheetRevisionTestCaller) Format() string { return "json" } +func (c *sheetRevisionTestCaller) DryRun() bool { return c.dryRun } +func (*sheetRevisionTestCaller) Fields() string { return "" } +func (*sheetRevisionTestCaller) JQ() string { return "" } + +func executeSheetRevisionCommand(t *testing.T, caller *sheetRevisionTestCaller, command *cobra.Command, args ...string) (string, error) { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + InitDeps(caller) + var stdout bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = io.Discard + command = prepareUnifiedTestCommand(command) + command.SetOut(&stdout) + command.SetErr(io.Discard) + command.SilenceErrors = true + command.SilenceUsage = true + command.SetArgs(args) + err := command.Execute() + return stdout.String(), err +} + +func assertSheetRevisionJSONEqual(t *testing.T, got, want string) { + t.Helper() + var envelope map[string]any + if err := json.Unmarshal([]byte(got), &envelope); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, got) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("command envelope = %#v, want success", envelope) + } + var wantValue any + if err := json.Unmarshal([]byte(want), &wantValue); err != nil { + t.Fatalf("decode expected JSON: %v", err) + } + if !reflect.DeepEqual(envelope["data"], wantValue) { + t.Fatalf("command data = %#v, want raw MCP business JSON %#v", envelope["data"], wantValue) + } +} + +func encodeSheetChangesetTransport(t *testing.T, logical string) string { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(logical)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + t.Fatalf("decode logical changeset fixture: %v", err) + } + changesets, ok := object["changesets"] + if !ok { + t.Fatal("logical changeset fixture missing changesets") + } + encodedChangesets, err := json.Marshal(changesets) + if err != nil { + t.Fatalf("encode changesets fixture: %v", err) + } + delete(object, "changesets") + object["changesetsJson"] = string(encodedChangesets) + encoded, err := json.Marshal(object) + if err != nil { + t.Fatalf("encode transport fixture: %v", err) + } + return string(encoded) +} + +func TestSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) { + const response = `{"success":true,"logId":"trace-revision","revision":142,"futureField":{"kept":true,"largeInteger":9007199254740993}}` + caller := &sheetRevisionTestCaller{responses: map[string]string{sheetRevisionGetRemoteTool: response}} + nodeURL := "https://alidocs.dingtalk.com/spreadsheetv2/key/path?sheet=one" + + got, err := executeSheetRevisionCommand(t, caller, newSheetRevisionGetCmd(), "--node", nodeURL) + if err != nil { + t.Fatalf("revision-get returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + call := caller.calls[0] + if call.server != "sheet" || call.tool != sheetRevisionGetRemoteTool { + t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetRevisionGetRemoteTool) + } + if !reflect.DeepEqual(call.args, map[string]any{"nodeId": nodeURL}) { + t.Fatalf("args = %#v, want node URL passed through", call.args) + } + assertSheetRevisionJSONEqual(t, got, response) + if !strings.Contains(got, "9007199254740993") { + t.Fatalf("large JSON integer lost precision: %s", got) + } +} + +func TestSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { + const logicalResponse = `{ + "success":true, + "logId":"trace-changeset", + "schemaVersion":2, + "changeSemantics":"FORWARD_ONLY", + "latestRevision":1, + "startRevision":0, + "endRevision":1, + "summary":{ + "changeCount":1, + "completeChangeCount":1, + "partialChangeCount":0, + "unsupportedChangeCount":0, + "containsStateReset":false, + "containsIncompleteChanges":false, + "affectedSheets":[{"sheetId":"sheet-id","sheetName":"项目","ranges":["C2"]}] + }, + "changesets":[{ + "revision":1, + "createTime":"2026-08-18T10:00:00+08:00", + "isSelfEdit":true, + "eventType":"EDIT", + "detailsStatus":"COMPLETE", + "changes":[{ + "type":"RANGE_CONTENT_SET", + "targets":[{"scope":"RANGE","sheetId":"sheet-id","sheetName":"项目","sheetNameSource":"AT_CHANGE","a1Range":"C2","role":"AFFECTED"}], + "details":{"cell":{"value":"完成"},"futureSafeField":{"x":1}}, + "detailsStatus":"COMPLETE", + "omissions":[] + }] + }] + }` + response := encodeSheetChangesetTransport(t, logicalResponse) + caller := &sheetRevisionTestCaller{responses: map[string]string{sheetChangesetGetRemoteTool: response}} + + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "0") + if err != nil { + t.Fatalf("changeset-get returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + call := caller.calls[0] + if call.server != "sheet" || call.tool != sheetChangesetGetRemoteTool { + t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetChangesetGetRemoteTool) + } + if gotStart, exists := call.args["startRevision"]; !exists || gotStart != int64(0) { + t.Fatalf("startRevision = %#v (exists=%v), want int64(0)", gotStart, exists) + } + if _, exists := call.args["endRevision"]; exists { + t.Fatalf("optional endRevision unexpectedly sent: %#v", call.args) + } + if call.args["nodeId"] != "node-1" { + t.Fatalf("nodeId = %#v", call.args["nodeId"]) + } + assertSheetRevisionJSONEqual(t, got, logicalResponse) + if strings.Contains(got, "changesetsJson") { + t.Fatalf("transport field leaked into Agent output: %s", got) + } +} + +func TestSheetChangesetGetSendsNumericEndRevision(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-changeset","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":121,"startRevision":120,"endRevision":121,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[]}`), + }} + _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "120", "--end-revision", "121") + if err != nil { + t.Fatalf("changeset-get returned error: %v", err) + } + want := map[string]any{"nodeId": "node-1", "startRevision": int64(120), "endRevision": int64(121)} + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("calls = %#v, want args %#v", caller.calls, want) + } +} + +func TestDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { + const response = `{"success":true,"changesets":[{"revision":9007199254740993}]}` + decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + if err != nil { + t.Fatalf("decode legacy array: %v", err) + } + object := decoded.(map[string]any) + changesets := object["changesets"].([]any) + revision := changesets[0].(map[string]any)["revision"].(json.Number) + if revision.String() != "9007199254740993" { + t.Fatalf("legacy revision = %s, want exact integer", revision) + } +} + +func TestDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "not string", raw: `{"success":true,"changesetsJson":[]}`}, + {name: "empty", raw: `{"success":true,"changesetsJson":""}`}, + {name: "not json", raw: `{"success":true,"changesetsJson":"not-json"}`}, + {name: "object root", raw: `{"success":true,"changesetsJson":"{}"}`}, + {name: "multiple values", raw: `{"success":true,"changesetsJson":"[] []"}`}, + {name: "missing", raw: `{"success":true}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, test.raw); err == nil { + t.Fatalf("decode %s unexpectedly succeeded", test.raw) + } + }) + } +} + +func TestDecodeSheetChangesetTransportPreservesLogIDInParseError(t *testing.T) { + const response = `{"success":true,"logId":"trace-malformed-changeset","changesetsJson":"not-json"}` + _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + if err == nil || !strings.Contains(err.Error(), "logId=trace-malformed-changeset") { + t.Fatalf("parse error = %v, want backend logId", err) + } +} + +func TestDecodeSheetChangesetTransportPassesFailureWithoutPayload(t *testing.T) { + const response = `{"success":false,"logId":"trace","errorCode":"UPSTREAM","errorMsg":"failed"}` + decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + if err != nil { + t.Fatalf("decode failure response: %v", err) + } + if decoded.(map[string]any)["errorCode"] != "UPSTREAM" { + t.Fatalf("failure response = %#v", decoded) + } +} + +func TestNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) { + data := map[string]any{ + "success": true, + "changesetsJson": strings.Repeat("x", sheetChangesetJSONMaxBytes+1), + } + if err := normalizeSheetChangesetTransport(data); err == nil || !strings.Contains(err.Error(), "超过") { + t.Fatalf("oversized changesetsJson error = %v", err) + } +} + +func TestSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) { + caller := &sheetRevisionTestCaller{dryRun: true, responses: map[string]string{}} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "0", "--dry-run") + if err != nil { + t.Fatalf("changeset-get dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run dispatched MCP calls: %#v", caller.calls) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(got), &envelope); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, got) + } + if envelope["dry_run"] != true || envelope["outcome"] != "success" { + t.Fatalf("dry-run envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["executed"] != false || data["tool"] != sheetChangesetGetRemoteTool { + t.Fatalf("dry-run data = %#v", envelope["data"]) + } + arguments, ok := data["arguments"].(map[string]any) + if !ok || arguments["startRevision"] != float64(0) || arguments["nodeId"] != "node-1" { + t.Fatalf("dry-run arguments = %#v", data["arguments"]) + } +} + +func TestSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "missing node", args: []string{"--start-revision", "0"}, want: "--node"}, + {name: "missing start", args: []string{"--node", "node-1"}, want: "--start-revision"}, + {name: "non integer start", args: []string{"--node", "node-1", "--start-revision", "1.5"}, want: "--start-revision 必须是 64 位整数"}, + {name: "negative start", args: []string{"--node", "node-1", "--start-revision", "-1"}, want: "--start-revision 必须是非负整数"}, + {name: "negative end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "-1"}, want: "--end-revision 必须是非负整数"}, + {name: "end before start", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "8"}, want: "必须大于或等于"}, + {name: "span too large", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "30"}, want: "单次最多查询 20 个 revision"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{}} + _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input dispatched calls: %#v", caller.calls) + } + }) + } +} + +func TestSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { + root := newSheetCommand() + for _, name := range []string{"revision-get", "changeset-get"} { + command, _, err := root.Find([]string{name}) + if err != nil || command == nil || command.Name() != name { + t.Fatalf("sheet %s lookup = command %#v, err %v", name, command, err) + } + if command.Flags().Lookup("sheet-id") != nil { + t.Fatalf("sheet %s unexpectedly accepts --sheet-id", name) + } + if !strings.Contains(command.Long, "工作簿级") { + t.Fatalf("sheet %s Long = %q, want workbook-level scope", name, command.Long) + } + + final, ok := contractfinal.RuntimeContractFinal(command) + if !ok { + t.Fatalf("sheet %s missing ContractFinal", name) + } + if final.Safety == nil || final.Safety.Effect != "read" || final.Safety.Confirmation != "not_required" { + t.Fatalf("sheet %s safety = %#v", name, final.Safety) + } + if final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.ProductID != "sheet" { + t.Fatalf("sheet %s interface = %#v", name, final.Interface) + } + if final.Result == nil || len(final.Result.DataSchema) == 0 { + t.Fatalf("sheet %s result = %#v", name, final.Result) + } + if output.CommandRollout(command) != output.RolloutUnifiedActive { + t.Fatalf("sheet %s rollout = %s, want unified_active so Result is Agent-visible", name, output.CommandRollout(command)) + } + if final.Selection == nil || len(final.Selection.UseWhen) == 0 || len(final.Selection.AvoidWhen) == 0 { + t.Fatalf("sheet %s selection = %#v", name, final.Selection) + } + } + + changeset, _, _ := root.Find([]string{"changeset-get"}) + if !strings.Contains(changeset.Long, "(startRevision, endRevision]") || + !strings.Contains(changeset.Long, "data.changesets") || + !strings.Contains(changeset.Long, "JSON 数组") || + !strings.Contains(changeset.Long, "只解码服务端传输格式") || + !strings.Contains(changeset.Long, "前向") || !strings.Contains(changeset.Long, "old/current") || + !strings.Contains(changeset.Long, "最终状态必须另行回读") { + t.Fatalf("changeset help does not explain interval/decoded output: %q", changeset.Long) + } + final, _ := contractfinal.RuntimeContractFinal(changeset) + if len(final.Parameters) != 3 || final.Parameters[1].InterfaceType != "number" || final.Parameters[2].InterfaceType != "number" { + t.Fatalf("changeset parameters = %#v, want numeric revision interface types", final.Parameters) + } +} + +func TestSheetRevisionResultSchemasUseMCPNumberAndChangesetV2(t *testing.T) { + var revisionSchema map[string]any + if err := json.Unmarshal(sheetRevisionResult.DataSchema, &revisionSchema); err != nil { + t.Fatalf("decode revision Result schema: %v", err) + } + revisionProperties := mustSheetSchemaMap(t, revisionSchema, "properties") + for _, name := range []string{"success", "logId", "revision"} { + mustSheetSchemaMap(t, revisionProperties, name) + } + for _, name := range []string{"success", "logId", "revision"} { + assertSheetSchemaRequired(t, revisionSchema, name) + } + if got := mustSheetSchemaMap(t, revisionProperties, "revision")["type"]; got != "number" { + t.Fatalf("revision schema type = %#v, want MCP number", got) + } + + var changesetSchema map[string]any + if err := json.Unmarshal(sheetChangesetResult.DataSchema, &changesetSchema); err != nil { + t.Fatalf("decode changeset Result schema: %v", err) + } + properties := mustSheetSchemaMap(t, changesetSchema, "properties") + for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} { + mustSheetSchemaMap(t, properties, name) + } + for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} { + assertSheetSchemaRequired(t, changesetSchema, name) + } + for _, name := range []string{"schemaVersion", "latestRevision", "startRevision", "endRevision"} { + if got := mustSheetSchemaMap(t, properties, name)["type"]; got != "number" { + t.Fatalf("changeset %s schema type = %#v, want MCP number", name, got) + } + } + + changesetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, properties, "changesets")) + changesetProperties := mustSheetSchemaMap(t, changesetItem, "properties") + for _, name := range []string{"revision", "createTime", "isSelfEdit", "eventType", "detailsStatus", "reset", "changes"} { + mustSheetSchemaMap(t, changesetProperties, name) + } + assertSheetSchemaNotRequired(t, changesetItem, "reset") + if got := mustSheetSchemaMap(t, changesetProperties, "reset")["type"]; got != "object" { + t.Fatalf("changeset reset schema type = %#v, want optional object", got) + } + changeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changesetProperties, "changes")) + changeProperties := mustSheetSchemaMap(t, changeItem, "properties") + for _, name := range []string{"type", "targets", "details", "detailsStatus", "omissions"} { + mustSheetSchemaMap(t, changeProperties, name) + assertSheetSchemaRequired(t, changeItem, name) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, changeProperties, "type"), 40, "UNSUPPORTED_CHANGE") + detailsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, changeProperties, "details"), "properties") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "fillMode"), 5, "predict") + if got := mustSheetSchemaMap(t, detailsProperties, "copyStyle")["type"]; got != "boolean" { + t.Fatalf("copyStyle schema type = %#v, want boolean", got) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "styleMode"), 4, "cell") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "pasteMode"), 4, "sheet") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "iterateMode"), 2, "flex") + if got := mustSheetSchemaMap(t, detailsProperties, "isCut")["type"]; got != "boolean" { + t.Fatalf("isCut schema type = %#v, want boolean", got) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "includedParts"), "items"), 18, "CONTENT") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "clearParts"), "items"), 14, "CELL_TYPES") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "preservedCellTypes"), "items"), 2, "SELECT") + for _, name := range []string{"cell", "cellType", "tag", "border", "style", "properties", "changes"} { + objectProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, name), "properties") + mustSheetSchemaMap(t, objectProperties, "cleared") + } + cellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "cell"), "properties") + if got := mustSheetSchemaMap(t, cellProperties, "formula")["type"]; got != "string" { + t.Fatalf("cell formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, cellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, cellProperties, "value"), false) + cellLink := mustSheetSchemaMap(t, cellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, cellLink, "properties"), name) + } + if required, exists := cellLink["required"]; exists { + t.Fatalf("cell.link schema required = %#v, want clear marker to be valid without range fields", required) + } + relativeChangeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, detailsProperties, "relativeChanges")) + relativeChangeProperties := mustSheetSchemaMap(t, relativeChangeItem, "properties") + if got := mustSheetSchemaMap(t, relativeChangeProperties, "offset")["type"]; got != "number" { + t.Fatalf("relativeChanges offset schema type = %#v, want MCP number", got) + } + relativeDimensionProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "properties"), "properties") + for _, name := range []string{"cleared", "size", "customSize", "hidden", "sticky", "cellType"} { + mustSheetSchemaMap(t, relativeDimensionProperties, name) + } + for _, name := range []string{"size", "customSize", "hidden", "sticky"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, relativeDimensionProperties, name), "null") + } + if required, exists := relativeChangeItem["required"]; exists { + t.Fatalf("relativeChanges item required = %#v, want refs.sheet item valid without offsets", required) + } + mustSheetSchemaMap(t, relativeChangeProperties, "styleCleared") + mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "tag"), "properties") + relativeCellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "cell"), "properties") + if got := mustSheetSchemaMap(t, relativeCellProperties, "formula")["type"]; got != "string" { + t.Fatalf("relative cell formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, relativeCellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, relativeCellProperties, "value"), false) + relativeCellLink := mustSheetSchemaMap(t, relativeCellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeCellLink, "properties"), name) + } + contentPatternProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "contentPattern"), "properties") + contentPatternCell := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, contentPatternProperties, "cells")) + contentPatternCellProperties := mustSheetSchemaMap(t, contentPatternCell, "properties") + mustSheetSchemaMap(t, contentPatternCellProperties, "cleared") + if got := mustSheetSchemaMap(t, contentPatternCellProperties, "formula")["type"]; got != "string" { + t.Fatalf("content pattern formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, contentPatternCellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, contentPatternCellProperties, "value"), false) + contentPatternLink := mustSheetSchemaMap(t, contentPatternCellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, contentPatternLink, "properties"), name) + } + if required, exists := contentPatternLink["required"]; exists { + t.Fatalf("contentPattern cell link schema required = %#v, want clear marker to be valid", required) + } + styles := mustSheetSchemaMap(t, detailsProperties, "styles") + if got := styles["type"]; got != "array" { + t.Fatalf("styles schema type = %#v, want named entry array", got) + } + styleEntry := mustSheetSchemaArrayItem(t, styles) + styleEntryProperties := mustSheetSchemaMap(t, styleEntry, "properties") + mustSheetSchemaMap(t, styleEntryProperties, "styleId") + mustSheetSchemaMap(t, styleEntryProperties, "style") + assertSheetSchemaRequired(t, styleEntry, "styleId") + assertSheetSchemaRequired(t, styleEntry, "style") + dataValidationProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "dataValidation"), "properties") + if got := mustSheetSchemaMap(t, dataValidationProperties, "templateId")["type"]; got != "number" { + t.Fatalf("dataValidation templateId schema type = %#v, want MCP number", got) + } + if got := mustSheetSchemaMap(t, dataValidationProperties, "options")["type"]; got != "array" { + t.Fatalf("dataValidation options schema type = %#v, want dropdown array", got) + } + settingsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "settings"), "properties") + for _, name := range []string{"allowBlank", "errorStyle", "showInputMessage", "showErrorMessage", "prompt", "error", "requiredInRow", "showDropDown", "columnType", "promptTitle", "errorTitle", "imeMode"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingsProperties, name), "null") + } + criteriaProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "criteria"), "properties") + mustSheetSchemaMap(t, criteriaProperties, "operator") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value1"), true) + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value2"), true) + mustSheetSchemaMap(t, criteriaProperties, "formula") + settingChangesProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "changes"), "properties") + settingMode := mustSheetSchemaMap(t, settingChangesProperties, "mode") + assertSheetSchemaEnum(t, settingMode, 4, "default") + if got := settingMode["type"]; got != "string" { + t.Fatalf("setting mode schema type = %#v, want string", got) + } + for _, name := range []string{"cleared", "iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904", "image"} { + mustSheetSchemaMap(t, settingChangesProperties, name) + } + for _, name := range []string{"iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingChangesProperties, name), "null") + } + settingImageProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, settingChangesProperties, "image"), "properties") + mustSheetSchemaMap(t, settingImageProperties, "cleared") + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingImageProperties, "opacity"), "null") + omissionItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "omissions")) + omissionCode := mustSheetSchemaMap(t, mustSheetSchemaMap(t, omissionItem, "properties"), "code") + assertSheetSchemaEnum(t, omissionCode, 13, "SOURCE_RANGE_UNRESOLVED") + targetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "targets")) + targetProperties := mustSheetSchemaMap(t, targetItem, "properties") + for _, name := range []string{"scope", "sheetId", "sheetName", "sheetNameSource", "a1Range", "role"} { + mustSheetSchemaMap(t, targetProperties, name) + } + + for _, forbidden := range []string{ + `"actions"`, `"operationKind"`, `"operationVersion"`, `"action"`, `"payload"`, + `"understood"`, `"payloadOmitted"`, `"sourceOperationIndex"`, `"pluginVersion"`, + `"targetId"`, `"targetType"`, `"changeKind"`, `"sourceDeltaType"`, `"resetType"`, + `"resetTarget"`, `"targetAvailable"`, `"warnings"`, `"changesetsJson"`, + } { + if bytes.Contains(sheetChangesetResult.DataSchema, []byte(forbidden)) { + t.Fatalf("changeset Result schema still exposes forbidden V1 key %s", forbidden) + } + } +} + +func assertSheetTypedValueSchema(t *testing.T, schema map[string]any, allowFormula bool) { + t.Helper() + if got := schema["type"]; got != "object" { + t.Fatalf("typed value schema type = %#v, want object", got) + } + properties := mustSheetSchemaMap(t, schema, "properties") + for _, name := range []string{"kind", "stringValue", "numberValue", "booleanValue"} { + mustSheetSchemaMap(t, properties, name) + } + if allowFormula { + mustSheetSchemaMap(t, properties, "formula") + } else { + mustSheetSchemaMap(t, properties, "values") + } + assertSheetSchemaRequired(t, schema, "kind") +} + +func mustSheetSchemaMap(t *testing.T, parent map[string]any, key string) map[string]any { + t.Helper() + value, ok := parent[key].(map[string]any) + if !ok { + t.Fatalf("schema key %q = %#v, want object", key, parent[key]) + } + return value +} + +func mustSheetSchemaArrayItem(t *testing.T, schema map[string]any) map[string]any { + t.Helper() + return mustSheetSchemaMap(t, schema, "items") +} + +func assertSheetSchemaRequired(t *testing.T, schema map[string]any, name string) { + t.Helper() + required, ok := schema["required"].([]any) + if !ok { + t.Fatalf("schema required = %#v, want array containing %q", schema["required"], name) + } + for _, value := range required { + if value == name { + return + } + } + t.Fatalf("schema required = %#v, missing %q", required, name) +} + +func assertSheetSchemaNotRequired(t *testing.T, schema map[string]any, name string) { + t.Helper() + required, ok := schema["required"].([]any) + if !ok { + t.Fatalf("schema required = %#v, want array without %q", schema["required"], name) + } + for _, value := range required { + if value == name { + t.Fatalf("schema required = %#v, unexpectedly contains optional %q", required, name) + } + } +} + +func assertSheetSchemaEnum(t *testing.T, schema map[string]any, wantCount int, wantValue string) { + t.Helper() + values, ok := schema["enum"].([]any) + if !ok || len(values) != wantCount { + t.Fatalf("schema enum = %#v, want %d values including %q", schema["enum"], wantCount, wantValue) + } + for _, value := range values { + if value == wantValue { + return + } + } + t.Fatalf("schema enum = %#v, missing %q", values, wantValue) +} + +func assertSheetSchemaTypeIncludes(t *testing.T, schema map[string]any, want string) { + t.Helper() + types, ok := schema["type"].([]any) + if !ok { + t.Fatalf("schema type = %#v, want array containing %q", schema["type"], want) + } + for _, value := range types { + if value == want { + return + } + } + t.Fatalf("schema type = %#v, missing %q", types, want) +} diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index 58e00bfb..a07fe014 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -111,8 +111,13 @@ func newSheetVersionCmd() *cobra.Command { versionListCmd.Flags().String("cursor", "", "分页游标") versionRevertCmd := &cobra.Command{ - Use: "revert", - Short: "[危险] 回滚表格到指定版本", + Use: "revert", + Short: "[危险] 回滚表格到指定历史版本或 revision", + Long: `将在线表格恢复到指定历史版本或精确 revision。 + +通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时, +也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入 +版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`, Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") @@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command { CLIPath: "sheet version revert", PrimaryCLIPath: "sheet version revert", }, - Description: "回滚表格到指定历史版本", + Description: "回滚表格到指定历史版本或已确认的精确 revision", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ - AgentSummary: "回滚表格到指定历史版本", - UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"}, - AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"}, + AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision", + UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"}, + AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"}, Examples: []string{"dws sheet version revert --node --version 3 --format json"}, }, }, }) versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") - versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)") + versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)") for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} { c.Flags().String("url", "", "") diff --git a/internal/helpers/sheet_version_test.go b/internal/helpers/sheet_version_test.go new file mode 100644 index 00000000..12de7ba8 --- /dev/null +++ b/internal/helpers/sheet_version_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "strings" + "testing" +) + +func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) { + command := newSheetVersionCmd() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs([]string{"revert", "--help"}) + + if err := command.Execute(); err != nil { + t.Fatalf("sheet version revert --help returned error: %v", err) + } + help := output.String() + for _, expected := range []string{ + "通常应从 version list 选择已保存的历史版本", + "已从同一工作簿真实查询结果确认的 revision", + "禁止猜测 revision", + "目标历史版本或已确认 revision", + } { + if !strings.Contains(help, expected) { + t.Fatalf("sheet version revert help missing %q:\n%s", expected, help) + } + } +} diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 4276456d..6b45e267 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -54,6 +54,7 @@ | 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | +| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | ## 触发必读 @@ -63,6 +64,7 @@ - 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。 - 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。 - 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。 +- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。 ## Reference 索引 @@ -82,13 +84,14 @@ | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | -| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` | | [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | | [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | | [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | | [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | | sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` | -| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` | +| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` | +| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` | ## 全局硬约束 @@ -99,12 +102,12 @@ 5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` 6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` 7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV -8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段 +8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除 9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段 10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` 11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` 12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) -13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 +13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) 15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` 16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` @@ -115,9 +118,10 @@ - **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。 - **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 - **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 -- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 +- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。 - **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 -- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 +- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。 +- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 ## URL 粘贴场景 @@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token --name dws sheet import create --file ./data.xls --workspace ``` - 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 -- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** +- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试** - 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node` - **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格 diff --git a/skills/mono/references/products/sheet/sheet-comment.md b/skills/mono/references/products/sheet/sheet-comment.md index 985e9813..9881e784 100644 --- a/skills/mono/references/products/sheet/sheet-comment.md +++ b/skills/mono/references/products/sheet/sheet-comment.md @@ -12,7 +12,7 @@ - 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 - 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 - `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 -- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 +- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。 --- diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md index 05b5f0c0..07c32d65 100644 --- a/skills/mono/references/products/sheet/sheet-dropdown.md +++ b/skills/mono/references/products/sheet/sheet-dropdown.md @@ -66,7 +66,7 @@ Flags: 查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -99,5 +99,5 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 -- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 +- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index ba0880c6..8c067aff 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -5,11 +5,11 @@ ### 导出 用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel": -- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载) +- 导出表格 → `export`(单命令会自动等待完成,并可选下载) - 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl) - 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx -- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性 +- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": - 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) @@ -38,26 +38,14 @@ Flags: --output string 本地保存路径(可选,支持文件路径或目录) ``` -将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `submit_export_job` 获取 `jobId` -2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 -3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 +将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。 **命令返回**: - `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载) - `--output` 指定为文件路径:下载到该路径并输出 `导出完成: ` - `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下 -**失败处理(命令内部已处理,Agent 仅需转述)**: +**失败处理**: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 @@ -111,7 +99,7 @@ dws sheet export-csv --node --sheet-id --allow-truncated -- ```bash # ── 工作流 12: 导出表格为 xlsx(单命令一站式)── -# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) +# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl) dws sheet export --node --format json # 传入 URL 也可: # dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json @@ -122,7 +110,7 @@ dws sheet export --node --output ./report.xlsx # 场景 C:下载到目录,自动按链接推断文件名 dws sheet export --node --output ./ -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 +# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。 # 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 ``` @@ -130,18 +118,18 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 | | `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | | `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 - ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` -- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 +- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 -- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性 - `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` - ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/mono/references/products/sheet/sheet-filter.md b/skills/mono/references/products/sheet/sheet-filter.md index 0e65607b..90bfe695 100644 --- a/skills/mono/references/products/sheet/sheet-filter.md +++ b/skills/mono/references/products/sheet/sheet-filter.md @@ -173,7 +173,7 @@ Flags: - `filter update` 前置:工作表必须已创建筛选 - `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等) - `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选 -- ★ **筛选操作规范**(参照飞书 core-operations): +- ★ **筛选操作规范**: - 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替 - 创建/更新筛选后**必须** `filter get` 回读验证配置正确 - 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作 diff --git a/skills/mono/references/products/sheet/sheet-import.md b/skills/mono/references/products/sheet/sheet-import.md index d50b27b4..b301e4bf 100644 --- a/skills/mono/references/products/sheet/sheet-import.md +++ b/skills/mono/references/products/sheet/sheet-import.md @@ -6,7 +6,7 @@ - 支持本地 `xlsx` / `xls`,文件上限 20MB - `--folder-token` 与 `--workspace` 至少提供一个 -- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 +- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试 - `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 ## 命令 diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md index 06aba4b7..d3e236f0 100644 --- a/skills/mono/references/products/sheet/sheet-media-image.md +++ b/skills/mono/references/products/sheet/sheet-media-image.md @@ -228,8 +228,8 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 -- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 +- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤 +- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 - `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL diff --git a/skills/mono/references/products/sheet/sheet-pivot-table.md b/skills/mono/references/products/sheet/sheet-pivot-table.md index 6b8f93d2..2e451972 100644 --- a/skills/mono/references/products/sheet/sheet-pivot-table.md +++ b/skills/mono/references/products/sheet/sheet-pivot-table.md @@ -191,7 +191,7 @@ Flags: - 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` - 必须包含表头行(通常从第 1 行开始) - sheet 名称用单引号包裹(含空格或特殊字符时必须) -- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 +- `source` 直接使用上述 A1 表示法,不要改写成其他结构 ## 高级功能示例 @@ -210,7 +210,7 @@ dws sheet pivot-table create --node \ }' ``` -> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。 +> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。 ## 核心工作流 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index f8008d0e..89fef5a2 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -54,7 +54,7 @@ Flags: - `rowIndices` — 行号映射数组(如 `[1,2,3]`) - `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 - `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 -- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围 - `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -161,7 +161,7 @@ Flags: | `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | | `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | | `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | -| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 @@ -270,6 +270,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 - ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` +- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。 - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 - `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表 diff --git a/skills/mono/references/products/sheet/sheet-revision-changeset.md b/skills/mono/references/products/sheet/sheet-revision-changeset.md new file mode 100644 index 00000000..13e5b662 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-revision-changeset.md @@ -0,0 +1,306 @@ +# 工作簿 revision 与 changeset + +## 何时使用 + +当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。 + +- 当前 revision → `dws sheet revision-get` +- revision 区间内的变更 → `dws sheet changeset-get` +- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md) +- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令 + +两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。 + +revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。 + +当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。 + +## 获取当前 revision + +```bash +dws sheet revision-get --node --format json +``` + +成功时从统一输出的 `data.revision` 读取当前 revision: + +```json +{ + "ok": true, + "outcome": "success", + "data": { + "success": true, + "logId": "trace-id", + "revision": 142 + } +} +``` + +- `revision=0` 是合法的空工作簿基线。 +- revision 不是工作表数量,也不是历史版本列表序号。 +- `logId` 仅用于失败反馈和问题排查,不是 revision。 + +## 获取 revision 区间 changeset + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --end-revision 140 \ + --format json +``` + +也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision: + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --format json +``` + +参数规则: + +- `--start-revision` 必填,必须为非负整数;`0` 合法。 +- `--end-revision` 可选;提供时必须大于或等于 start。 +- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。 +- `startRevision == endRevision` 合法,返回空 `changesets`。 +- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。 +- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。 +- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。 + +成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。 + +## 先看区间摘要 + +`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响: + +| 字段 | 含义 | +|------|------| +| `changeCount` | `changes[]` 中变更对象的总数 | +| `completeChangeCount` | 详情完整的变更数量 | +| `partialChangeCount` | 只返回部分可信详情的变更数量 | +| `unsupportedChangeCount` | 当前无法解释的变更数量 | +| `containsStateReset` | 区间内是否发生整表状态替换 | +| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 | +| `affectedSheets` | 受影响工作表及其 A1 范围摘要 | + +`affectedSheets[]` 常用字段: + +| 字段 | 含义 | +|------|------| +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 | +| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` | + +摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。 + +## 读取 `changesets[]` + +每个 changeset 对应一个 revision: + +| 字段 | 含义 | 使用规则 | +|------|------|----------| +| `revision` | 该事件对应的 revision | 按升序解释 | +| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 | +| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 | +| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 | +| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 | +| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 | + +事件类型: + +- `EDIT`:一次普通编辑提交产生的效果。 +- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。 +- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。 + +changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。 + +## 读取 `reset` + +| 字段 | 含义 | +|------|------| +| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` | +| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 | +| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 | + +当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。 + +回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。 + +## 读取 `changes[]` + +每个 change 都按以下顺序读取: + +1. `type`:发生了哪类变更。 +2. `targets`:变更作用在哪个工作簿、工作表或范围。 +3. `detailsStatus`:详情是否完整。 +4. `details`:本次变更的可用详情。 +5. `omissions`:哪些详情未能提供。 + +公共字段: + +| 字段 | 含义 | +|------|------| +| `type` | 语义变更类型,见下方字典 | +| `targets` | 工作簿、工作表或 A1 范围定位数组 | +| `details` | 与 `type` 对应的变更详情;无详情时为空对象 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | +| `omissions` | 未提供详情的原因;没有省略时为空数组 | + +`detailsStatus` 的处理规则: + +- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。 +- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。 +- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。 + +`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。 + +## 读取 `targets[]` + +| 字段 | 含义 | +|------|------| +| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` | +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称 | +| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 | +| `a1Range` | 1-based A1 范围 | +| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 | + +- `SOURCE` 是读取或复制来源,不表示该位置被修改。 +- `DESTINATION` 是写入目标,应算作受影响位置。 +- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。 +- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。 + +## 变更类型字典 + +### 行列与工作表 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` | +| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` | +| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` | +| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` | +| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 | +| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` | +| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` | + +`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。 + +### 单元格与范围 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` | +| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` | +| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` | +| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` | +| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` | +| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` | +| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` | +| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` | +| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` | +| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` | +| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` | +| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` | + +粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。 + +`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。 + +`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。 + +### 分组、数据验证和结构对象 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` | +| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` | +| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` | +| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` | +| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` | +| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` | +| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` | + +扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。 + +`dataValidation` 常用字段: + +| 字段 | 含义 | +|------|------| +| `type` | 验证规则类型 | +| `sourceType` | 下拉来源:`inline` 或 `sourceRange` | +| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` | +| `sourceRange` | 已确认的来源工作表与 A1 范围 | +| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` | +| `sourceRangeExpression` | 可读的来源表达式 | +| `enableMultiSelect` | 是否允许多选 | +| `criteria` | 非下拉规则的运算符和条件值 | +| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 | + +只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。 + +### 工作簿设置与其他变更 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` | +| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` | +| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` | + +`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。 + +## 常用详情值 + +### 单元格内容 + +`cell` 常见字段: + +- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。 +- `formula`:本次写入的可读公式。 +- `formulaCleared=true`:本次清除了公式。 +- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。 +- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。 + +### 清除、null 与字段缺失 + +- 对象整体为 `{ "cleared": true }`:本次清除了该对象。 +- `formulaCleared=true`:本次清除了公式。 +- `styleCleared=true`:本次清除了该相对位置的样式。 +- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。 +- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。 + +不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。 + +## Agent 解读顺序 + +1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。 +2. 直接遍历 `data.changesets`,不要把它当作字符串。 +3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。 +4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。 +5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。 +6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。 +7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。 +8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。 +9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。 +10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。 + +## 最终状态回读 + +changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。 + +- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。 +- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。 +- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。 +- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。 +- `STATE_RESET`:重新读取工作簿当前状态。 +- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。 + +## 常见失败处理 + +- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。 +- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。 +- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。 +- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。 +- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。 diff --git a/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index 20cb2b32..574c0d94 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -99,7 +99,7 @@ Flags: - `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 - 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) - 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 -- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 +- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并 ### 批量设置单元格样式(服务端原子事务) ``` @@ -161,7 +161,7 @@ Flags: - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** - 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) -- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 +- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域 ### 合并单元格 ``` @@ -304,7 +304,7 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 - `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 - `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 -- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) +- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/mono/references/products/sheet/sheet-version.md b/skills/mono/references/products/sheet/sheet-version.md index 3a119745..2217bb16 100644 --- a/skills/mono/references/products/sheet/sheet-version.md +++ b/skills/mono/references/products/sheet/sheet-version.md @@ -5,10 +5,10 @@ 管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。 - 手动保存当前表格为一个版本快照 → `version save` -- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`) -- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认) +- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段 +- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认) -三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。 +三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。 ## 命令详细参考 @@ -38,7 +38,7 @@ Flags: --limit int 返回版本数量上限 (可选) --cursor string 分页游标 (可选,游标分页) ``` -返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。 +返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。 ### 回滚表格到指定版本 ``` @@ -49,21 +49,35 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) - --version int 目标版本号 (必填,从 version list 获取) + --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本只有在已经取得用户确认时才可加全局 `--yes`。 + +`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 + +### 精确 revision 回滚 + +只有用户明确要求恢复到某个 revision 时才走此流程: + +1. 用 `revision-get` 记录回滚前的当前 revision。 +2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 +3. 向用户确认目标 revision 及当前内容将被覆盖,再执行 `version revert --version --yes`。 +4. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +5. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 | +| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 | | `version save` | 版本快照结果 | 确认已生成快照 | | `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 | ## 注意事项 - ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` -- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号 -- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可 +- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision +- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 +- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) - 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成" diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 54c4ca48..8299ba06 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -70,12 +70,12 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 +本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。 **写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 -- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) @@ -104,7 +104,7 @@ Flags: **行为要点**: - 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 -- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 命令会等待新建表格可写后再写入数据,并通过回读确认结果 - 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index c884f44a..1fb2d5d1 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -54,6 +54,7 @@ | 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | +| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | ## 触发必读 @@ -63,6 +64,7 @@ - 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。 - 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。 - 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。 +- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。 ## Reference 索引 @@ -82,13 +84,14 @@ | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | -| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` | | [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | | [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | | [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | | [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | | sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` | -| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` | +| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` | +| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` | ## 全局硬约束 @@ -99,12 +102,12 @@ 5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` 6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` 7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV -8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段 +8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除 9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段 10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` 11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` 12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) -13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 +13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) 15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` 16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` @@ -115,9 +118,10 @@ - **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。 - **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 - **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 -- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 +- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。 - **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 -- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 +- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。 +- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 ## URL 粘贴场景 @@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token --name dws sheet import create --file ./data.xls --workspace ``` - 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 -- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** +- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试** - 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node` - **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md index 985e9813..9881e784 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md @@ -12,7 +12,7 @@ - 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 - 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 - `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 -- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 +- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。 --- diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md index 05b5f0c0..07c32d65 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md @@ -66,7 +66,7 @@ Flags: 查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -99,5 +99,5 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 -- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 +- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index ba0880c6..8c067aff 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -5,11 +5,11 @@ ### 导出 用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel": -- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载) +- 导出表格 → `export`(单命令会自动等待完成,并可选下载) - 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl) - 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx -- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性 +- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": - 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) @@ -38,26 +38,14 @@ Flags: --output string 本地保存路径(可选,支持文件路径或目录) ``` -将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `submit_export_job` 获取 `jobId` -2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 -3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 +将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。 **命令返回**: - `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载) - `--output` 指定为文件路径:下载到该路径并输出 `导出完成: ` - `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下 -**失败处理(命令内部已处理,Agent 仅需转述)**: +**失败处理**: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 @@ -111,7 +99,7 @@ dws sheet export-csv --node --sheet-id --allow-truncated -- ```bash # ── 工作流 12: 导出表格为 xlsx(单命令一站式)── -# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) +# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl) dws sheet export --node --format json # 传入 URL 也可: # dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json @@ -122,7 +110,7 @@ dws sheet export --node --output ./report.xlsx # 场景 C:下载到目录,自动按链接推断文件名 dws sheet export --node --output ./ -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 +# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。 # 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 ``` @@ -130,18 +118,18 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 | | `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | | `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 - ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` -- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 +- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 -- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性 - `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` - ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md b/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md index 0e65607b..90bfe695 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md @@ -173,7 +173,7 @@ Flags: - `filter update` 前置:工作表必须已创建筛选 - `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等) - `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选 -- ★ **筛选操作规范**(参照飞书 core-operations): +- ★ **筛选操作规范**: - 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替 - 创建/更新筛选后**必须** `filter get` 回读验证配置正确 - 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md index d50b27b4..b301e4bf 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md @@ -6,7 +6,7 @@ - 支持本地 `xlsx` / `xls`,文件上限 20MB - `--folder-token` 与 `--workspace` 至少提供一个 -- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 +- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试 - `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 ## 命令 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md index 06aba4b7..d3e236f0 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md @@ -228,8 +228,8 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 -- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 +- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤 +- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 - `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md index 6b8f93d2..2e451972 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md @@ -191,7 +191,7 @@ Flags: - 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` - 必须包含表头行(通常从第 1 行开始) - sheet 名称用单引号包裹(含空格或特殊字符时必须) -- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 +- `source` 直接使用上述 A1 表示法,不要改写成其他结构 ## 高级功能示例 @@ -210,7 +210,7 @@ dws sheet pivot-table create --node \ }' ``` -> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。 +> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。 ## 核心工作流 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md index f8008d0e..89fef5a2 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md @@ -54,7 +54,7 @@ Flags: - `rowIndices` — 行号映射数组(如 `[1,2,3]`) - `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 - `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 -- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围 - `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -161,7 +161,7 @@ Flags: | `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | | `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | | `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | -| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 @@ -270,6 +270,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 - ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` +- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。 - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 - `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md b/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md new file mode 100644 index 00000000..13e5b662 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md @@ -0,0 +1,306 @@ +# 工作簿 revision 与 changeset + +## 何时使用 + +当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。 + +- 当前 revision → `dws sheet revision-get` +- revision 区间内的变更 → `dws sheet changeset-get` +- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md) +- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令 + +两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。 + +revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。 + +当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。 + +## 获取当前 revision + +```bash +dws sheet revision-get --node --format json +``` + +成功时从统一输出的 `data.revision` 读取当前 revision: + +```json +{ + "ok": true, + "outcome": "success", + "data": { + "success": true, + "logId": "trace-id", + "revision": 142 + } +} +``` + +- `revision=0` 是合法的空工作簿基线。 +- revision 不是工作表数量,也不是历史版本列表序号。 +- `logId` 仅用于失败反馈和问题排查,不是 revision。 + +## 获取 revision 区间 changeset + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --end-revision 140 \ + --format json +``` + +也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision: + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --format json +``` + +参数规则: + +- `--start-revision` 必填,必须为非负整数;`0` 合法。 +- `--end-revision` 可选;提供时必须大于或等于 start。 +- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。 +- `startRevision == endRevision` 合法,返回空 `changesets`。 +- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。 +- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。 +- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。 + +成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。 + +## 先看区间摘要 + +`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响: + +| 字段 | 含义 | +|------|------| +| `changeCount` | `changes[]` 中变更对象的总数 | +| `completeChangeCount` | 详情完整的变更数量 | +| `partialChangeCount` | 只返回部分可信详情的变更数量 | +| `unsupportedChangeCount` | 当前无法解释的变更数量 | +| `containsStateReset` | 区间内是否发生整表状态替换 | +| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 | +| `affectedSheets` | 受影响工作表及其 A1 范围摘要 | + +`affectedSheets[]` 常用字段: + +| 字段 | 含义 | +|------|------| +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 | +| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` | + +摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。 + +## 读取 `changesets[]` + +每个 changeset 对应一个 revision: + +| 字段 | 含义 | 使用规则 | +|------|------|----------| +| `revision` | 该事件对应的 revision | 按升序解释 | +| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 | +| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 | +| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 | +| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 | +| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 | + +事件类型: + +- `EDIT`:一次普通编辑提交产生的效果。 +- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。 +- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。 + +changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。 + +## 读取 `reset` + +| 字段 | 含义 | +|------|------| +| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` | +| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 | +| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 | + +当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。 + +回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。 + +## 读取 `changes[]` + +每个 change 都按以下顺序读取: + +1. `type`:发生了哪类变更。 +2. `targets`:变更作用在哪个工作簿、工作表或范围。 +3. `detailsStatus`:详情是否完整。 +4. `details`:本次变更的可用详情。 +5. `omissions`:哪些详情未能提供。 + +公共字段: + +| 字段 | 含义 | +|------|------| +| `type` | 语义变更类型,见下方字典 | +| `targets` | 工作簿、工作表或 A1 范围定位数组 | +| `details` | 与 `type` 对应的变更详情;无详情时为空对象 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | +| `omissions` | 未提供详情的原因;没有省略时为空数组 | + +`detailsStatus` 的处理规则: + +- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。 +- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。 +- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。 + +`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。 + +## 读取 `targets[]` + +| 字段 | 含义 | +|------|------| +| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` | +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称 | +| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 | +| `a1Range` | 1-based A1 范围 | +| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 | + +- `SOURCE` 是读取或复制来源,不表示该位置被修改。 +- `DESTINATION` 是写入目标,应算作受影响位置。 +- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。 +- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。 + +## 变更类型字典 + +### 行列与工作表 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` | +| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` | +| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` | +| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` | +| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 | +| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` | +| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` | + +`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。 + +### 单元格与范围 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` | +| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` | +| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` | +| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` | +| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` | +| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` | +| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` | +| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` | +| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` | +| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` | +| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` | +| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` | + +粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。 + +`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。 + +`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。 + +### 分组、数据验证和结构对象 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` | +| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` | +| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` | +| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` | +| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` | +| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` | +| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` | + +扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。 + +`dataValidation` 常用字段: + +| 字段 | 含义 | +|------|------| +| `type` | 验证规则类型 | +| `sourceType` | 下拉来源:`inline` 或 `sourceRange` | +| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` | +| `sourceRange` | 已确认的来源工作表与 A1 范围 | +| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` | +| `sourceRangeExpression` | 可读的来源表达式 | +| `enableMultiSelect` | 是否允许多选 | +| `criteria` | 非下拉规则的运算符和条件值 | +| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 | + +只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。 + +### 工作簿设置与其他变更 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` | +| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` | +| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` | + +`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。 + +## 常用详情值 + +### 单元格内容 + +`cell` 常见字段: + +- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。 +- `formula`:本次写入的可读公式。 +- `formulaCleared=true`:本次清除了公式。 +- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。 +- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。 + +### 清除、null 与字段缺失 + +- 对象整体为 `{ "cleared": true }`:本次清除了该对象。 +- `formulaCleared=true`:本次清除了公式。 +- `styleCleared=true`:本次清除了该相对位置的样式。 +- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。 +- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。 + +不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。 + +## Agent 解读顺序 + +1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。 +2. 直接遍历 `data.changesets`,不要把它当作字符串。 +3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。 +4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。 +5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。 +6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。 +7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。 +8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。 +9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。 +10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。 + +## 最终状态回读 + +changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。 + +- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。 +- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。 +- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。 +- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。 +- `STATE_RESET`:重新读取工作簿当前状态。 +- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。 + +## 常见失败处理 + +- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。 +- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。 +- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。 +- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。 +- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md index 20cb2b32..574c0d94 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md @@ -99,7 +99,7 @@ Flags: - `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 - 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) - 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 -- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 +- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并 ### 批量设置单元格样式(服务端原子事务) ``` @@ -161,7 +161,7 @@ Flags: - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** - 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) -- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 +- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域 ### 合并单元格 ``` @@ -304,7 +304,7 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 - `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 - `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 -- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) +- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md index 3a119745..2217bb16 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md @@ -5,10 +5,10 @@ 管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。 - 手动保存当前表格为一个版本快照 → `version save` -- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`) -- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认) +- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段 +- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认) -三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。 +三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。 ## 命令详细参考 @@ -38,7 +38,7 @@ Flags: --limit int 返回版本数量上限 (可选) --cursor string 分页游标 (可选,游标分页) ``` -返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。 +返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。 ### 回滚表格到指定版本 ``` @@ -49,21 +49,35 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) - --version int 目标版本号 (必填,从 version list 获取) + --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本只有在已经取得用户确认时才可加全局 `--yes`。 + +`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 + +### 精确 revision 回滚 + +只有用户明确要求恢复到某个 revision 时才走此流程: + +1. 用 `revision-get` 记录回滚前的当前 revision。 +2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 +3. 向用户确认目标 revision 及当前内容将被覆盖,再执行 `version revert --version --yes`。 +4. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +5. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 | +| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 | | `version save` | 版本快照结果 | 确认已生成快照 | | `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 | ## 注意事项 - ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` -- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号 -- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可 +- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision +- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 +- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) - 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成" diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index 54c4ca48..8299ba06 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -70,12 +70,12 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 +本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。 **写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 -- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) @@ -104,7 +104,7 @@ Flags: **行为要点**: - 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 -- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 命令会等待新建表格可写后再写入数据,并通过回读确认结果 - 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 From 7f4318a10d41fc0d280e3ac5de70b493d296ba9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 15:39:14 +0800 Subject: [PATCH 208/267] =?UTF-8?q?fix=E5=AE=A1=E6=89=B9skill=E4=B8=AD?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E6=8F=8F=E8=BF=B0=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/mono/references/products/oa.md | 15 ++++++++++++--- .../references/products/oa/oa-form-components.md | 6 +++--- skills/multi/dingtalk-misc/references/oa.md | 15 ++++++++++++--- .../references/oa/oa-form-components.md | 6 +++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 6add42e9..5a944b68 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -308,7 +308,7 @@ Flags: - **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。 - **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。 -- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。 +- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file ` 获取字段再组装为 value 提交,不属于不支持类型。 - **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。** - **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。 - **严禁在未得到用户确认前直接执行真实提单。** @@ -427,7 +427,7 @@ Flags: | 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 | | 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 | | 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** | -| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 | +| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file ` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 | | 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) | | 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 | | 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 | @@ -442,9 +442,18 @@ Flags: - `CalculateField`(计算公式)— 由系统自动计算 - `SeqNumberField`(流水号)— 由系统自动生成 - `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互 -- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取 - **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作 +> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file ` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例: +> +> ```bash +> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType +> dws oa approval attachment upload --file ./a.pdf +> # 2) 组装 value 并发起(value 为 JSON 数组转义字符串) +> dws oa approval create-instance --process-code PROC-xxx \ +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' --yes +> ``` + > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。 diff --git a/skills/mono/references/products/oa/oa-form-components.md b/skills/mono/references/products/oa/oa-form-components.md index 5e795ce4..b54408e3 100644 --- a/skills/mono/references/products/oa/oa-form-components.md +++ b/skills/mono/references/products/oa/oa-form-components.md @@ -239,14 +239,14 @@ ### DDAttachment(附件控件) -> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。 +> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file ` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。 | 属性 | 说明 | |------|------| | `componentName` | `DDAttachment` | | value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId | | 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` | -| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 | +| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file ` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 | ### StarRatingField(评分控件) @@ -328,7 +328,7 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 | +| 附件控件 | `DDAttachment` | 已支持:value 需要 spaceId、fileName、fileSize、fileType、fileId,先用 `dws oa approval attachment upload --file ` 上传获取再组装提交 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index e8800eeb..63a202e6 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -311,7 +311,7 @@ Flags: - **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。 - **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。 -- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。 +- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file ` 获取字段再组装为 value 提交,不属于不支持类型。 - **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。** - **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。 - **严禁在未得到用户确认前直接执行真实提单。** @@ -430,7 +430,7 @@ Flags: | 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 | | 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 | | 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** | -| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 | +| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file ` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 | | 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) | | 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 | | 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 | @@ -445,9 +445,18 @@ Flags: - `CalculateField`(计算公式)— 由系统自动计算 - `SeqNumberField`(流水号)— 由系统自动生成 - `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互 -- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取 - **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作 +> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file ` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例: +> +> ```bash +> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType +> dws oa approval attachment upload --file ./a.pdf +> # 2) 组装 value 并发起(value 为 JSON 数组转义字符串) +> dws oa approval create-instance --process-code PROC-xxx \ +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' --yes +> ``` + > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。 diff --git a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md index 79e7ae5c..2b319dcc 100644 --- a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md +++ b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md @@ -239,14 +239,14 @@ ### DDAttachment(附件控件) -> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。 +> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file ` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。 | 属性 | 说明 | |------|------| | `componentName` | `DDAttachment` | | value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId | | 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` | -| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 | +| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file ` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 | ### StarRatingField(评分控件) @@ -328,7 +328,7 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 | +| 附件控件 | `DDAttachment` | 已支持:value 需要 spaceId、fileName、fileSize、fileType、fileId,先用 `dws oa approval attachment upload --file ` 上传获取再组装提交 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 From 87ab311764ad5af8a526d9e925a93a33a7b9c04e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:55:40 +0000 Subject: [PATCH 209/267] chore: update beta formula for v1.0.59-beta.5 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 285d2254..52036bdf 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.59-beta.4" + version "1.0.59-beta.5" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz" - sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz" + sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz" - sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz" + sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz" - sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz" + sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz" - sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz" + sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip" - sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip" + sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47" end def install From 9c6ab99bf13ce534c77473d6bc53054725d86fd5 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 16:01:09 +0800 Subject: [PATCH 210/267] chore: prepare v1.0.59 changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa7df60..76de4f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59] - 2026-08-20 + +This release promotes the sealed `v1.0.59-beta.5` contents to stable. + +### Changed + +- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions. + +- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage. + +- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations. + ## [1.0.59-beta.5] - 2026-08-20 ### Added From fbcd8887ee3e81d448915ef8e9f03d453b0a539a Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:09:09 +0800 Subject: [PATCH 211/267] fix: harden calendar todo and comment shortcuts --- internal/commentreaction/validate.go | 56 ++++++++++++++++ internal/commentreaction/validate_test.go | 19 ++++++ internal/helpers/comment_base.go | 4 ++ internal/helpers/comment_base_test.go | 23 +++++++ internal/helpers/doc.go | 4 ++ internal/helpers/doc_group_mentions_test.go | 6 +- internal/helpers/sheet_comment.go | 4 ++ internal/helpers/sheet_ports_coverage_test.go | 4 +- .../calendar_cross_platform_coverage_test.go | 22 ++++-- internal/shortcut/calendar/common.go | 4 ++ .../shortcut/calendarcompat/empty_events.go | 48 +++++++++++++ .../calendarcompat/empty_events_test.go | 42 ++++++++++++ internal/shortcut/doc/doc.go | 4 ++ internal/shortcut/doc/doc_shortcuts_test.go | 15 +++++ internal/shortcut/smart/calendar_shared.go | 2 + ...dar_strict_cross_platform_coverage_test.go | 13 ++++ internal/shortcut/todo/lifecycle.go | 67 ++++++++++++++++++- ...additional_cross_platform_coverage_test.go | 13 +++- 18 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 internal/commentreaction/validate.go create mode 100644 internal/commentreaction/validate_test.go create mode 100644 internal/shortcut/calendarcompat/empty_events.go create mode 100644 internal/shortcut/calendarcompat/empty_events_test.go diff --git a/internal/commentreaction/validate.go b/internal/commentreaction/validate.go new file mode 100644 index 00000000..f74ab562 --- /dev/null +++ b/internal/commentreaction/validate.go @@ -0,0 +1,56 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package commentreaction validates the reviewed DingTalk names accepted by +// comment emoji replies. The names mirror the bundled default emoji catalog; +// arbitrary text and raw Unicode emoji must never be persisted as reactions. +package commentreaction + +import ( + "strings" + "unicode/utf8" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Validate rejects values outside DingTalk's reviewed default reaction-name +// catalog. Names are case-sensitive because they are sent verbatim to the +// doc-comment service. +func Validate(value string) error { + name := strings.TrimSpace(value) + if value != name || !utf8.ValidString(name) || !supported(name) { + return apperrors.NewValidation( + "--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本", + apperrors.WithReason("unsupported_comment_reaction"), + ) + } + return nil +} + +func supported(name string) bool { + switch name { + case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴", + "睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳", + "握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住", + "比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病", + "吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕", + "骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见", + "投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈", + "灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰", + "炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶", + "发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头", + "白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸", + "Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注", + "忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班", + "退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬", + "选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物", + "生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶", + "OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差", + "高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音", + "公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹", + "烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜": + return true + default: + return false + } +} diff --git a/internal/commentreaction/validate_test.go b/internal/commentreaction/validate_test.go new file mode 100644 index 00000000..2aaa71b7 --- /dev/null +++ b/internal/commentreaction/validate_test.go @@ -0,0 +1,19 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package commentreaction + +import "testing" + +func TestValidate(t *testing.T) { + for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} { + if err := Validate(value); err != nil { + t.Errorf("supported reaction %q rejected: %v", value, err) + } + } + for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} { + if err := Validate(value); err == nil { + t.Errorf("unsupported reaction %q accepted", value) + } + } +} diff --git a/internal/helpers/comment_base.go b/internal/helpers/comment_base.go index 81f0c8e3..4af1c8ca 100644 --- a/internal/helpers/comment_base.go +++ b/internal/helpers/comment_base.go @@ -18,6 +18,7 @@ import ( "fmt" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传 if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil { return err } + if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil { + return err + } return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{ "nodeId": nodeID, "replyCommentKey": mustGetFlag(cmd, "comment-key"), diff --git a/internal/helpers/comment_base_test.go b/internal/helpers/comment_base_test.go index 2d6e8638..423c0797 100644 --- a/internal/helpers/comment_base_test.go +++ b/internal/helpers/comment_base_test.go @@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) { } } +func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) { + for _, surface := range []string{"doc", "sheet"} { + for _, test := range []struct { + name string + args []string + }{ + {name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}}, + {name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}}, + {name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}}, + } { + t.Run(surface+"/"+test.name, func(t *testing.T) { + caller := &docCommentMutationCaller{} + if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil { + t.Fatal("unsupported reaction accepted") + } + if len(caller.calls) != 0 { + t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls) + } + }) + } + } +} + func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) { for _, surface := range []struct { name string diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 177f2976..23cbb186 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -15,6 +15,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 "replyCommentKey": mustGetFlag(cmd, "comment-key"), } if v, _ := cmd.Flags().GetBool("emoji"); v { + if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil { + return err + } groupMentions, err := commentGroupMentionIDs(cmd) if err != nil { return err diff --git a/internal/helpers/doc_group_mentions_test.go b/internal/helpers/doc_group_mentions_test.go index cebdcb80..ea17f12e 100644 --- a/internal/helpers/doc_group_mentions_test.go +++ b/internal/helpers/doc_group_mentions_test.go @@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", "--mentioned-open-conversation-id", "oc-1", ) if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") { @@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", "--mentioned-open-conversation-id", " ", ) if err == nil || !strings.Contains(err.Error(), "must not be empty") { @@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", ) if err != nil { diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go index c0b62612..b16ddf33 100644 --- a/internal/helpers/sheet_comment.go +++ b/internal/helpers/sheet_comment.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command { "replyCommentKey": mustGetFlag(cmd, "comment-key"), } if v, _ := cmd.Flags().GetBool("emoji"); v { + if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil { + return err + } toolArgs["emoji"] = true } if v, _ := cmd.Flags().GetString("mention"); v != "" { diff --git a/internal/helpers/sheet_ports_coverage_test.go b/internal/helpers/sheet_ports_coverage_test.go index c8aa0fc5..7ce43f29 100644 --- a/internal/helpers/sheet_ports_coverage_test.go +++ b/internal/helpers/sheet_ports_coverage_test.go @@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) { "comment", "reply", "--node", "node-1", "--comment-key", "ck-1", - "--content", "heart", + "--content", "比心", "--emoji", "--mention", "uid1", }, @@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) { toolName: "reply_comment", args: map[string]any{ "nodeId": "node-1", - "content": "heart", + "content": "比心", "replyCommentKey": "ck-1", "emoji": true, "mentionedUserIds": []string{"uid1"}, diff --git a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go index e3f2ec4a..342d07b1 100644 --- a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go +++ b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go @@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio if err != nil || len(events) != 0 || !page.Known || page.HasMore { t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err) } + var serviceEmptySentinel map[string]any + if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil { + t.Fatal(err) + } + events, page, err = eventListProject(serviceEmptySentinel) + if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore { + t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err) + } for name, payload := range map[string]string{ - "missing collection": `{"success":true,"result":{"hasMore":false}}`, - "bad collection": `{"success":true,"result":{"events":{}}}`, - "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`, - "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`, - "missing pagination": `{"success":true,"result":{"events":[]}}`, - "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`, + "missing collection": `{"success":true,"result":{"hasMore":false}}`, + "bad collection": `{"success":true,"result":{"events":{}}}`, + "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`, + "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`, + "unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`, + "sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`, + "missing pagination": `{"success":true,"result":{"events":[]}}`, + "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`, } { t.Run(name, func(t *testing.T) { var data map[string]any diff --git a/internal/shortcut/calendar/common.go b/internal/shortcut/calendar/common.go index e0d7c27f..f54b6fa0 100644 --- a/internal/shortcut/calendar/common.go +++ b/internal/shortcut/calendar/common.go @@ -16,6 +16,7 @@ import ( 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" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat" ) const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract." @@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st if !ok { return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) } + if key == "events" { + items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container) + } if err := validateCalendarCollectionItems(items, operation, key); err != nil { return nil, nil, err } diff --git a/internal/shortcut/calendarcompat/empty_events.go b/internal/shortcut/calendarcompat/empty_events.go new file mode 100644 index 00000000..fc96e300 --- /dev/null +++ b/internal/shortcut/calendarcompat/empty_events.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package calendarcompat contains narrow compatibility adapters for observed +// Calendar wire shapes shared by the calendar and smart Shortcut packages. +package calendarcompat + +// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty +// page sentinel. Some deployments encode an exhausted empty page as one object +// whose collection fields are all null instead of events:[]. Only that exact +// terminal shape is normalized; arbitrary id-less or mixed event rows remain +// available to the strict caller validation and must fail closed. +func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) { + if len(items) != 1 || container == nil { + return items, false + } + hasMore, ok := container["hasMore"].(bool) + if !ok || hasMore || hasNonEmptyCursor(container) { + return items, false + } + placeholder, ok := items[0].(map[string]any) + knownNullFields := map[string]struct{}{ + "attendees": {}, + "categories": {}, + "meetingRooms": {}, + "reminders": {}, + } + if !ok || len(placeholder) != len(knownNullFields) { + return items, false + } + for key, value := range placeholder { + if _, known := knownNullFields[key]; !known || value != nil { + return items, false + } + } + return []any{}, true +} + +func hasNonEmptyCursor(container map[string]any) bool { + for _, key := range []string{"nextCursor", "nextToken", "pageToken"} { + value, present := container[key] + if !present || value == nil || value == "" { + continue + } + return true + } + return false +} diff --git a/internal/shortcut/calendarcompat/empty_events_test.go b/internal/shortcut/calendarcompat/empty_events_test.go new file mode 100644 index 00000000..57564044 --- /dev/null +++ b/internal/shortcut/calendarcompat/empty_events_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package calendarcompat + +import "testing" + +func TestNormalizeTerminalEmptyEvents(t *testing.T) { + sentinel := []any{map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil, + }} + if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 { + t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok) + } + + for name, items := range map[string][]any{ + "empty object": {map[string]any{}}, + "missing fields": {map[string]any{"attendees": nil}}, + "unknown field": {map[string]any{"summary": nil}}, + "non-null field": {map[string]any{ + "attendees": []any{}, "categories": nil, + }}, + "mixed rows": {sentinel[0], map[string]any{"id": "event-1"}}, + } { + t.Run(name, func(t *testing.T) { + if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok { + t.Fatalf("invalid sentinel accepted: %#v", items) + } + }) + } + for name, container := range map[string]map[string]any{ + "missing terminal evidence": {}, + "more pages": {"hasMore": true}, + "cursor": {"hasMore": false, "nextCursor": "next"}, + } { + t.Run(name, func(t *testing.T) { + if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok { + t.Fatalf("non-terminal sentinel accepted with %#v", container) + } + }) + } +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go index 494ee29a..0b66fe44 100644 --- a/internal/shortcut/doc/doc.go +++ b/internal/shortcut/doc/doc.go @@ -19,6 +19,7 @@ package doc import ( "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" @@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{ "replyCommentKey": rt.Str("comment-key"), } if rt.Bool("emoji") { + if err := commentreaction.Validate(rt.Str("content")); err != nil { + return err + } params["emoji"] = true } if rt.Changed("mention") { diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 8b3da279..33b37c81 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -132,6 +132,21 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc return root.Execute() } +func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) { + for _, content := range []string{"😄", "乱码"} { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + err := runDocCoverage(t, CommentReply, caller, + "--node", "node-1", "--comment-key", "comment-1", + "--content", content, "--emoji", "--yes") + if err == nil { + t.Fatalf("unsupported reaction %q accepted", content) + } + if caller.calls != 0 { + t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls) + } + } +} + func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) { revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}} if got, ok := nestedRevision(revisionPayload); !ok || got != 9 { diff --git a/internal/shortcut/smart/calendar_shared.go b/internal/shortcut/smart/calendar_shared.go index a50709cf..96bee89e 100644 --- a/internal/shortcut/smart/calendar_shared.go +++ b/internal/shortcut/smart/calendar_shared.go @@ -23,6 +23,7 @@ import ( 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" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat" ) const calendarSmartMaxPages = 20 @@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string if !hasMore && next != "" { return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空") } + list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result) events := make([]map[string]any, 0, len(list)) for index, item := range list { event, ok := item.(map[string]any) diff --git a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go index a5b989cf..eb65e7da 100644 --- a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go @@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) { if err != nil || items == nil || len(items) != 0 || hasMore || next != "" { t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err) } + items, hasMore, next, err = calendarSmartEventPage(map[string]any{ + "success": true, + "result": map[string]any{ + "events": []any{map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil, + }}, + "hasMore": false, + }, + }) + if err != nil || items == nil || len(items) != 0 || hasMore || next != "" { + t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err) + } invalidPages := []map[string]any{ {}, @@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) { {"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}}, {"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}}, {"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}}, + {"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}}, {"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}}, } for index, page := range invalidPages { diff --git a/internal/shortcut/todo/lifecycle.go b/internal/shortcut/todo/lifecycle.go index e517fd05..07384206 100644 --- a/internal/shortcut/todo/lifecycle.go +++ b/internal/shortcut/todo/lifecycle.go @@ -6,6 +6,7 @@ package todo import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -159,13 +160,77 @@ func updateTodo(rt *shortcut.RuntimeContext) error { if key == "taskId" { continue } - if fmt.Sprint(detail[key]) != fmt.Sprint(expected) { + if !todoUpdateFieldMatches(key, detail[key], expected) { return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致") } } return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail}) } +func todoUpdateFieldMatches(key string, actual, expected any) bool { + switch key { + case "dueTime", "priority": + actualNumber, actualOK := todoExactInteger(actual) + expectedNumber, expectedOK := todoExactInteger(expected) + return actualOK && expectedOK && actualNumber == expectedNumber + case "subject": + actualText, actualOK := actual.(string) + expectedText, expectedOK := expected.(string) + return actualOK && expectedOK && actualText == expectedText + default: + return false + } +} + +func todoExactInteger(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int8: + return int64(typed), true + case int16: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case uint: + if uint64(typed) > math.MaxInt64 { + return 0, false + } + return int64(typed), true + case uint8: + return int64(typed), true + case uint16: + return int64(typed), true + case uint32: + return int64(typed), true + case uint64: + if typed > math.MaxInt64 { + return 0, false + } + return int64(typed), true + case float64: + return todoExactFloat(typed) + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed, err == nil + default: + return 0, false + } +} + +func todoExactFloat(value float64) (int64, bool) { + if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value || + value < float64(math.MinInt64) || value >= float64(math.MaxInt64) { + return 0, false + } + return int64(value), true +} + var Update = shortcut.Shortcut{ OutputRollout: output.RolloutUnifiedActive, Service: "todo", Command: "+update", Product: "todo", diff --git a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go index 47d46685..cb137212 100644 --- a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go +++ b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "io" + "math" "strconv" "strings" "testing" @@ -313,11 +314,19 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) { } updateSuccess := &todoCoverageCaller{responses: map[string][]string{ "update_todo_task": {`{"success":true}`}, - "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`}, + "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`}, }} - if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil { + if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil { t.Fatal(err) } + if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) { + t.Fatal("different dueTime milliseconds matched") + } + for _, value := range []any{1786932000000.5, "not-a-number", uint64(math.MaxInt64) + 1} { + if _, ok := todoExactInteger(value); ok { + t.Fatalf("invalid integer accepted: %#v", value) + } + } } func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) { From 61f8140f910ec69bcbc3e3e677cc1b941f650b22 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:38:05 +0800 Subject: [PATCH 212/267] test: close shortcut regression coverage gaps --- internal/commentreaction/validate_test.go | 2 +- .../calendarcompat/empty_events_test.go | 8 +++--- internal/shortcut/doc/doc_shortcuts_test.go | 10 ++++++++ internal/shortcut/todo/lifecycle.go | 22 ---------------- ...additional_cross_platform_coverage_test.go | 25 ++++++++++++++++--- 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/internal/commentreaction/validate_test.go b/internal/commentreaction/validate_test.go index 2aaa71b7..abcea8a7 100644 --- a/internal/commentreaction/validate_test.go +++ b/internal/commentreaction/validate_test.go @@ -5,7 +5,7 @@ package commentreaction import "testing" -func TestValidate(t *testing.T) { +func TestCrossPlatformCoverageValidate(t *testing.T) { for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} { if err := Validate(value); err != nil { t.Errorf("supported reaction %q rejected: %v", value, err) diff --git a/internal/shortcut/calendarcompat/empty_events_test.go b/internal/shortcut/calendarcompat/empty_events_test.go index 57564044..c5e5d930 100644 --- a/internal/shortcut/calendarcompat/empty_events_test.go +++ b/internal/shortcut/calendarcompat/empty_events_test.go @@ -5,7 +5,7 @@ package calendarcompat import "testing" -func TestNormalizeTerminalEmptyEvents(t *testing.T) { +func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) { sentinel := []any{map[string]any{ "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil, }} @@ -16,9 +16,11 @@ func TestNormalizeTerminalEmptyEvents(t *testing.T) { for name, items := range map[string][]any{ "empty object": {map[string]any{}}, "missing fields": {map[string]any{"attendees": nil}}, - "unknown field": {map[string]any{"summary": nil}}, + "unknown field": {map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil, + }}, "non-null field": {map[string]any{ - "attendees": []any{}, "categories": nil, + "attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil, }}, "mixed rows": {sentinel[0], map[string]any{"id": "event-1"}}, } { diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 33b37c81..db3e9441 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -145,6 +145,16 @@ func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *te t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls) } } + + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, CommentReply, caller, + "--node", "node-1", "--comment-key", "comment-1", + "--content", "鼓掌", "--emoji", "--yes"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 || caller.history[0].params["emoji"] != true { + t.Fatalf("valid reaction call = %#v", caller.history) + } } func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) { diff --git a/internal/shortcut/todo/lifecycle.go b/internal/shortcut/todo/lifecycle.go index 07384206..9aba6481 100644 --- a/internal/shortcut/todo/lifecycle.go +++ b/internal/shortcut/todo/lifecycle.go @@ -186,30 +186,8 @@ func todoExactInteger(value any) (int64, bool) { switch typed := value.(type) { case int: return int64(typed), true - case int8: - return int64(typed), true - case int16: - return int64(typed), true - case int32: - return int64(typed), true case int64: return typed, true - case uint: - if uint64(typed) > math.MaxInt64 { - return 0, false - } - return int64(typed), true - case uint8: - return int64(typed), true - case uint16: - return int64(typed), true - case uint32: - return int64(typed), true - case uint64: - if typed > math.MaxInt64 { - return 0, false - } - return int64(typed), true case float64: return todoExactFloat(typed) case json.Number: diff --git a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go index cb137212..7bbfc282 100644 --- a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go +++ b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "io" - "math" "strconv" "strings" "testing" @@ -322,11 +321,29 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) { if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) { t.Fatal("different dueTime milliseconds matched") } - for _, value := range []any{1786932000000.5, "not-a-number", uint64(math.MaxInt64) + 1} { - if _, ok := todoExactInteger(value); ok { - t.Fatalf("invalid integer accepted: %#v", value) + for _, tc := range []struct { + value any + want int64 + ok bool + }{ + {value: int(40), want: 40, ok: true}, + {value: int64(1786932000000), want: 1786932000000, ok: true}, + {value: float64(40), want: 40, ok: true}, + {value: json.Number("40"), want: 40, ok: true}, + {value: " 40 ", want: 40, ok: true}, + {value: 1786932000000.5}, + {value: json.Number("not-a-number")}, + {value: "not-a-number"}, + {value: true}, + } { + got, ok := todoExactInteger(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok) } } + if todoUpdateFieldMatches("unknown", 1, 1) { + t.Fatal("unknown update field matched") + } } func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) { From e95ac52ff43a29a9425557c08df72c872904a58a Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Thu, 20 Aug 2026 16:41:35 +0800 Subject: [PATCH 213/267] refresh org slot not only identity --- internal/auth/oauth_provider.go | 73 ++++- .../auth/org_slot_refresh_fallback_test.go | 304 ++++++++++++++++++ internal/auth/refresh_failure.go | 13 + 3 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 internal/auth/org_slot_refresh_fallback_test.go diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index da6b66df..2e20d5e5 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -811,7 +811,78 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { if p.logger != nil { p.logger.Debug("refreshing token (dual-locked)") } - return oauthRefreshToken(p, ctx, data) + refreshed, rErr := oauthRefreshToken(p, ctx, data) + if rErr == nil || !isRefreshTokenRejected(rErr) { + return refreshed, rErr + } + // A stale identity slot can survive an older organization-only refresh. + // Retry once with the same-corp organization mirror while holding the + // existing dual lock; the refresh path repairs both persisted slots. + logging.AuthDebug( + "auth.refresh.fallback.triggered", + "corp_id", strings.TrimSpace(data.CorpID), + "user_id", strings.TrimSpace(data.UserID), + "error", rErr, + ) + fallback, fErr := p.refreshFromOrgSlot(ctx, data) + if fErr != nil { + logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr) + return nil, rErr + } + if p.logger != nil { + p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态")) + } + return fallback, nil +} + +// refreshFromOrgSlot retries a rejected refresh with the token mirrored in +// the organization slot. The mirror must match the current corp, be valid, +// and differ from the rejected token. When both slots carry user identities, +// they must agree; legacy mirrors with an empty UserID are backfilled from the +// current identity before refresh. +func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) { + if current == nil { + return nil, fmt.Errorf("no current token data") + } + corpID := strings.TrimSpace(current.CorpID) + if corpID == "" { + return nil, fmt.Errorf("current token has no corpId") + } + orgData, err := tokenLoadKeychainForCorpID(corpID) + if err != nil { + return nil, err + } + if orgData == nil { + return nil, ErrTokenDataNotFound + } + if strings.TrimSpace(orgData.CorpID) != corpID { + return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID) + } + if !orgData.IsRefreshTokenValid() { + return nil, fmt.Errorf("organization mirror refresh_token 已过期") + } + if orgData.RefreshToken == current.RefreshToken { + return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token") + } + currentUserID := strings.TrimSpace(current.UserID) + orgUserID := strings.TrimSpace(orgData.UserID) + if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID { + return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID) + } + if orgUserID == "" { + orgData.UserID = current.UserID + orgData.UserName = current.UserName + } + refreshed, err := oauthRefreshToken(p, ctx, orgData) + if err != nil { + return nil, err + } + logging.AuthDebug( + "auth.refresh.fallback.success", + "corp_id", corpID, + "new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339), + ) + return refreshed, nil } // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an diff --git a/internal/auth/org_slot_refresh_fallback_test.go b/internal/auth/org_slot_refresh_fallback_test.go new file mode 100644 index 00000000..1ad3cf4a --- /dev/null +++ b/internal/auth/org_slot_refresh_fallback_test.go @@ -0,0 +1,304 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true}, + {"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false}, + {"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true}, + {"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false}, + {"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false}, + {"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false}, + {"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false}, + {"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false}, + {"plain error is unknown", errors.New("boom"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRefreshTokenRejected(tt.err); got != tt.want { + t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on +// and records refresh attempts plus organization slot lookups. +type orgSlotFallbackFixture struct { + provider *OAuthProvider + stale *TokenData + orgMirror *TokenData + renewed *TokenData + rejected *MCPTokenExchangeError + refreshErr error + orgRefreshErr error + refreshCalls []string + refreshUserIDs []string + orgLoads int +} + +func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture { + t.Helper() + isolateOAuthPersistence(t) + + f := &orgSlotFallbackFixture{ + provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard}, + stale: &TokenData{ + AccessToken: "old-access", + RefreshToken: "stale-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + orgMirror: &TokenData{ + AccessToken: "org-access", + RefreshToken: "org-refresh", + ExpiresAt: time.Now().Add(-time.Minute), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + renewed: &TokenData{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, + } + f.refreshErr = f.rejected + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) }) + testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil }) + testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + f.refreshCalls = append(f.refreshCalls, data.RefreshToken) + f.refreshUserIDs = append(f.refreshUserIDs, data.UserID) + switch data.RefreshToken { + case "stale-refresh": + return nil, f.refreshErr + case "org-refresh": + return f.renewed, f.orgRefreshErr + } + return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken) + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) { + f.orgLoads++ + if corpID != "corp-1" { + return nil, ErrTokenDataNotFound + } + return f.orgMirror, nil + }) + return f +} + +func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + + got, err := f.provider.lockedRefresh(context.Background()) + if err != nil || got != f.renewed { + t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err) + } + if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" { + t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls) + } + if f.orgLoads != 1 { + t.Fatalf("organization slot loads = %d, want 1", f.orgLoads) + } +} + +func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) { + t.Run("transient failure does not fall back", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError} + + _, err := f.provider.lockedRefresh(context.Background()) + if err == nil || err.Error() != f.refreshErr.Error() { + t.Fatalf("lockedRefresh() error = %v, want transient failure", err) + } + if f.orgLoads != 0 { + t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("missing org slot preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { + f.orgLoads++ + return nil, ErrTokenDataNotFound + }) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("nil org data from keychain preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { + f.orgLoads++ + return nil, nil + }) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("org slot refresh failure preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgRefreshErr = fmt.Errorf("org mirror refresh failed") + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 2 { + t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls) + } + }) + + t.Run("different user in org slot is rejected", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.UserID = "user-2" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls) + } + }) + + t.Run("empty org slot user identity is backfilled", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.UserID = "" + f.orgMirror.UserName = "" + + got, err := f.provider.lockedRefresh(context.Background()) + if err != nil || got != f.renewed { + t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err) + } + if len(f.refreshCalls) != 2 { + t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls) + } + if f.refreshUserIDs[1] != "user-1" { + t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1]) + } + }) + + t.Run("same rejected refresh token is skipped", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.RefreshToken = "stale-refresh" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls) + } + }) + + t.Run("expired org refresh token is skipped", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("missing corp id skips fallback", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.stale.CorpID = "" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if f.orgLoads != 0 { + t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads) + } + }) + + t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest} + + _, err := f.provider.lockedRefresh(context.Background()) + if err == nil || err.Error() != f.refreshErr.Error() { + t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err) + } + if f.orgLoads != 0 { + t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) +} + +func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + + if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil { + t.Fatal("refreshFromOrgSlot(nil) succeeded") + } + f.orgMirror.CorpID = "corp-2" + if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil { + t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded") + } + if len(f.refreshCalls) != 0 { + t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls) + } +} diff --git a/internal/auth/refresh_failure.go b/internal/auth/refresh_failure.go index 1e841894..359c4d4a 100644 --- a/internal/auth/refresh_failure.go +++ b/internal/auth/refresh_failure.go @@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass { } return RefreshFailureUnknown } + +// isRefreshTokenRejected reports whether the server returned a reviewed +// business code that definitively rejects the presented refresh_token. +// Only the reviewed MCP business code enables the organization-slot +// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no +// reviewed business code and deliberately do not trigger the fallback. +func isRefreshTokenRejected(err error) bool { + if err == nil { + return false + } + var exchangeErr *MCPTokenExchangeError + return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization() +} From e6fe475aeac82667e9f765764e65db9102a3e716 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:42:29 +0800 Subject: [PATCH 214/267] chore: retire consumed chat flag migration --- .../approved-flag-migrations-v1.json | 1458 +---------------- 1 file changed, 40 insertions(+), 1418 deletions(-) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1c596fa..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -1,39 +1,6 @@ { "version": 1, "migrations": [ - { - "command": "dws chat group audit-join-validation", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws chat group bots", "legacy": { @@ -67,1098 +34,6 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws chat group dismiss", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group invite-url", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice create", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice edit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice get", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group quit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-admin", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-history", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group transfer-owner", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-alias", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-icon", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-nick", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-settings", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group upgrade-to-external", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role add", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role query-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "role-ids", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "role-id" - } - }, - "canonical": { - "name": "role-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role update", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message edit", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message forward", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message list-topic-replies", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message recall", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message send", - "legacy": { - "name": "uuid", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "idempotency-key" - } - }, - "canonical": { - "name": "idempotency-key", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": false - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message update-text-emotion", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws ding message send-by-message", "legacy": { @@ -1192,227 +67,33 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws aisearch", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws aisearch person", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +broadcast", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +dm", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-batch-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +send-to-group", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, { "command": "dws chat message send-by-bot", "legacy": { "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "file" + } }, "canonical": { "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." @@ -1421,91 +102,32 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "content" + } }, "canonical": { "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc +doc-append", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block insert", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block update", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws todo task add-attachment", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." } ] } From 62d72ad84cb667379c02132148086bd984ea69c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:45:55 +0000 Subject: [PATCH 215/267] chore: update formula for v1.0.59 [skip ci] --- Formula/dingtalk-workspace-cli.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli.rb b/Formula/dingtalk-workspace-cli.rb index 46d064f7..5662ae17 100644 --- a/Formula/dingtalk-workspace-cli.rb +++ b/Formula/dingtalk-workspace-cli.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCli < Formula desc "Automate DingTalk workspace tasks from the terminal" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.58" + version "1.0.59" license "Apache-2.0" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz" - sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz" + sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz" - sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz" + sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz" - sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz" + sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz" - sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz" + sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip" - sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip" + sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e" end def install From c4a1018213d37cb1d41eedb53434ff06b7485326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 16:55:18 +0800 Subject: [PATCH 216/267] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E5=85=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .review_diff_oa_attachment.txt | 857 --------------------------------- 1 file changed, 857 deletions(-) delete mode 100644 .review_diff_oa_attachment.txt diff --git a/.review_diff_oa_attachment.txt b/.review_diff_oa_attachment.txt deleted file mode 100644 index 42ef58a9..00000000 --- a/.review_diff_oa_attachment.txt +++ /dev/null @@ -1,857 +0,0 @@ -diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md -index f2bbe2fd..6d4089c7 100644 ---- a/.changes/oa-approval-attachment-upload.md -+++ b/.changes/oa-approval-attachment-upload.md -@@ -2,5 +2,4 @@ - category: Added - --- - --- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`). --- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`). -+- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. -diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go -index e175c275..33a0f359 100644 ---- a/internal/app/schema_oa_attachment_contract_test.go -+++ b/internal/app/schema_oa_attachment_contract_test.go -@@ -54,30 +54,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t - "spaceId": "integer", "agentId": "integer", "class": "string", - }, - }, -- { -- cliPath: "oa approval attachment init-upload", -- canonical: "oa.init_attachment_upload_info", -- rpc: "init_attachment_upload_info", -- description: "初始化审批附件上传信息,返回 OSS 上传凭证", -- effect: "read", -- resultType: "object", -- resultFields: map[string]string{ -- "uploadKey": "string", "resourceUrls": "array", "storageDriver": "string", -- "expirationSeconds": "integer", "region": "string", "class": "string", -- }, -- }, -- { -- cliPath: "oa approval attachment commit-upload", -- canonical: "oa.commit_attachment_upload_info", -- rpc: "commit_attachment_upload_info", -- description: "提交附件上传信息,完成附件上传流程", -- effect: "write", -- resultType: "object", -- resultFields: map[string]string{ -- "spaceId": "integer", "fileName": "string", "fileSize": "integer", -- "class": "string", "fileType": "string", "fileId": "string", -- }, -- }, - } - - for _, test := range tests { -@@ -160,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t - } - } - -+// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的 -+// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT, -+// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。 -+func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) { -+ snapshot := fullSchemaSnapshotForTest(t) -+ tool := snapshot.Tools["oa.attachment_upload"] -+ if tool == nil { -+ t.Fatal("oa.attachment_upload is missing from final Schema") -+ } -+ if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" { -+ t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got) -+ } -+ if got := schemaContractString(tool["interface_mode"]); got != "composite" { -+ t.Fatalf("interface_mode = %q, want composite", got) -+ } -+ if got := schemaContractString(tool["availability"]); got != "available" { -+ t.Fatalf("availability = %q, want available", got) -+ } -+ if got := schemaContractString(tool["interface_reason"]); got == "" { -+ t.Fatal("composite upload command must document an interface reason") -+ } -+ if got := schemaContractString(tool["effect"]); got != "write" { -+ t.Fatalf("effect = %q, want write", got) -+ } -+ if got := schemaContractString(tool["risk"]); got != "low" { -+ t.Fatalf("risk = %q, want low", got) -+ } -+ if got := schemaContractString(tool["confirmation"]); got != "not_required" { -+ t.Fatalf("confirmation = %q, want not_required", got) -+ } -+ parameters := schemaContractMap(tool["parameters"]) -+ for _, flag := range []string{"file", "file-name", "md5"} { -+ if parameters[flag] == nil { -+ t.Fatalf("upload --%s is missing from final Schema", flag) -+ } -+ } -+ if required, _ := parameters["file"]["required"].(bool); !required { -+ t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"]) -+ } -+ result := schemaContractMap(tool["result"]) -+ dataSchema := schemaContractMap(result["data_schema"]) -+ properties := schemaContractMap(dataSchema["properties"]) -+ if properties["fileId"] == nil { -+ t.Fatal("upload Result data_schema is missing fileId") -+ } -+} -+ - func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any { - t.Helper() - result, ok := tool["result"].(map[string]any) -diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go -index 4326b493..d1404700 100644 ---- a/internal/helpers/oa.go -+++ b/internal/helpers/oa.go -@@ -2,12 +2,16 @@ package helpers - - import ( - "bytes" -+ "context" - "encoding/json" - "errors" - "fmt" - "io" -+ "os" -+ "path/filepath" - "strconv" - "strings" -+ "time" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" -@@ -125,7 +129,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { - } - - func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { -- data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) -+ return callOAAttachmentResultCtx(cmd.Context(), tool, args) -+} -+ -+// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context -+// (例如 upload 命令的 10 分钟超时):调用 oa/,提取 result 并包装为 output.Success。 -+func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) { -+ data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args) - if err != nil { - return nil, err - } -@@ -140,13 +150,106 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any - return output.Success(result), nil - } - --// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的 --// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。 --func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error { -- if size, _ := cmd.Flags().GetInt("file-size"); size < 0 { -- return apperrors.NewValidation("--file-size 必须大于等于 0") -+// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 -+// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、 -+// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。 -+func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) { -+ var data map[string]any -+ if err = json.Unmarshal([]byte(text), &data); err != nil { -+ return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err)) - } -- return nil -+ result, ok := data["result"].(map[string]any) -+ if !ok { -+ return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result") -+ } -+ -+ uploadKey, _ = result["uploadKey"].(string) -+ if strings.TrimSpace(uploadKey) == "" { -+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey") -+ } -+ -+ rawURLs, ok := result["resourceUrls"].([]any) -+ if !ok || len(rawURLs) == 0 { -+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls") -+ } -+ resourceURL, _ = rawURLs[0].(string) -+ if strings.TrimSpace(resourceURL) == "" { -+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空") -+ } -+ -+ headers = make(map[string]string) -+ if rawHeaders, ok := result["headers"].(map[string]any); ok { -+ for key, value := range rawHeaders { -+ if str, ok := value.(string); ok { -+ headers[key] = str -+ } -+ } -+ } -+ return resourceURL, headers, uploadKey, nil -+} -+ -+// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节 -+// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并 -+// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。 -+func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { -+ filePath := strings.TrimSpace(mustGetFlag(cmd, "file")) -+ if filePath == "" { -+ return apperrors.NewValidation("--file 不能为空") -+ } -+ info, err := os.Stat(filePath) -+ if err != nil { -+ return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err)) -+ } -+ if info.IsDir() { -+ return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath)) -+ } -+ fileSize := info.Size() -+ -+ fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name")) -+ if fileName == "" { -+ fileName = filepath.Base(filePath) -+ } -+ -+ md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5")) -+ if md5Hex == "" { -+ md5Hex, err = fileMD5Hex(filePath) -+ if err != nil { -+ return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err)) -+ } -+ } -+ -+ ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) -+ defer cancel() -+ -+ // Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。 -+ initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{ -+ "fileName": fileName, -+ "fileSize": float64(fileSize), -+ "md5": md5Hex, -+ }) -+ if err != nil { -+ return err -+ } -+ resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText) -+ if err != nil { -+ return err -+ } -+ -+ // Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。 -+ if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil { -+ return err -+ } -+ -+ // Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。 -+ result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{ -+ "fileName": fileName, -+ "uploadKey": uploadKey, -+ "fileSize": float64(fileSize), -+ }) -+ if err != nil { -+ return err -+ } -+ return output.StoreResult(cmd.Context(), result) - } - - func newOAAttachmentCommand() *cobra.Command { -@@ -327,113 +430,64 @@ func newOAAttachmentCommand() *cobra.Command { - }, - }) - -- initUploadCmd := NewLeafCommand(LeafSpec{ -- Use: "init-upload", -- Short: "初始化审批附件上传信息,使用获取到的信息上传文件", -- Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400", -- Server: "oa", -- Tool: "init_attachment_upload_info", -- OutputRollout: output.RolloutUnifiedActive, -- ResultCall: callOAAttachmentResult, -- Flags: []LeafFlag{ -- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, -- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, -- {Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true}, -- }, -- Validate: validateOAAttachmentFileSize, -- Safety: contract.SafetySpec{ -- Effect: "read", Risk: "low", -- Confirmation: "not_required", Idempotency: "idempotent", -- }, -- Contract: LeafContract{ -- Identity: contract.ToolIdentitySpec{ -- ProductID: "oa", -- Name: "init_attachment_upload_info", -- CanonicalPath: "oa.init_attachment_upload_info", -- CLIPath: "oa approval attachment init-upload", -- PrimaryCLIPath: "oa approval attachment init-upload", -- }, -- Description: "初始化审批附件上传信息,返回 OSS 上传凭证", -- Result: &contract.ResultSpec{ -- Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, -- DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`), -- }, -- Interface: &contract.InterfaceSpec{ -- Mode: "mcp", -- Availability: "available", -- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"}, -- }, -- Selection: contract.SelectionSpec{ -- AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址", -- UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"}, -- AvoidWhen: []string{ -- "文件已上传完成需要提交确认时使用 attachment commit-upload", -- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", -- }, -- Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"}, -- }, -- Parameters: []contract.ParamDecl{ -- {Name: "file-name", Property: "fileName", InterfaceType: "string"}, -- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, -- {Name: "md5", Property: "md5", InterfaceType: "string"}, -- }, -- }, -- }) -+ uploadCmd := &cobra.Command{ -+ Use: "upload", -+ Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)", -+ Long: `上传本地文件为审批附件(三步自动完成)。 - -- commitUploadCmd := NewLeafCommand(LeafSpec{ -- Use: "commit-upload", -- Short: "提交附件上传信息", -- Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400", -- Server: "oa", -- Tool: "commit_attachment_upload_info", -+流程: -+ 1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info) -+ 2. HTTP PUT 上传文件二进制到 OSS -+ 3. 提交上传信息完成入库 (oa/commit_attachment_upload_info) -+ -+--file-name 不传时默认用文件名;--md5 不传时自动计算。`, -+ Example: ` dws oa approval attachment upload --file ./合同.pdf -+ dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx -+ dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`, -+ RunE: runOAAttachmentUpload, -+ } -+ DeclareLeafMetadata(uploadCmd, LeafSpec{ - OutputRollout: output.RolloutUnifiedActive, -- ResultCall: callOAAttachmentResult, -- Flags: []LeafFlag{ -- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true}, -- {Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true}, -- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true}, -- }, -- Validate: validateOAAttachmentFileSize, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "low", -- Confirmation: "not_required", Idempotency: "idempotent", -+ Confirmation: "not_required", Idempotency: "unknown", - }, - Contract: LeafContract{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", -- Name: "commit_attachment_upload_info", -- CanonicalPath: "oa.commit_attachment_upload_info", -- CLIPath: "oa approval attachment commit-upload", -- PrimaryCLIPath: "oa approval attachment commit-upload", -+ Name: "attachment_upload", -+ CanonicalPath: "oa.attachment_upload", -+ CLIPath: "oa approval attachment upload", -+ PrimaryCLIPath: "oa approval attachment upload", - }, -- Description: "提交附件上传信息,完成附件上传流程", -+ Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库", -+ DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, - Result: &contract.ResultSpec{ - Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`), - }, - Interface: &contract.InterfaceSpec{ -- Mode: "mcp", -+ Mode: "composite", - Availability: "available", -- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"}, -+ Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref", - }, - Selection: contract.SelectionSpec{ -- AgentSummary: "提交附件上传信息,确认文件上传完成", -- UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"}, -+ AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步", -+ UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"}, - AvoidWhen: []string{ -- "尚未初始化上传信息时使用 attachment init-upload", -- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview", -+ "仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview", -+ "仅需获取已有附件临时下载链接时使用 attachment download-url", - }, -- Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400"}, -- }, -- Parameters: []contract.ParamDecl{ -- {Name: "file-name", Property: "fileName", InterfaceType: "string"}, -- {Name: "upload-key", Property: "uploadKey", InterfaceType: "string"}, -- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"}, -+ Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"}, - }, - }, - }) -+ uploadCmd.Flags().String("file", "", "本地文件路径 (必填)") -+ uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)") -+ uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)") -+ _ = uploadCmd.MarkFlagRequired("file") - -- attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd) -+ attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd) - return attachmentCmd - } - -diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go -index 8eb9e1f3..7b66ad58 100644 ---- a/internal/helpers/oa_attachment_test.go -+++ b/internal/helpers/oa_attachment_test.go -@@ -10,7 +10,9 @@ import ( - "errors" - "io" - "os" -+ "path/filepath" - "reflect" -+ "strconv" - "strings" - "testing" - -@@ -338,123 +340,174 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin - } - } - --func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) { -- const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}` -- commandArgs := []string{ -- "approval", "attachment", "init-upload", -+const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}` -+ -+func oaUploadCommitResponse(size int64) string { -+ return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` + -+ strconv.FormatInt(size, 10) + -+ `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` -+} -+ -+// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。 -+func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) { -+ t.Helper() -+ path := filepath.Join(t.TempDir(), name) -+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil { -+ t.Fatalf("write temp file: %v", err) -+ } -+ return path, int64(len(content)) -+} -+ -+// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的 -+// resourceURL 与签名 headers。 -+type capturedPut struct { -+ calls int -+ url string -+ headers map[string]string -+ filePath string -+ fileSize int64 -+} -+ -+func mockOAAttachmentPut(t *testing.T) *capturedPut { -+ t.Helper() -+ put := &capturedPut{} -+ SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { -+ put.calls++ -+ put.url = url -+ put.headers = headers -+ put.filePath = filePath -+ put.fileSize = fileSize -+ return nil -+ }) -+ t.Cleanup(func() { SetHTTPPutFile(nil) }) -+ return put -+} -+ -+func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) { -+ const content = "pdf-bytes-content" -+ filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content) -+ put := mockOAAttachmentPut(t) -+ -+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ -+ {text: oaUploadInitResponse}, -+ {text: oaUploadCommitResponse(fileSize)}, -+ }} -+ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, -+ "approval", "attachment", "upload", -+ "--file", filePath, - "--file-name", "合同.pdf", -- "--file-size", "102400", - "--md5", "d41d8cd98f00b204e9800998ecf8427e", -+ ) -+ if err != nil { -+ t.Fatalf("execute command: %v", err) - } -- wantArgs := map[string]any{ -+ -+ if caller.calls != 2 { -+ t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls) -+ } -+ if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" { -+ t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0]) -+ } -+ wantInit := map[string]any{ - "fileName": "合同.pdf", -- "fileSize": 102400, -+ "fileSize": float64(fileSize), - "md5": "d41d8cd98f00b204e9800998ecf8427e", - } -+ if !reflect.DeepEqual(caller.argsLog[0], wantInit) { -+ t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit) -+ } - -- t.Run("json", func(t *testing.T) { -- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} -- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) -- if err != nil { -- t.Fatalf("execute command: %v", err) -- } -- if caller.server != "oa" || caller.tool != "init_attachment_upload_info" { -- t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool) -- } -- if !reflect.DeepEqual(caller.args, wantArgs) { -- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) -- } -- var envelope map[string]any -- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { -- t.Fatalf("decode unified output: %v", err) -- } -- if envelope["ok"] != true || envelope["outcome"] != "success" { -- t.Fatalf("unified envelope = %#v", envelope) -- } -- data, ok := envelope["data"].(map[string]any) -- if !ok || data["uploadKey"] != "key-123" { -- t.Fatalf("unified data = %#v", envelope["data"]) -- } -- }) -- -- t.Run("optional md5 omitted", func(t *testing.T) { -- caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} -- if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, -- "approval", "attachment", "init-upload", -- "--file-name", "report.xlsx", -- "--file-size", "2048", -- ); err != nil { -- t.Fatalf("execute command: %v", err) -- } -- if _, exists := caller.args["md5"]; exists { -- t.Fatalf("optional md5 should be omitted: %#v", caller.args) -- } -- }) --} -+ if put.calls != 1 { -+ t.Fatalf("httpPutFile called %d times, want 1", put.calls) -+ } -+ if put.url != "https://oss.example.test/upload" { -+ t.Fatalf("PUT url = %q, want parsed resourceURL", put.url) -+ } -+ if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" { -+ t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers) -+ } -+ if put.filePath != filePath || put.fileSize != fileSize { -+ t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize) -+ } - --func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) { -- const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` -- commandArgs := []string{ -- "approval", "attachment", "commit-upload", -- "--file-name", "合同.pdf", -- "--upload-key", "key-123", -- "--file-size", "102400", -+ if caller.toolLog[1] != "commit_attachment_upload_info" { -+ t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1]) - } -- wantArgs := map[string]any{ -+ wantCommit := map[string]any{ - "fileName": "合同.pdf", - "uploadKey": "key-123", -- "fileSize": 102400, -+ "fileSize": float64(fileSize), -+ } -+ if !reflect.DeepEqual(caller.argsLog[1], wantCommit) { -+ t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit) - } - -- t.Run("json", func(t *testing.T) { -- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} -- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) -- if err != nil { -- t.Fatalf("execute command: %v", err) -- } -- if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" { -- t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool) -- } -- if !reflect.DeepEqual(caller.args, wantArgs) { -- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) -- } -- var envelope map[string]any -- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { -- t.Fatalf("decode unified output: %v", err) -- } -- if envelope["ok"] != true || envelope["outcome"] != "success" { -- t.Fatalf("unified envelope = %#v", envelope) -- } -- data, ok := envelope["data"].(map[string]any) -- if !ok || data["fileId"] != "file-abc" { -- t.Fatalf("unified data = %#v", envelope["data"]) -- } -- }) -+ var envelope map[string]any -+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { -+ t.Fatalf("decode unified output: %v", err) -+ } -+ if envelope["ok"] != true || envelope["outcome"] != "success" { -+ t.Fatalf("unified envelope = %#v", envelope) -+ } -+ data, ok := envelope["data"].(map[string]any) -+ if !ok || data["fileId"] != "file-abc" { -+ t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"]) -+ } -+} -+ -+func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) { -+ const content = "auto-md5-content" -+ filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content) -+ wantMD5, err := fileMD5Hex(filePath) -+ if err != nil { -+ t.Fatalf("compute expected md5: %v", err) -+ } -+ mockOAAttachmentPut(t) -+ -+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ -+ {text: oaUploadInitResponse}, -+ {text: oaUploadCommitResponse(fileSize)}, -+ }} -+ // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。 -+ if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, -+ "approval", "attachment", "upload", -+ "--file", filePath, -+ ); err != nil { -+ t.Fatalf("execute command: %v", err) -+ } -+ -+ if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" { -+ t.Fatalf("default fileName = %#v, want basename report.xlsx", got) -+ } -+ if got := caller.argsLog[0]["md5"]; got != wantMD5 { -+ t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5) -+ } -+ if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) { -+ t.Fatalf("fileSize = %#v, want number %d", got, fileSize) -+ } - } - - func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { -+ existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x") - tests := []struct { - name string - args []string - }{ -- {name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}}, -- {name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}}, -- {name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}}, -- {name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}}, -- {name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}}, -- {name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}}, -- {name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}}, -+ {name: "missing file", args: []string{"approval", "attachment", "upload"}}, -+ {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}}, -+ {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { -+ mockOAAttachmentPut(t) - caller := &scriptedToolCaller{} - err := executeOACommand(t, caller, test.args...) - if err == nil { - t.Fatalf("%s unexpectedly succeeded", test.name) - } - if caller.calls != 0 { -- t.Fatalf("missing required flag made %d MCP call(s)", caller.calls) -+ t.Fatalf("invalid upload made %d MCP call(s)", caller.calls) - } - }) - } -diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go -index 8b58bdbf..5cc02f26 100644 ---- a/internal/helpers/polling_coverage_more_test.go -+++ b/internal/helpers/polling_coverage_more_test.go -@@ -27,6 +27,11 @@ type scriptedToolCaller struct { - server string - tool string - args map[string]any -+ // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit) -+ // can assert each invocation instead of only the last one captured above. -+ serverLog []string -+ toolLog []string -+ argsLog []map[string]any - } - - func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { -@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri - c.server = serverID - c.tool = toolName - c.args = args -+ c.serverLog = append(c.serverLog, serverID) -+ c.toolLog = append(c.toolLog, toolName) -+ c.argsLog = append(c.argsLog, args) - if len(c.steps) == 0 { - return &edition.ToolResult{}, nil - } -diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md -index a0cb242b..6add42e9 100644 ---- a/skills/mono/references/products/oa.md -+++ b/skills/mono/references/products/oa.md -@@ -34,8 +34,7 @@ Flags: - - 需要单个附件的临时下载链接:`attachment download-url` - - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - - 需要在审批场景内批量预览附件:`attachment authorize-preview` --- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` --- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` -+- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` - - #### 获取审批附件临时下载链接 - -@@ -80,35 +79,20 @@ Flags: - - 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 - --#### 初始化审批附件上传 -+#### 上传本地文件为审批附件 - - ``` - Usage: -- dws oa approval attachment init-upload [flags] -+ dws oa approval attachment upload [flags] - Example: -- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json -+ dws oa approval attachment upload --file ./合同.pdf --format json - Flags: -- --file-name string 完整文件名,例如 合同.pdf (必填) -- --file-size int 文件字节数,必须大于等于 0 (必填) -- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) -+ --file string 本地文件路径 (必填) -+ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) -+ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) - ``` - --该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 -- --#### 提交附件上传信息 -- --``` --Usage: -- dws oa approval attachment commit-upload [flags] --Example: -- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json --Flags: -- --file-name string 完整文件名,例如 合同.pdf (必填) -- --upload-key string 初始化接口返回的 uploadKey (必填) -- --file-size int 文件字节数,必须大于等于 0 (必填) --``` -- --该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 -+该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 - - ### 同意审批 - -@@ -753,8 +737,7 @@ Flags: - 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) - 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) - 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) --用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) --用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) -+用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) - 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` - 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` - 用户说"撤回审批/取消审批" → `approval revoke` -diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md -index 959fb48f..e8800eeb 100644 ---- a/skills/multi/dingtalk-misc/references/oa.md -+++ b/skills/multi/dingtalk-misc/references/oa.md -@@ -38,8 +38,7 @@ Flags: - - 需要单个附件的临时下载链接:`attachment download-url` - - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - - 需要在审批场景内批量预览附件:`attachment authorize-preview` --- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload` --- 文件已上传至 OSS,需要提交确认:`attachment commit-upload` -+- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` - - #### 获取审批附件临时下载链接 - -@@ -84,35 +83,20 @@ Flags: - - 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 - --#### 初始化审批附件上传 -+#### 上传本地文件为审批附件 - - ``` - Usage: -- dws oa approval attachment init-upload [flags] -+ dws oa approval attachment upload [flags] - Example: -- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json -+ dws oa approval attachment upload --file ./合同.pdf --format json - Flags: -- --file-name string 完整文件名,例如 合同.pdf (必填) -- --file-size int 文件字节数,必须大于等于 0 (必填) -- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选) -+ --file string 本地文件路径 (必填) -+ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) -+ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) - ``` - --该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。 -- --#### 提交附件上传信息 -- --``` --Usage: -- dws oa approval attachment commit-upload [flags] --Example: -- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key --file-size 102400 --format json --Flags: -- --file-name string 完整文件名,例如 合同.pdf (必填) -- --upload-key string 初始化接口返回的 uploadKey (必填) -- --file-size int 文件字节数,必须大于等于 0 (必填) --``` -- --该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。 -+该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 - - ### 同意审批 - -@@ -756,8 +740,7 @@ Flags: - 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) - 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) - 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) --用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5) --用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size) -+用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) - 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` - 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` - 用户说"撤回审批/取消审批" → `approval revoke` -diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go -index 25dd8b64..ba74452e 100644 ---- a/test/unit/oa_attachment_delivery_policy_test.go -+++ b/test/unit/oa_attachment_delivery_policy_test.go -@@ -37,8 +37,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { - "download-url": "get_attachment_download_url", - "authorize-download": "auth_download_file", - "authorize-preview": "auth_preview_attachment", -- "init-upload": "init_attachment_upload_info", -- "commit-upload": "commit_attachment_upload_info", - } - gotTools := make(map[string]string, len(wantTools)) - foundConstructor := false -@@ -80,8 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { - "dws oa approval attachment download-url", - "dws oa approval attachment authorize-download", - "dws oa approval attachment authorize-preview", -- "dws oa approval attachment init-upload", -- "dws oa approval attachment commit-upload", -+ "dws oa approval attachment upload", - "临时下载链接", - "最多 10", - "最多 20", From 76a5559ca282d627ae0ff4f1e272336771819527 Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 16:59:18 +0800 Subject: [PATCH 217/267] fix(sheet): align revision dry-run contract --- internal/helpers/sheet_revision.go | 4 ++-- internal/helpers/sheet_revision_test.go | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go index ac2a4697..3485435d 100644 --- a/internal/helpers/sheet_revision.go +++ b/internal/helpers/sheet_revision.go @@ -244,7 +244,7 @@ func newSheetRevisionGetCmd() *cobra.Command { PrimaryCLIPath: "sheet revision-get", }, Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。", - DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation, RemoteReads: false}, + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, Result: sheetRevisionResult, Interface: &contract.InterfaceSpec{ Mode: contract.InterfaceModeMCP, @@ -301,7 +301,7 @@ func newSheetChangesetGetCmd() *cobra.Command { PrimaryCLIPath: "sheet changeset-get", }, Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。", - DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation, RemoteReads: false}, + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, Result: sheetChangesetResult, Interface: &contract.InterfaceSpec{ Mode: contract.InterfaceModeMCP, diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go index 0849efbd..157088a7 100644 --- a/internal/helpers/sheet_revision_test.go +++ b/internal/helpers/sheet_revision_test.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -245,6 +246,8 @@ func TestDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { {name: "object root", raw: `{"success":true,"changesetsJson":"{}"}`}, {name: "multiple values", raw: `{"success":true,"changesetsJson":"[] []"}`}, {name: "missing", raw: `{"success":true}`}, + {name: "non object response", raw: `[]`}, + {name: "legacy changesets not array", raw: `{"success":true,"changesets":{}}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -320,6 +323,7 @@ func TestSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { {name: "missing node", args: []string{"--start-revision", "0"}, want: "--node"}, {name: "missing start", args: []string{"--node", "node-1"}, want: "--start-revision"}, {name: "non integer start", args: []string{"--node", "node-1", "--start-revision", "1.5"}, want: "--start-revision 必须是 64 位整数"}, + {name: "non integer end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "1.5"}, want: "--end-revision 必须是 64 位整数"}, {name: "negative start", args: []string{"--node", "node-1", "--start-revision", "-1"}, want: "--start-revision 必须是非负整数"}, {name: "negative end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "-1"}, want: "--end-revision 必须是非负整数"}, {name: "end before start", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "8"}, want: "必须大于或等于"}, @@ -367,6 +371,9 @@ func TestSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { if final.Result == nil || len(final.Result.DataSchema) == 0 { t.Fatalf("sheet %s result = %#v", name, final.Result) } + if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewRequest { + t.Fatalf("sheet %s dry-run = %#v, want request preview", name, final.DryRun) + } if output.CommandRollout(command) != output.RolloutUnifiedActive { t.Fatalf("sheet %s rollout = %s, want unified_active so Result is Agent-visible", name, output.CommandRollout(command)) } From 74859b966b7fec242aae91a7fb485ee38832acce Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 15:59:48 +0800 Subject: [PATCH 218/267] fix(release): consume active fragments in stable seals --- .changes/README.md | 4 +- .changes/stable-active-fragment-seal.md | 5 ++ docs/releasing.md | 9 ++- scripts/policy/check-release-fragments.sh | 23 ++++++- scripts/release/prepare-changelog.sh | 44 +++++++++++-- test/scripts/changelog_pr_gate_test.go | 46 +++++++++++++ test/scripts/release_script_test.go | 79 +++++++++++++++++++++++ 7 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 .changes/stable-active-fragment-seal.md diff --git a/.changes/README.md b/.changes/README.md index 79b5868f..1ed1fbd3 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -25,7 +25,9 @@ category: Added 发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序, 将未归档 fragments 汇总为唯一的版本章节,并移动到 -`.changes/released//`。因此 release-seal PR 是唯一会修改 +`.changes/released//`。beta 发布后若有新 fragments 合入并直接准备 stable, +stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新 +fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改 `CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。 归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、 删除或重写已归档文件。 diff --git a/.changes/stable-active-fragment-seal.md b/.changes/stable-active-fragment-seal.md new file mode 100644 index 00000000..72958e9d --- /dev/null +++ b/.changes/stable-active-fragment-seal.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes. diff --git a/docs/releasing.md b/docs/releasing.md index c8d6c702..b6b57aa0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -140,8 +140,13 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 `.changes/.md` 中增加一个独立 fragment;格式和允许的分类见 [`.changes/README.md`](../.changes/README.md)。预发封板时 `scripts/release/prepare-changelog.sh prerelease ` 会稳定排序并汇总所有未归档 -fragment,写入唯一版本章节后移动到 `.changes/released//`。因此并发 PR 不会争用 -`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。 +fragment,写入唯一版本章节后移动到 `.changes/released//`。如果 beta 发布后又有 +带 fragment 的 PR 合入,而维护者决定直接发布 stable, +`scripts/release/prepare-changelog.sh stable --from-beta ` 会保留 beta 晋级摘要 +模板,并把这些 post-beta fragments 写到明确的 `Changes since ` 边界之后,再移动到 +`.changes/released//`。没有 active fragment 时,stable 仍只生成原有晋级摘要 +模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与 +归档移动,供审计复核。 ## CI/CD 保证 diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh index a93f113a..a617831e 100755 --- a/scripts/policy/check-release-fragments.sh +++ b/scripts/policy/check-release-fragments.sh @@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then index($0, heading) == 1 { found = 1; next } found && /^## / { exit } found { print } - ' >"$tmp_root/actual-notes" + ' >"$tmp_root/actual-section" + case "$release_version" in + *-beta.*) + cp "$tmp_root/actual-section" "$tmp_root/actual-notes" + ;; + *) + stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")" + [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2 + exit 1 + } + . "$ROOT/scripts/release/release-lib.sh" + [ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || { + printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2 + exit 1 + } + awk ' + found { print } + /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 } + ' "$tmp_root/actual-section" >"$tmp_root/actual-notes" + ;; + esac normalize_notes() { awk ' /^[[:space:]]*$/ && !started { next } diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh index 053a46b7..e7a7b25a 100755 --- a/scripts/release/prepare-changelog.sh +++ b/scripts/release/prepare-changelog.sh @@ -64,17 +64,49 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}" section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")" output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")" fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")" -cleanup() { rm -f "$section" "$output" "$fragments"; } +fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")" +cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; } trap cleanup EXIT HUP INT TERM +render_fragments=false +if [ "$CHANNEL" = "prerelease" ]; then + render_fragments=true +else + if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then + printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2 + exit 1 + fi + if [ -d "$CHANGES_DIR" ]; then + find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries" + while IFS= read -r entry; do + base="${entry##*/}" + if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + render_fragments=true + break + done <"$fragment_entries" + fi +fi + +if [ "$render_fragments" = true ]; then + "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" +fi + { printf '## [%s] - %s\n\n' "$semver" "$release_date" if [ "$CHANNEL" = "stable" ]; then printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA" printf '### Changed\n\n' printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA" + if [ -s "$fragments" ]; then + printf '\n### Changes since `%s`\n\n' "$FROM_BETA" + cat "$fragments" + fi else - "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" cat "$fragments" fi } > "$section" @@ -106,14 +138,18 @@ fi [ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; } cp "$output" "$CHANGELOG" -if [ "$CHANNEL" = "prerelease" ]; then +if [ -s "$fragments" ]; then archive_dir="$CHANGES_DIR/released/$semver" mkdir -p "$archive_dir" find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \; fi if [ "$CHANNEL" = "stable" ]; then - printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + if [ -s "$fragments" ]; then + printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION" + else + printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + fi else printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION" fi diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 5d8ef72b..a71385b5 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -305,6 +305,52 @@ func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) { } } +func TestReleaseFragmentPolicyAcceptsStablePostBetaArchival(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, ".changes/1236-stable-followup.md", "---\ncategory: Fixed\n---\n\n- Stable follow-up.\n", 0o644) + repo.commit(t, "add post-beta release fragment") + sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [1.0.1] - 2026-07-17 + +This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable. + +### Changed + +- Promote the complete beta release to stable. + +### Changes since `+"`v1.0.1-beta.1`"+` + +### Fixed + +- Stable follow-up. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, 0o644) + archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatalf("MkdirAll stable archive: %v", err) + } + if err := os.Rename( + filepath.Join(repo.root, ".changes", "1236-stable-followup.md"), + filepath.Join(archiveDir, "1236-stable-followup.md"), + ); err != nil { + t.Fatalf("Rename stable release fragment: %v", err) + } + repo.commit(t, "seal stable release notes") + + if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected stable seal: %v\noutput:\n%s", err, output) + } +} + func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) { t.Run("invalid active fragment", func(t *testing.T) { repo := newChangelogGateRepo(t) diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 7c8e98cc..a53c7910 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -1895,6 +1895,85 @@ func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing } } +func TestReleasePrepareChangelogRendersAndArchivesStableFragments(t *testing.T) { + r := newReleaseTestRepo(t) + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755) + releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755) + mustWriteFile(t, filepath.Join(r.root, ".changes", "1236-stable-followup.md"), []byte("---\ncategory: Fixed\n---\n\n- **Stable follow-up** (#1236) — includes a change merged after the beta.\n"), 0o644) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "promotes the sealed `v1.0.1-beta.1` contents to stable") || + !strings.Contains(content, "### Changes since `v1.0.1-beta.1`") || + !strings.Contains(content, "### Fixed") || + !strings.Contains(content, "Stable follow-up") { + t.Fatalf("prepared stable changelog did not render post-beta fragments:\n%s", changelog) + } + name := "1236-stable-followup.md" + if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) { + t.Fatalf("unconsumed stable release fragment %s still present: %v", name, err) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1", name)); err != nil { + t.Fatalf("archived stable release fragment %s missing: %v", name, err) + } +} + +func TestReleasePrepareChangelogStableWithoutFragmentsKeepsTemplate(t *testing.T) { + r := newReleaseTestRepo(t) + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755) + releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog without fragments error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "TODO: summarize the complete user-visible release") || + strings.Contains(content, "### Changes since") { + t.Fatalf("stable changelog without fragments changed template behavior:\n%s", changelog) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1")); !os.IsNotExist(err) { + t.Fatalf("stable preparation without fragments created an archive: %v", err) + } +} + func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) { r := newReleaseTestRepo(t) releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) From 6e8fec5684ecc6a579a003fe4f02f83d1c04f25b Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 17:48:59 +0800 Subject: [PATCH 219/267] chore(policy): retire consumed flag migrations --- .../approved-flag-migrations-v1.json | 1458 +---------------- 1 file changed, 40 insertions(+), 1418 deletions(-) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1c596fa..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -1,39 +1,6 @@ { "version": 1, "migrations": [ - { - "command": "dws chat group audit-join-validation", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws chat group bots", "legacy": { @@ -67,1098 +34,6 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws chat group dismiss", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group invite-url", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice create", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice edit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice get", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group quit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-admin", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-history", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group transfer-owner", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-alias", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-icon", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-nick", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-settings", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group upgrade-to-external", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role add", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role query-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "role-ids", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "role-id" - } - }, - "canonical": { - "name": "role-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role update", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message edit", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message forward", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message list-topic-replies", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message recall", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message send", - "legacy": { - "name": "uuid", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "idempotency-key" - } - }, - "canonical": { - "name": "idempotency-key", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": false - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message update-text-emotion", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws ding message send-by-message", "legacy": { @@ -1192,227 +67,33 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws aisearch", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws aisearch person", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +broadcast", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +dm", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-batch-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +send-to-group", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, { "command": "dws chat message send-by-bot", "legacy": { "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "file" + } }, "canonical": { "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." @@ -1421,91 +102,32 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "content" + } }, "canonical": { "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc +doc-append", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block insert", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block update", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws todo task add-attachment", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." } ] } From 60a474f30c773032af9197443d3cbcadfa1608f9 Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 19:15:48 +0800 Subject: [PATCH 220/267] fix(sheet): fail closed on invalid revision results --- internal/helpers/sheet_revision.go | 68 +++++++++++++++------- internal/helpers/sheet_revision_test.go | 76 +++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go index 3485435d..f6f9f9d9 100644 --- a/internal/helpers/sheet_revision.go +++ b/internal/helpers/sheet_revision.go @@ -346,25 +346,59 @@ func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]an func decodeSheetRevisionResult(tool, raw string) (any, error) { if strings.TrimSpace(raw) == "" { - return map[string]any{}, nil + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned no non-empty text content", + "empty_tool_response", + true, + ) } data, err := decodeSheetSingleJSON(raw) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) } - if tool == sheetChangesetGetRemoteTool { - if err := normalizeSheetChangesetTransport(data); err != nil { - return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v%s", tool, err, sheetResultLogIDSuffix(data))) - } - } - return data, nil -} - -func sheetResultLogIDSuffix(data any) string { object, ok := data.(map[string]any) if !ok { - return "" + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned a non-object business result", + "invalid_tool_response", + false, + ) } + if isBusinessError(object) { + return nil, &CLIError{ + Code: CodeMCPToolError, + Message: raw, + Suggestion: suggestForBusinessError(object), + Operation: "sheet/" + tool, + } + } + success, ok := object["success"].(bool) + if !ok || !success { + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned a business result without success=true", + "invalid_tool_response", + false, + ) + } + if tool == sheetChangesetGetRemoteTool { + if err := normalizeSheetChangesetTransport(object); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v%s", tool, err, sheetResultLogIDSuffix(object))) + } + } + return object, nil +} + +func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error { + return apperrors.NewAPI(message, + apperrors.WithOperation("sheet/"+tool), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(retryable), + apperrors.WithReason(reason), + ) +} + +func sheetResultLogIDSuffix(object map[string]any) string { logID, ok := object["logId"].(string) if !ok || strings.TrimSpace(logID) == "" { return "" @@ -389,12 +423,7 @@ func decodeSheetSingleJSON(raw string) (any, error) { return data, nil } -func normalizeSheetChangesetTransport(data any) error { - object, ok := data.(map[string]any) - if !ok { - return fmt.Errorf("业务结果不是 JSON 对象") - } - +func normalizeSheetChangesetTransport(object map[string]any) error { encoded, hasEncoded := object["changesetsJson"] if hasEncoded { changesetsJSON, ok := encoded.(string) @@ -427,10 +456,7 @@ func normalizeSheetChangesetTransport(data any) error { return nil } - if success, _ := object["success"].(bool); success { - return fmt.Errorf("成功响应缺少 changesetsJson") - } - return nil + return fmt.Errorf("成功响应缺少 changesetsJson") } func sheetRevisionNumberArg(raw string) (any, error) { diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go index 157088a7..29ea3593 100644 --- a/internal/helpers/sheet_revision_test.go +++ b/internal/helpers/sheet_revision_test.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "reflect" "strings" @@ -26,6 +27,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -266,14 +268,76 @@ func TestDecodeSheetChangesetTransportPreservesLogIDInParseError(t *testing.T) { } } -func TestDecodeSheetChangesetTransportPassesFailureWithoutPayload(t *testing.T) { +func TestDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) { const response = `{"success":false,"logId":"trace","errorCode":"UPSTREAM","errorMsg":"failed"}` - decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) - if err != nil { - t.Fatalf("decode failure response: %v", err) + _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || + cliErr.Operation != "sheet/"+sheetChangesetGetRemoteTool { + t.Fatalf("decode failure error = %#v, want sheet MCP business failure", err) } - if decoded.(map[string]any)["errorCode"] != "UPSTREAM" { - t.Fatalf("failure response = %#v", decoded) + if !strings.Contains(cliErr.Message, `"errorCode":"UPSTREAM"`) { + t.Fatalf("business failure message = %q, want backend error payload", cliErr.Message) + } +} + +func TestSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *testing.T) { + tests := []struct { + name string + tool string + response string + build func() *cobra.Command + args []string + }{ + { + name: "revision empty response", tool: sheetRevisionGetRemoteTool, response: " \n ", + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset empty response", tool: sheetChangesetGetRemoteTool, response: "", + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "revision non-object response", tool: sheetRevisionGetRemoteTool, response: `[]`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset missing success", tool: sheetChangesetGetRemoteTool, response: `{"changesetsJson":"[]"}`, + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "revision business failure", tool: sheetRevisionGetRemoteTool, + response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset business failure", tool: sheetChangesetGetRemoteTool, + response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`, + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}} + got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...) + if err == nil { + t.Fatalf("command unexpectedly succeeded with output %q", got) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("failure path emitted a success envelope: %s", got) + } + }) + } +} + +func TestDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { + _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, " \n ") + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" || + apiErr.Operation != "sheet/"+sheetRevisionGetRemoteTool || + apiErr.FailureStage != "response_validation" || !apiErr.RetryableSet || !apiErr.Retryable { + t.Fatalf("empty response error = %#v", err) } } From 11cbc30a10e847d819e37a1ff307524d265e089d Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 19:14:13 +0800 Subject: [PATCH 221/267] fix(ci): keep completed migration receipts inert --- docs/cli-interface-flag-migrations.md | 8 +- .../interfacesnapshot/command_migrations.go | 4 +- .../command_migrations_test.go | 5 +- internal/interfacesnapshot/compare_test.go | 12 +- internal/interfacesnapshot/migrations.go | 4 +- .../migrations_coverage_test.go | 14 +- .../approved-flag-migrations-v1.json | 1410 ----------------- scripts/policy/schema-compat/main_test.go | 9 +- 8 files changed, 32 insertions(+), 1434 deletions(-) diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 81477cd5..b349d95c 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -76,7 +76,7 @@ candidate 自增的 pending 记录、其他命令的同名参数、参数概念 首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数, 并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main 消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after -并按 lifecycle 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、 +并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、 target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。 `replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot @@ -97,7 +97,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 -跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 +跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。 @@ -110,9 +110,9 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` | 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 | | 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 | | 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` | -| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 | +| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 | -因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。 +因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。 下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对: diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go index 053db474..62205fba 100644 --- a/internal/interfacesnapshot/command_migrations.go +++ b/internal/interfacesnapshot/command_migrations.go @@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle( } } if allAfter { - if exists { - return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey()) + if exists && proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey()) } continue } diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index 9585afa1..a7d5965e 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { {"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"}, {"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"}, {"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"}, - {"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"}, {"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"}, {"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"}, + {"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"}, {"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"}, {"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"}, } { @@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 { t.Fatalf("cleaned stale receipt=%#v, %v", got, err) } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 { + t.Fatalf("retained inert receipt=%#v, %v", got, err) + } if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 { t.Fatalf("candidate pending plan=%#v, %v", got, err) } diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index e73aa074..ee362dee 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt } } -func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) { +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) { after := testFlagMigrationSnapshot(true, true) consumed := testFlagMigrationManifest(FlagMigrationConsumed) references := map[string]Snapshot{ @@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl "stable": after, } - if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil { - t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report) + report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed) + if err != nil { + t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err) + } + if !report.Compatible { + t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons) } - report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest()) + report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest()) if err != nil { t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err) } diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index a16395f8..d1e7f3d5 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -579,8 +579,8 @@ func evaluateFlagMigrationLifecycle( } } if allReferencesAfter { - if exists { - return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey()) + if exists && proposed.State != FlagMigrationConsumed { + return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey()) } continue } diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go index fa4b239f..90f8064a 100644 --- a/internal/interfacesnapshot/migrations_coverage_test.go +++ b/internal/interfacesnapshot/migrations_coverage_test.go @@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes wantErr: "back to pending", }, { - name: "consumed receipt becomes stale", + name: "inert consumed receipt cannot revert to pending", current: after, references: map[string]Snapshot{"merge-base": after, "stable": after}, authority: consumed, - candidate: consumed, - wantErr: "is stale after all references reached the after state", + candidate: pending, + wantErr: "back to pending", }, { name: "candidate-added receipt starts pending", @@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te } }) - t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) { - _, err := CompareAllWithFlagMigrations( + t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) { + report, err := CompareAllWithFlagMigrations( after, map[string]Snapshot{"merge-base": after, "stable": after}, consumed, consumed, ) - if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") { - t.Fatalf("stale optional migration error = %v", err) + if err != nil || !report.Compatible { + t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err) } }) } diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1c596fa..c1474dd0 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -1,39 +1,6 @@ { "version": 1, "migrations": [ - { - "command": "dws chat group audit-join-validation", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws chat group bots", "legacy": { @@ -67,1098 +34,6 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws chat group dismiss", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group invite-url", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice create", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice edit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice get", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group quit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-admin", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-history", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group transfer-owner", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-alias", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-icon", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-nick", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-settings", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group upgrade-to-external", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role add", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role query-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "role-ids", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "role-id" - } - }, - "canonical": { - "name": "role-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role update", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message edit", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message forward", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message list-topic-replies", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message recall", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message send", - "legacy": { - "name": "uuid", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "idempotency-key" - } - }, - "canonical": { - "name": "idempotency-key", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": false - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message update-text-emotion", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws ding message send-by-message", "legacy": { @@ -1192,216 +67,6 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws aisearch", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws aisearch person", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +broadcast", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +dm", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-batch-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +send-to-group", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, { "command": "dws chat message send-by-bot", "legacy": { @@ -1431,81 +96,6 @@ }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc +doc-append", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block insert", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block update", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws todo task add-attachment", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." } ] } diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index c5eaa912..d1067f0e 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1739,11 +1739,12 @@ func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *tes t.Fatalf("consumed receipt did not preserve compatibility: %v", failures) } - // Once stable also reaches after, retaining the consumed receipt is stale; - // deleting it yields no authorization and the already-after Schema passes. + // Once stable also reaches after, retaining or deleting the consumed receipt + // yields no authorization and the already-after Schema passes. writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true)) - if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") { - t.Fatalf("stale consumed receipt error = %v", err) + migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath) + if err != nil || len(migrations) != 0 { + t.Fatalf("retained consumed receipt authorizations = %#v, %v", migrations, err) } emptyManifest := interfacesnapshot.FlagMigrationManifest{ Version: interfacesnapshot.FlagMigrationManifestVersion, From ff65f80c9821a6b1c2e9b5c0496f3367d7c3a77d Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 14:25:15 +0800 Subject: [PATCH 222/267] refactor(oa): add strict shortcut response helpers --- internal/shortcut/oa/common.go | 327 +++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 internal/shortcut/oa/common.go diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go new file mode 100644 index 00000000..2c994b9c --- /dev/null +++ b/internal/shortcut/oa/common.go @@ -0,0 +1,327 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "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 oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract." + +type oaPageEvidence struct { + Known bool + HasMore bool + Next string +} + +func oaCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"}, + } +} + +func oaObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"}, + } +} + +func oaReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func oaWriteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func oaContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "oa " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", Name: name, CanonicalPath: "oa." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: oaCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"}, + Examples: examples, + }, + } +} + +func oaPagePagination(parameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: parameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func oaResponseError(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), + ) +} + +// oaRequireSuccess accepts only the two success encodings observed from the +// OA backend. Missing, false, null, and all other encodings fail closed. +func oaRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态") + } + message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "OA 服务未明确返回成功状态" + } + return oaResponseError(operation, "remote_failure", message) +} + +func oaLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present || value == nil { + return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present { + return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func oaFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func oaScalarString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func oaIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + if value := oaScalarString(object[key]); value != "" { + return value + } + } + return "" +} + +func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := oaIdentity(object, keys...) + if actual == "" { + return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致") + } + return nil +} + +func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + forms := make([]map[string]any, 0, len(items)) + for index, item := range items { + code := oaIdentity(item, "processCode") + name := oaFirstString(item, "processName", "name") + if code == "" || name == "" { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index)) + } + row := map[string]any{"processCode": code, "name": name} + if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" { + row["iconUrl"] = icon + } + forms = append(forms, row) + } + return forms, nil +} + +func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + instances := make([]map[string]any, 0, len(items)) + for index, item := range items { + id := oaIdentity(item, "processInstanceId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index)) + } + row := map[string]any{"processInstanceId": id} + if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" { + row["title"] = title + } + if status := oaScalarString(item["status"]); status != "" { + row["status"] = status + } + for _, key := range []string{"processCreateTime", "createTime"} { + if value, present := item[key]; present && value != nil { + row["createTime"] = value + break + } + } + instances = append(instances, row) + } + return instances, nil +} + +func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + // Reviewed OA zero-page encoding: get_done_tasks, + // get_submitted_instances, and get_noticed_instances omit hasMore only + // when result.values is an explicit empty array. No other missing + // pagination shape is accepted. + if itemCount == 0 { + return oaPageEvidence{Known: true}, nil + } + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "非空审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + if hasMore { + if currentPage <= 0 { + return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据") + } + page.Next = strconv.Itoa(currentPage + 1) + } + return page, nil +} + +func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error { + if !page.Known { + return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据") + } + payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextPage"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return oaResponseError("oa/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func validateOAPage(page, limit int) error { + if page <= 0 { + return apperrors.NewValidation("--page 必须大于 0") + } + if limit <= 0 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil +} From ba9c0f624e1c0d80f79db0c059996ca5aabe2479 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 14:58:27 +0800 Subject: [PATCH 223/267] feat(shortcut): harden OA workflows and availability --- docs/shortcut-downstream-requirements/oa.md | 90 +++ docs/shortcut-public-catalog.json | 51 +- internal/cli/command_path_fallbacks.json | 4 +- .../cli/command_path_fallbacks_generated.go | 4 +- internal/cli/command_path_fallbacks_test.go | 2 +- .../builtin/oa_semantic_catalog_test.go | 83 ++ internal/shortcut/oa/common.go | 85 +++ .../shortcut/oa/list_forms_projection_test.go | 82 -- internal/shortcut/oa/oa.go | 721 +++++------------- .../oa_strict_cross_platform_coverage_test.go | 228 ++++++ internal/shortcut/oa/smart.go | 269 +++++++ internal/shortcut/public_catalog_generated.go | 3 - internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_oa.json | 85 +++ internal/shortcut/smart/approve.go | 277 ------- internal/shortcut/smart/done_approvals.go | 109 --- internal/shortcut/smart/my_initiated.go | 247 ------ internal/shortcut/smart/pending_approvals.go | 128 ---- scripts/gen_shortcut_public_catalog.py | 1 + 19 files changed, 1055 insertions(+), 1418 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/oa.md create mode 100644 internal/shortcut/builtin/oa_semantic_catalog_test.go delete mode 100644 internal/shortcut/oa/list_forms_projection_test.go create mode 100644 internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/oa/smart.go create mode 100644 internal/shortcut/semantic_catalog_oa.json delete mode 100644 internal/shortcut/smart/approve.go delete mode 100644 internal/shortcut/smart/done_approvals.go delete mode 100644 internal/shortcut/smart/my_initiated.go delete mode 100644 internal/shortcut/smart/pending_approvals.go diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md new file mode 100644 index 00000000..3e5cdf11 --- /dev/null +++ b/docs/shortcut-downstream-requirements/oa.md @@ -0,0 +1,90 @@ +# OA Shortcut 下游能力需求 + +基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-18。 + +本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 + +## 结论与 surface + +- 源码 Shortcut:10。 +- 公开:4(`+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms`)。 +- 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 +- 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 + +| Exact Shortcut 证明 | 标签 | 聚合事实 | +|---|---|---| +| `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | +| `+list-submitted` | PASS | 已知非空 14;保证零命中 0;分页终态明确 | +| `+my-initiated` | PASS | 已知非空 14;保证零命中 0;不再回退原始响应 | +| `+search-forms` | PASS | 已知非空 2;保证零命中 0;显式完整结果 | +| `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | +| `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | +| `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | + +未列为 PASS 的能力没有安全的已知非空或可逆写 fixture,保持 unavailable;显式空结果不替代非空证明。 + +## Lark 1.0.87 用户任务映射 + +Lark approval 共 14 个任务;映射按用户结果而不是命令名判断。 + +| Lark 任务 | DWS 当前路线 | 分类 | 临时处置 | +|---|---|---|---| +| approvals get | `oa approval form-schema` | routed | 使用原子读取 | +| approvals search | `oa +search-forms` | covered | 公开 Shortcut | +| instances initiated | `oa +list-submitted` / `+my-initiated` | covered | 公开 Shortcut | +| instances get | `oa approval detail` | routed | 使用原子读取 | +| instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | +| instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | +| instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | +| tasks query | 四类列表与 `oa approval tasks` | partial | 已办/已发起公开;待办/抄送/任务 unavailable | +| tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | +| tasks approve | `oa approval approve` / `+approve-by` | partial | Shortcut unavailable;原子写需用户确认 | +| tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | +| tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | +| tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | +| tasks transfer | `redirect-task` | routed | 无安全 fixture,保持原子入口 | + +DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评论、可回退节点等产品原生任务。它们当前应由精确原子命令承担;在取得稳定 Result、fixture 与写后验证前,不为增加数量而包装 Shortcut。 + +## `DS-oa-001`:可证明前进的可见表单分页 + +- 优先级:P1;类型:adapter defect / contract insufficient;Owner:OA MCP adapter 与业务接口。 +- 影响:`oa +list-forms` 当前 unavailable。Exact Shortcut 与同参数 atomic/raw 均显示非空列表,但响应没有 `hasMore`、`nextCursor` 或等价 continuation;改变 cursor 后集合高度重叠,已排除 Shortcut 投影错误。 +- 所需合同: + - 请求明确 `cursor`(首次为 0)与 `pageSize`(1–100)。 + - 成功必须是布尔 `success=true`,并包含显式 `result.processCodeList` 数组。 + - 每个元素必须包含唯一、非空 `processCode` 和非空名称。 + - 必须返回布尔 `hasMore`;`hasMore=true` 时返回非空、与当前值不同且单调前进的 `nextCursor`;`hasMore=false` 时不得返回非空 continuation。 + - cursor 不得被忽略;重复页、回退游标与循环必须返回稳定非成功错误。 + - 明确页面大小上限、总页/总项边界和合法空首页语义。 +- 验收:Exact Shortcut 与 atomic/raw 对同一脱敏 fixture 逐页得到不重复稳定身份集合;已知非空和保证零命中均通过;缺失、重复、循环和错型 continuation 都返回非零。 +- 临时处置:`+list-forms` 保持 hidden/unavailable;需要精确定位定义时使用公开 `+search-forms`。 + +## `DS-oa-002`:可重复的审批读写 E2E fixture + +- 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 +- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。 +- 所需 fixture: + - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 + - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 + - 写操作可在隔离资源上完成,且具有确定的恢复、撤回或自动到期路径。 + - fixture 权限最小化,支持在测试结束后验证零残留;不得复用真实业务审批。 +- 验收:每个 list/search 同时通过已知非空与保证零命中;每个写在确认前 0 调用,确认后只写一次,按同一实例和 taskId 精确读回终态并完成清理。 +- 临时处置:相关 Shortcut 保持 hidden/unavailable;精确原子写仅在用户明确确认并提供真实目标时执行,不记为自动化 E2E PASS。 + +## `DS-oa-003`:审批写入的稳定终态回执与读回语义 + +- 优先级:P1;类型:contract insufficient;Owner:OA 业务服务与 MCP adapter。 +- 影响:即使写调用返回 nominal success,也难以区分已提交、处理中、已生效与 commit-unknown,阻止安全公开 `+approve-by` 及其他复合写任务。 +- 所需合同: + - 写回执必须包含 `success=true`、稳定 `processInstanceId`、稳定 `taskId`、终态或 typed pending 状态及操作类型。 + - `success=false`、缺 success、空响应、错型 ID、业务错误码冲突必须非零;不得以请求 echo 作为成功证据。 + - 同一 taskId 的查询必须提供明确 pending/approved/rejected/transferred/rolled_back 状态,或明确从待处理集合移除的强一致性窗口。 + - 异步操作返回可执行查询动作、轮询边界和 terminal failure;超时保留 commit-unknown,禁止自动重试非幂等写。 + - 批量/多步操作提供逐项 ledger;任一失败不得返回整体 success。 +- 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 +- 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 + +## 超越 Lark 的机会 + +在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index a13ab19a..8499b8bc 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -3602,53 +3602,44 @@ "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-cc", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+list-executed", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-forms", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-pending", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+list-submitted", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+my-initiated", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+search-forms", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "availability": "available" }, { "suite": "read", @@ -4089,4 +4080,4 @@ "availability": "available" } ] -} \ No newline at end of file +} diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fdd58095..fea4b829 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -191,12 +191,12 @@ "from": "oa +list-processes", "mode": "ambiguous", "candidates": [ - "oa +list-forms", + "oa +search-forms", "oa +my-initiated", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 83bcae9e..598cabb7 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 0a52ca9c..5136a8c0 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go new file mode 100644 index 00000000..a9836911 --- /dev/null +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_oa.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "oa" { + registered[item.Command] = item + } + } + if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 4 || unavailable != 5 { + t.Fatalf("public/unavailable=%d/%d, want 4/5", public, unavailable) + } +} diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index 2c994b9c..aae53aae 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -46,6 +46,17 @@ func oaObjectResult(description string) *contract.ResultSpec { } } +func oaWriteResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"processInstanceId", "taskId"}, + } +} + func oaReadSafety() contract.SafetySpec { return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} } @@ -96,6 +107,17 @@ func oaResponseError(operation, reason, message string) error { ) } +func oaPostWriteError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("write_verification"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + // oaRequireSuccess accepts only the two success encodings observed from the // OA backend. Missing, false, null, and all other encodings fail closed. func oaRequireSuccess(data map[string]any, operation string) error { @@ -260,6 +282,12 @@ func oaProjectInstances(data map[string]any, operation, path string) ([]map[stri if status := oaScalarString(item["status"]); status != "" { row["status"] = status } + if businessID := oaIdentity(item, "businessId"); businessID != "" { + row["businessId"] = businessID + } + if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" { + row["originatorName"] = originator + } for _, key := range []string{"processCreateTime", "createTime"} { if value, present := item[key]; present && value != nil { row["createTime"] = value @@ -271,6 +299,54 @@ func oaProjectInstances(data map[string]any, operation, path string) ([]map[stri return instances, nil } +func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, "result.taskIdList") + if err != nil { + return nil, err + } + tasks := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id := oaIdentity(item, "taskId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId") + } + seen[id] = struct{}{} + tasks = append(tasks, map[string]any{"taskId": id}) + } + return tasks, nil +} + +func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + next := oaScalarString(result["nextCursor"]) + if !hasMore { + if next != "" { + return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + return page, nil + } + if next == "" { + return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") + } + if next == strconv.Itoa(current) { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 与当前 cursor 相同") + } + page.Next = next + return page, nil +} + func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { raw, present := result["hasMore"] if !present { @@ -316,6 +392,15 @@ func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[st return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) } +func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error { + payload := map[string]any{"count": len(items), collection: items, "complete": true} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + func validateOAPage(page, limit int) error { if page <= 0 { return apperrors.NewValidation("--page 必须大于 0") diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go deleted file mode 100644 index 21ce7df8..00000000 --- a/internal/shortcut/oa/list_forms_projection_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oa - -import ( - "encoding/json" - "testing" -) - -// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss -// class: list_user_visible_process nests the forms under result.processCodeList. -// The resolver MUST probe that exact key — otherwise the whole list silently -// projects to empty (exit 0, no error envelope) while the backend has data. -func TestListFormsProjectProcessCodeListShape(t *testing.T) { - // Faithful list_user_visible_process shape (as returned by the backend). - const raw = `{"result":{"processCodeList":[ - {"processCode":"PROC-1","processName":"leave","dirName":"attendance"}, - {"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"}, - {"processCode":"PROC-3","processName":"overtime","dirName":"attendance"} - ],"totalCount":-1},"success":true}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - - forms := listFormsProject(data) - if len(forms) != 3 { - t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms) - } - for _, f := range forms { - if f["processCode"] == nil || f["name"] == nil { - t.Fatalf("projected form missing processCode/name: %v", f) - } - } -} - -// TestListFormsProjectBareArrayShape ensures a bare top-level array still works. -func TestListFormsProjectBareArrayShape(t *testing.T) { - const raw = `{"result":[ - {"processCode":"PROC-9","processName":"generic approval"} - ]}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if forms := listFormsProject(data); len(forms) != 1 { - t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms) - } -} - -// TestOAInstanceResolveListValuesShape guards the shared resolver behind -// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval -// instance tools nest the list under result.values; the resolver must probe -// "values" or all four commands silently return empty despite backend records. -func TestOAInstanceResolveListValuesShape(t *testing.T) { - const raw = `{"result":{"hasMore":false,"values":[ - {"processInstanceId":"i-1","title":"user leave"}, - {"processInstanceId":"i-2","title":"user reimbursement"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := oaInstanceResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got)) - } - // End-to-end through a real projection that uses the shared resolver. - if instances := listSubmittedProject(data); len(instances) != 2 { - t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances) - } -} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 80d1590d..31364c46 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -1,59 +1,75 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package oa registers declarative shortcuts for the DingTalk OA approval -// service, wrapping the raw MCP tools exposed by the dws oa helper. +// Package oa registers strict declarative shortcuts for DingTalk OA approval. package oa import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strconv" + "strings" + "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" ) -// ListPending — 查询待我处理的审批 (list_pending_approvals) +func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) { + raw := rt.Str(name) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name)) + } + return value, nil +} + +func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + evidence, err := oaHasMorePage(result, operation, len(instances), page) + if err != nil { + return err + } + return outputOAPage(rt, "instances", instances, evidence) +} + var ListPending = shortcut.Shortcut{ - Service: "oa", - Command: "+list-pending", - Product: "oa", - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_pending", - CanonicalPath: "oa.shortcut_list_pending", - CLIPath: "oa +list-pending", - PrimaryCLIPath: "oa +list-pending", + Service: "oa", Command: "+list-pending", Product: "oa", + Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", + Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-pending", + "查询待我处理的审批(时间范围为 epoch 毫秒)", + "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + oaCollectionResult("instances", "严格验证的待处理审批实例页"), + oaPagePagination("page"), + []contract.ParamDecl{ + {Name: "start", Property: "starTime"}, + {Name: "end", Property: "endTime"}, + {Name: "page", Property: "pageNum"}, + {Name: "limit", Property: "pageSize"}, + {Name: "query", Property: "query"}, }, - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - 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: "查询待我处理的审批(时间范围为 epoch 毫秒)", - UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"}, - }, - }, + "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", + ), Flags: []shortcut.Flag{ {Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true}, {Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true}, @@ -61,12 +77,24 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "starTime": rt.Int("start"), - "endTime": rt.Int("end"), + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { + return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") } + page, err := parseOAStringPage(rt, "page", 1) + if err != nil { + return err + } + limit, err := parseOAStringPage(rt, "limit", 20) + if err != nil { + return err + } + return validateOAPage(page, limit) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")} if rt.Changed("page") { params["pageNum"] = rt.Str("page") } @@ -76,530 +104,149 @@ var ListPending = shortcut.Shortcut{ if rt.Changed("query") { params["query"] = rt.Str("query") } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - instances := listPendingProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return oaInstancePage(rt, "list_pending_approvals", params, page) }, } -// listPendingProject reshapes the raw list_pending_approvals response into the -// same clean {processInstanceId, title, status, createTime} approval list as -// +list-executed/+list-submitted, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listPendingProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// Detail — 获取审批实例详情 (get_processInstance_detail) -// Approve — 同意审批 (approve_processInstance) -// Reject — 拒绝审批 (reject_processInstance) -// Revoke — 撤销已发起的审批 (revoke_processInstance) -// Records — 获取审批操作记录 (get_processInstance_records) -// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances) -// ListTasks — 查询待我审批的任务 ID (list_pending_tasks) -// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process) var ListForms = shortcut.Shortcut{ - Service: "oa", - Command: "+list-forms", - Product: "oa", - Description: "获取当前用户可见的审批表单列表", - Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_forms", - CanonicalPath: "oa.shortcut_list_forms", - CLIPath: "oa +list-forms", - PrimaryCLIPath: "oa +list-forms", - }, - 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{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"}, - }, - }, + Service: "oa", Command: "+list-forms", Product: "oa", + Description: "获取当前用户可见的审批表单列表", + Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-forms", "获取当前用户可见的审批表单列表", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", + oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "pageSize"}}, + "dws oa +list-forms --cursor 0 --limit 100", + ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + if rt.Int("limit") <= 0 || rt.Int("limit") > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{ - "cursor": rt.Int("cursor"), - "pageSize": rt.Int("limit"), - }) + const operation = "oa/list_user_visible_process" + data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")}) if err != nil { return err } - forms := listFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + forms, err := oaProjectForms(data, operation, "result.processCodeList") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaCursorPage(result, operation, rt.Int("cursor")) + if err != nil { + return err + } + return outputOAPage(rt, "forms", forms, page) }, } -// listFormsProject reshapes the raw list_user_visible_process response into a -// clean approval-form list ({processCode, name, iconUrl}) — the output-projection -// fidelity the framework applies to every list command. The list container and -// per-item field names are probed defensively across candidate keys so the -// projection tolerates response-shape drift rather than crashing or fabricating. -func listFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaFormResolveList locates the form list inside a response, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func oaFormResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // list_user_visible_process nests the forms under result.processCodeList; - // probing must include that exact key (both at the top level in case the - // envelope is already unwrapped, and one level deeper under result/data) or - // the whole list silently projects to empty. - for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} { - v, ok := data[key] - 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", "processList", "processCodeList", "forms", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaFormProjectItem picks the stable identity/label fields of a single approval -// form, probing candidate key spellings so it survives snake/camel drift. -func oaFormProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok { - row["processCode"] = v - } - if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok { - row["name"] = v - } - if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok { - row["iconUrl"] = v - } - return row -} - -// oaFormFirst returns the first present candidate key's value. -func oaFormFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// SearchForms — 按关键字模糊搜索可见审批表单 (search_form) var SearchForms = shortcut.Shortcut{ - Service: "oa", - Command: "+search-forms", - Product: "oa", - Description: "按关键字模糊搜索当前用户可见的审批表单", - Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", + Service: "oa", Command: "+search-forms", Product: "oa", + Description: "按关键字模糊搜索当前用户可见的审批表单", + Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", + "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, + []contract.ParamDecl{{Name: "query", Property: "query"}}, + "dws oa +search-forms --query 报销", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Tips: []string{`dws oa +search-forms --query 报销`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("query") == "" { + return apperrors.NewValidation("--query 不能为空") + } + return nil }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_search_forms", - CanonicalPath: "oa.shortcut_search_forms", - CLIPath: "oa +search-forms", - PrimaryCLIPath: "oa +search-forms", - }, - 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{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +search-forms --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}, - }, - Tips: []string{`dws oa +search-forms --query 报销`}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "search_form", map[string]any{ - "query": rt.Str("query"), - }) + const operation = "oa/search_form" + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": rt.Str("query")}) if err != nil { return err } - forms := searchFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) - }, -} - -// searchFormsProject reshapes the raw search_form response into the same clean -// {processCode, name, iconUrl} list as +list-forms, so both approval-form -// listings project identically. It reuses the shared oaForm* defensive probes, -// tolerating response-shape and key-spelling drift. -func searchFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user) -// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks) -var ListExecuted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-executed", - Product: "oa", - Description: "获取当前用户已经处理过的审批单列表", - Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_executed", - CanonicalPath: "oa.shortcut_list_executed", - CLIPath: "oa +list-executed", - PrimaryCLIPath: "oa +list-executed", - }, - 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{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) + forms, err := oaProjectForms(data, operation, "result") if err != nil { return err } - instances := listExecutedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return outputOACompleteCollection(rt, "forms", forms) }, } -// listExecutedProject reshapes the raw get_done_tasks response into a clean -// approval-instance list ({processInstanceId, title, status, createTime}) — the -// the clean output projection applied to every list command. The -// list container and per-item field names are probed defensively across -// candidate keys so the projection tolerates response-shape drift rather than -// crashing or fabricating data. -func listExecutedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaInstanceResolveList locates the approval-instance list inside a response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func oaInstanceResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // The approval instance tools (list_pending_approvals, get_done_tasks, - // get_submitted_instances, get_noticed_instances) all nest the instance list - // under result.values; "values" MUST be in the probe set or every one of - // +list-pending / +list-executed / +list-submitted / +list-cc silently - // projects to empty despite the backend returning records. - for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} { - v, ok := data[key] - 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", "values", "instances", "tasks", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaInstanceProjectItem picks the stable identity/label fields of a single -// approval instance, probing candidate key spellings so it survives -// snake/camel drift. -func oaInstanceProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok { - row["processInstanceId"] = v - } - if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok { - row["title"] = v - } - if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok { - row["status"] = v - } - if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok { - row["createTime"] = v - } - return row -} - -// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances) -var ListSubmitted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-submitted", - Product: "oa", - Description: "获取当前用户已发起的审批单列表", - Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_submitted", - CanonicalPath: "oa.shortcut_list_submitted", - CLIPath: "oa +list-submitted", - PrimaryCLIPath: "oa +list-submitted", +func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut { + declaration := shortcut.Shortcut{ + Service: "oa", Command: command, Product: "oa", + Description: description, Intent: intent, Risk: shortcut.RiskRead, + Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract(command, description, intent, + oaCollectionResult("instances", description), oaPagePagination("page"), + []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, + "dws oa "+command+" --page 1 --limit 20"), + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - 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{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + } + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + page, err := parseOAStringPage(rt, "page", 1) if err != nil { return err } - instances := listSubmittedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listSubmittedProject reshapes the raw get_submitted_instances response into -// the same clean {processInstanceId, title, status, createTime} approval list -// as +list-executed, so both instance listings project identically. It reuses -// the shared oaInstance* defensive probes, tolerating response-shape and -// key-spelling drift. -func listSubmittedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances) -var ListCc = shortcut.Shortcut{ - Service: "oa", - Command: "+list-cc", - Product: "oa", - Description: "获取抄送当前用户的审批单列表", - Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_cc", - CanonicalPath: "oa.shortcut_list_cc", - CLIPath: "oa +list-cc", - PrimaryCLIPath: "oa +list-cc", - }, - 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{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_noticed_instances", params) + limit, err := parseOAStringPage(rt, "limit", 20) if err != nil { return err } - instances := listCcProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listCcProject reshapes the raw get_noticed_instances response into the same -// clean {processInstanceId, title, status, createTime} approval list as the -// other instance listings, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listCcProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } + return validateOAPage(page, limit) } - return out + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")} + if query := strings.TrimSpace(rt.Str("query")); query != "" { + params["query"] = query + } + return oaInstancePage(rt, tool, params, page) + } + return declaration } -// RedirectTask — 转交审批任务给其他人 (redirect_task) -// Comment — 对审批实例添加评论 (dingflow_comments) -// CcNotice — 对审批实例进行抄送 (oa_cc_noticer) -// AppendTask — 对审批任务进行加签 (append_task) -// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities) -// RevertTask — 退回审批任务到指定节点 (revert_task) +var ListExecuted = oaNumberedInstanceShortcut( + "+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表", + "需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。", +) + +var ListSubmitted = oaNumberedInstanceShortcut( + "+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表", + "需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。", +) + +var ListCc = oaNumberedInstanceShortcut( + "+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表", + "需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。", +) + func init() { - shortcut.Register( - ListPending, - ListForms, - SearchForms, - ListExecuted, - ListSubmitted, - ListCc, - ) + shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc) } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..1e84ca4d --- /dev/null +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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 oaCoverageCaller struct { + responses map[string][]string + history []string + arguments []map[string]any +} + +func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing OA fake response for " + tool) + } + caller.responses[tool] = queue[1:] + if queue[0] == "__ERROR__" { + return nil, errors.New("injected OA failure") + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*oaCoverageCaller) Format() string { return "json" } +func (*oaCoverageCaller) DryRun() bool { return false } +func (*oaCoverageCaller) Fields() string { return "" } +func (*oaCoverageCaller) JQ() string { return "" } + +func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ + ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, + ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated, + } { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract.Result", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { + t.Fatal("+approve-by must require explicit high-risk confirmation") + } + for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { + if declaration.Contract.Pagination == nil { + t.Errorf("%s lacks Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}} + items, err := oaProjectInstances(validEmpty, "oa/test", "result.values") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"values": []any{}}}, + "false success": {"success": false, "result": map[string]any{"values": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{}}, + "wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}}, + "bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}}, + "missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}}, + } { + if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}} + if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 { + t.Fatalf("strict forms projection=%#v err=%v", projected, err) + } + badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}} + if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil { + t.Fatalf("form without processCode returned success: %#v", projected) + } + search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}} + if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 { + t.Fatalf("strict search projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1, 1); err == nil { + t.Fatal("nonempty numbered page without hasMore was accepted") + } + if page, err := oaHasMorePage(map[string]any{}, "oa/page", 0, 1); err != nil || !page.Known || page.HasMore { + t.Fatalf("reviewed empty numbered terminal page=%+v err=%v", page, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 0, 1); err == nil { + t.Fatal("wrong hasMore type was accepted") + } + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 1, 2) + if err != nil || !page.HasMore || page.Next != "3" { + t.Fatalf("numbered continuation=%+v err=%v", page, err) + } + if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil { + t.Fatal("cursor response without pagination was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil { + t.Fatal("hasMore without nextCursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { + t.Fatal("stalled cursor was accepted") + } + page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) + if err != nil || page.Next != "2" { + t.Fatalf("cursor continuation=%+v err=%v", page, err) + } +} + +func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "get_submitted_instances": {`{"success":"true","result":{"values":[]}}`}, + }} + cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + code, emitted, err := output.EmitStoredResult(cmd) + if err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if _, raw := envelope.Data["result"]; raw { + t.Fatalf("raw result leaked: %s", stdout.String()) + } + initiated, ok := envelope.Data["initiated"].([]any) + if !ok || len(initiated) != 0 || envelope.Data["complete"] != true { + t.Fatalf("strict empty initiated payload=%#v", envelope.Data) + } +} + +func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { + unconfirmed := &oaCoverageCaller{responses: map[string][]string{}} + helpers.InitDepsForTest(t, unconfirmed) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"}) + if err := root.Execute(); err == nil { + t.Fatal("unconfirmed approval unexpectedly succeeded") + } + if len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history) + } + + confirmed := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`}, + "list_pending_tasks": { + `{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`, + `{"success":true,"result":{"taskIdList":[]}}`, + }, + "approve_processInstance": {`{"success":true,"result":{"accepted":true}}`}, + }} + helpers.InitDepsForTest(t, confirmed) + confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + confirmedRoot.PersistentFlags().Bool("yes", false, "") + confirmedRoot.PersistentFlags().Bool("dry-run", false, "") + confirmedRoot.PersistentFlags().String("format", "json", "") + confirmedCtx, _ := output.WithResultStore(context.Background()) + confirmedRoot.SetContext(confirmedCtx) + confirmedRoot.SetOut(io.Discard) + confirmedRoot.SetErr(io.Discard) + confirmedService := &cobra.Command{Use: "oa"} + confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + confirmedRoot.AddCommand(confirmedService) + confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"}) + if err := confirmedRoot.Execute(); err != nil { + t.Fatal(err) + } + if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" { + t.Fatalf("call history=%s", got) + } + if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" { + t.Fatalf("identity was not preserved: %#v", confirmed.arguments) + } +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go new file mode 100644 index 00000000..90c86950 --- /dev/null +++ b/internal/shortcut/oa/smart.go @@ -0,0 +1,269 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "fmt" + "strconv" + "strings" + "time" + + "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" +) + +func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, operation, len(instances), 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令") + } + return outputOACompleteCollection(rt, collection, instances) +} + +var PendingApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+pending", Product: "oa", + Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时保持 unavailable。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + oaCollectionResult("pending", "严格验证的待审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Tips: []string{`dws oa +pending --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())} + if rt.Changed("limit") { + params["pageSize"] = float64(rt.Int("limit")) + } + return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params) + }, +} + +var DoneApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+done-approvals", Product: "oa", + Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") + } + return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)}) + }, +} + +var MyInitiated = shortcut.Shortcut{ + Service: "oa", Command: "+my-initiated", Product: "oa", + Description: "列出我发起(提交)的审批单据", + Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+my-initiated", "列出我发起(提交)的审批单据", + "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}}, + "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", + ), + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "oa/get_submitted_instances" + params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} + if query := rt.Str("query"); query != "" { + params["query"] = query + } + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + items, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, operation, len(items), rt.Int("page")) + if err != nil { + return err + } + return outputOAPage(rt, "initiated", items, page) + }, +} + +type oaApprovalMatch struct { + id string + title string +} + +func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch { + needle := strings.ToLower(strings.TrimSpace(keyword)) + matches := make([]oaApprovalMatch, 0) + for _, item := range items { + id := oaIdentity(item, "processInstanceId") + title := oaFirstString(item, "title", "processInstanceTitle") + businessID := oaIdentity(item, "businessId") + if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) { + matches = append(matches, oaApprovalMatch{id: id, title: title}) + } + } + return matches +} + +var Approve = shortcut.Shortcut{ + Service: "oa", Command: "+approve-by", Product: "oa", + Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前保持 unavailable。", + Risk: shortcut.RiskHighWrite, + Safety: oaWriteSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + oaWriteResult("同意审批并通过精确任务读回验证"), nil, + []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, + "dws oa +approve-by --keyword 报销", + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, + {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, + }, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("keyword") == "" { + return apperrors.NewValidation("--keyword 不能为空") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := rt.Str("keyword") + now := time.Now() + pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, + }) + if err != nil { + return err + } + items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(pending, "oa/list_pending_approvals", "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, "oa/list_pending_approvals", len(items), 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一") + } + matches := oaMatchApprovals(items, keyword) + if len(matches) != 1 { + return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches))) + } + instanceID := matches[0].id + tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return err + } + tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks") + if err != nil { + return err + } + if len(tasks) != 1 { + return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks))) + } + taskID := tasks[0]["taskId"].(string) + numericTaskID, err := strconv.ParseFloat(taskID, 64) + if err != nil { + return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字") + } + writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID} + if comment := rt.Str("comment"); comment != "" { + writeArgs["remark"] = comment + } + receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs) + if err != nil { + return err + } + if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil { + return err + } + readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知") + } + remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks") + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知") + } + for _, task := range remaining { + if task["taskId"] == taskID { + return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合") + } + } + return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true}) + }, +} + +func init() { + shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated) +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 51dd4a94..c1fa1f33 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -377,10 +377,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-cc": {}, "oa\u0000+list-executed": {}, - "oa\u0000+list-forms": {}, - "oa\u0000+list-pending": {}, "oa\u0000+list-submitted": {}, "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 12a00442..35efab99 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -40,6 +40,9 @@ var attendanceSemanticCatalogJSON []byte //go:embed semantic_catalog_mail.json var mailSemanticCatalogJSON []byte +//go:embed semantic_catalog_oa.json +var oaSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -69,6 +72,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( todoSemanticCatalogJSON, attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, + oaSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json new file mode 100644 index 00000000..4cdb5696 --- /dev/null +++ b/internal/shortcut/semantic_catalog_oa.json @@ -0,0 +1,85 @@ +{ + "version": 1, + "service": "oa", + "default_availability": "unavailable", + "shortcuts": { + "+list-executed": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+list-submitted": { + "disposition": "semantic_adapter", + "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+my-initiated": { + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "risk": "read", + "availability": "available", + "primary": "+list-submitted", + "public": true, + "reviewed": true + }, + "+search-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+done-approvals": { + "disposition": "alias_internal", + "semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。", + "risk": "read", + "availability": "available", + "primary": "+list-executed", + "public": false, + "reviewed": true + }, + "+list-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+list-pending": { + "disposition": "semantic_adapter", + "semantic_delta": "严格待办投影与分页已实现,但当前安全账号只有显式空结果,缺少已知非空待审批 fixture。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+pending": { + "disposition": "alias_internal", + "semantic_delta": "历史首屏待办摘要缺少页码且当前没有已知非空安全 fixture;新调用应使用 +list-pending,当前保持不可用。", + "risk": "read", + "primary": "+list-pending", + "public": false, + "reviewed": true + }, + "+list-cc": { + "disposition": "semantic_adapter", + "semantic_delta": "严格抄送列表与审核过的空页终态已实现,但当前没有已知非空抄送审批 fixture,不能完成 exact Shortcut 非空证明。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+approve-by": { + "disposition": "primary_smart", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,保持不可用。", + "risk": "high-risk-write", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go deleted file mode 100644 index 9ad9a36b..00000000 --- a/internal/shortcut/smart/approve.go +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "fmt" - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its -// title / order number, in a single command (钉钉原生编排能力). -// -// Steps (all tool names + params copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals (starTime / endTime -// as float64 millis over a recent window, plus query=keyword for -// server-side narrowing — mirroring `dws oa approval list-pending`); -// -// 2. scan the returned instances and keep the ones whose title / order number -// contains --keyword: none → "没找到待审批单据", many → list candidates -// (标题 + processInstanceId) so the caller can be more specific, exactly one -// → take its processInstanceId; -// -// 3. resolve the pending taskId for that instance via list_pending_tasks -// (processInstanceId), then agree via approve_processInstance -// (processInstanceId + taskId as float64, optional remark) — -// mirroring `dws oa approval tasks` + `dws oa approval approve`. -// -// dws oa +approve-by --keyword 报销 -// dws oa +approve-by --keyword 出差单 --comment "同意" -var Approve = shortcut.Shortcut{ - Service: "oa", - Command: "+approve-by", - Product: "oa", - Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" + - "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" + - "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," + - "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, - {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +approve-by --keyword 报销`, - `dws oa +approve-by --keyword 出差单 --comment "同意"`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - keyword := strings.TrimSpace(rt.Str("keyword")) - if keyword == "" { - return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词") - } - - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds and query narrows server-side, mirroring - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - listArgs := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - "query": keyword, - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs) - if err != nil { - return err - } - - // Step 2 — match by title / order number substring. - matches := shortcutApproveMatch(data, keyword) - switch { - case len(matches) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword)) - case len(matches) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s", - keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";"))) - } - - instanceID := matches[0].instanceID - - // Step 3a — resolve the pending taskId for this instance via - // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`. - tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{ - "processInstanceId": instanceID, - }) - if err != nil { - return err - } - taskID := shortcutApproveTaskID(tasksData) - if taskID == "" { - return apperrors.NewValidation(fmt.Sprintf( - "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。", - matches[0].title, instanceID)) - } - - // Step 3b — agree. taskId is passed as float64 and remark is optional, - // mirroring helpers.approve (approve_processInstance). - taskIDNum, err := strconv.ParseFloat(taskID, 64) - if err != nil { - return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID)) - } - approveArgs := map[string]any{ - "processInstanceId": instanceID, - "taskId": taskIDNum, - } - if c := strings.TrimSpace(rt.Str("comment")); c != "" { - approveArgs["remark"] = c - } - return rt.CallMCP("approve_processInstance", approveArgs) - }, -} - -// shortcutApproveInstance is the minimal identity we need from a pending-approval -// entry: its processInstanceId (fed to approve/tasks) and a human title/order -// number for matching + disambiguation. -type shortcutApproveInstance struct { - instanceID string - title string -} - -// shortcutApproveMatch walks a list_pending_approvals response and returns the -// instances whose title / order number contains keyword (case-insensitive). The -// gateway wraps the list under one of several common container keys, so we probe -// them defensively (mirroring latestMinutesItems / shortcutTodoCards). -func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance { - items := shortcutApproveItems(data) - kw := strings.ToLower(keyword) - var out []shortcutApproveInstance - seen := map[string]bool{} - for _, m := range items { - id := shortcutApproveInstanceID(m) - if id == "" || seen[id] { - continue - } - title := shortcutApproveTitle(m) - // Match on either the visible title/order number or the instance id. - if !strings.Contains(strings.ToLower(title), kw) && - !strings.Contains(strings.ToLower(id), kw) { - continue - } - seen[id] = true - if title == "" { - title = id - } - out = append(out, shortcutApproveInstance{instanceID: id, title: title}) - } - return out -} - -// shortcutApproveItems pulls the list of pending-approval entries out of the -// response, probing common container shapes before scanning for a bare list. -func shortcutApproveItems(data map[string]any) []map[string]any { - for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} { - if arr, ok := data[key].([]any); ok { - return shortcutApproveToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutApproveToMaps(arr) - } - } - } - } - return nil -} - -func shortcutApproveToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutApproveInstanceID reads an entry's processInstanceId (the value the -// helper feeds to --instance-id), tolerating a few common field names and both -// string / numeric JSON encodings. -func shortcutApproveInstanceID(m map[string]any) string { - for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTitle reads a human-facing title / order number for matching -// and disambiguation, probing common field names. -func shortcutApproveTitle(m map[string]any) string { - for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTaskID walks a list_pending_tasks response and returns the -// first pending taskId (the value the helper feeds to --task-id). -func shortcutApproveTaskID(data map[string]any) string { - items := shortcutApproveItems(data) - // list_pending_tasks may also return a bare {"result": [...]}/task list; the - // probe above covers those. Some gateways return a single task object. - if len(items) == 0 { - if id := shortcutApproveTaskIDField(data); id != "" { - return id - } - if r, ok := data["result"].(map[string]any); ok { - if id := shortcutApproveTaskIDField(r); id != "" { - return id - } - } - return "" - } - for _, m := range items { - if id := shortcutApproveTaskIDField(m); id != "" { - return id - } - } - return "" -} - -func shortcutApproveTaskIDField(m map[string]any) string { - for _, key := range []string{"taskId", "taskID", "activityId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveStr coerces a JSON value into a string, tolerating string and -// numeric encodings (taskId / instance id can arrive as either). -func shortcutApproveStr(v any) string { - switch t := v.(type) { - case string: - return t - case float64: - return strconv.FormatFloat(t, 'f', -1, 64) - case int64: - return strconv.FormatInt(t, 10) - } - return "" -} - -func shortcutApproveLabels(items []shortcutApproveInstance) []string { - out := make([]string, 0, len(items)) - for _, c := range items { - out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID)) - } - return out -} - -func init() { - shortcut.Register(Approve) -} diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go deleted file mode 100644 index f75b51fe..00000000 --- a/internal/shortcut/smart/done_approvals.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// DoneApprovals: READ-ONLY list of the approval tasks I have already processed -// (my approval history), projected to clean fields. Unlike `oa +approve-by` -// (which agrees to an approval) or `oa +pending` (which lists what still waits -// for me), this shortcut only looks back at what I've already handled — it never -// approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as -// float64, mirroring `dws oa approval list-executed`; optional --limit maps -// to pageSize (float64). This tool takes NO time window (unlike -// list_pending_approvals) — only pageNumber/pageSize/query. -// -// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems -// from approve.go, multiple candidate container keys + one nested level) and -// project each entry to a readable shape {title, originatorName, -// processInstanceId, createTime}, every field probed across candidate keys. -// -// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an -// empty raw dump. -// -// dws oa +done-approvals -// dws oa +done-approvals --limit 10 -var DoneApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+done-approvals", - Product: "oa", - Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", - Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" + - "若没有任何已处理记录则提示「没有已处理的审批记录」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +done-approvals`, - `dws oa +done-approvals --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys - // copied verbatim from helpers.list-executed (get_done_tasks). This tool - // carries no time window; pageNumber defaults to the first page. - // pageSize is required by get_done_tasks — the 1:1 list-executed always - // sends one (a missing/zero pageSize triggers a backend business error), - // so default to 20 and let --limit override. - params := map[string]any{ - "pageNumber": float64(1), - "pageSize": float64(20), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没有已处理的审批记录") - } - - return rt.Output(map[string]any{"count": len(results), "done": results}) - }, -} - -func init() { - shortcut.Register(DoneApprovals) -} diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go deleted file mode 100644 index 195fb6a9..00000000 --- a/internal/shortcut/smart/my_initiated.go +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// MyInitiated: list the OA approval instances *I* have initiated (submitted), in -// one step. -// -// Steps: -// -// 1. call get_submitted_instances on the OA server — the exact tool + parameter -// names (pageNumber / pageSize as float64, optional query) used by -// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page -// and limit default to the same first-page values (page 1, limit 20). -// -// 2. defensively locate the instance list inside the response (probing common -// container keys, incl. a nested result/data object) and project each item -// down to {title, businessId, status, processInstanceId} with multiple -// candidate keys per field. When no recognisable list is found we print the -// raw payload so nothing is silently lost. -// -// 3. print via rt.Output so it honours --format / --jq / --fields. -// -// Read-only: it only lists and reshapes my submitted approvals, it never -// approves, rejects, revokes or mutates anything. -// -// dws oa +my-initiated -// dws oa +my-initiated --query 报销 -// dws oa +my-initiated --page 2 --limit 50 -var MyInitiated = shortcut.Shortcut{ - Service: "oa", - Command: "+my-initiated", - Product: "oa", - Description: "列出我发起(提交)的审批单据", - Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" + - "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," + - "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" + - "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" + - "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_my_initiated", - CanonicalPath: "oa.shortcut_my_initiated", - CLIPath: "oa +my-initiated", - PrimaryCLIPath: "oa +my-initiated", - }, - 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{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws oa +my-initiated", - "dws oa +my-initiated --query 报销", - }, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false}, - }, - Tips: []string{ - `dws oa +my-initiated`, - `dws oa +my-initiated --query 报销`, - `dws oa +my-initiated --page 2 --limit 50`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my submitted approval instances. pageNumber/pageSize are - // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd. - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 - } - params := map[string]any{ - "pageNumber": float64(page), - "pageSize": float64(limit), - } - if q := strings.TrimSpace(rt.Str("query")); q != "" { - params["query"] = q - } - - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) - if err != nil { - return err - } - - // Step 2 — project the instance list; fall back to the raw payload when we - // cannot locate a recognisable list. - items := myInitiatedItems(data) - if len(items) == 0 { - return rt.Output(data) - } - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - "title": myInitiatedTitle(m), - "businessId": myInitiatedBusinessID(m), - "status": myInitiatedStatus(m), - "processInstanceId": myInitiatedInstanceID(m), - }) - } - - // Step 3 — print the projected list. - return rt.Output(map[string]any{"initiated": results}) - }, -} - -// myInitiatedItems locates the instance list inside a get_submitted_instances -// response, probing common container keys at the top level and nested under a -// result/data object. Returns nil when no list is found. -func myInitiatedItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return myInitiatedToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} { - if arr, ok := inner[k2].([]any); ok { - return myInitiatedToMaps(arr) - } - } - } - } - return nil -} - -func myInitiatedToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// myInitiatedTitle reads an instance's human-readable title, tolerating the -// common title keys the gateway may use. -func myInitiatedTitle(m map[string]any) any { - for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedBusinessID reads an instance's business / order number, preferring -// the customer-visible business id and falling back to related identifiers. -func myInitiatedBusinessID(m map[string]any) any { - for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedStatus reads an instance's current status/result, tolerating both -// the running status and the final result keys. -func myInitiatedStatus(m map[string]any) any { - for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the -// common id keys. -func myInitiatedInstanceID(m map[string]any) any { - for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedString coerces a scalar JSON value to a trimmed string, returning -// "" for nil / non-scalar / empty values. -func myInitiatedString(v any) string { - switch typed := v.(type) { - case string: - return strings.TrimSpace(typed) - case float64: - // approval ids/numbers may arrive as JSON numbers; render without a - // trailing decimal point when integral. - return strconv.FormatFloat(typed, 'f', -1, 64) - case int64: - return strconv.FormatInt(typed, 10) - case int: - return strconv.Itoa(typed) - case bool: - if typed { - return "true" - } - return "false" - default: - return "" - } -} - -func init() { - shortcut.Register(MyInitiated) -} diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go deleted file mode 100644 index 3c4e34bd..00000000 --- a/internal/shortcut/smart/pending_approvals.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to -// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval), -// this shortcut only looks — it never approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals with starTime / -// endTime as float64 milliseconds over a recent (~90 day) window, mirroring -// `dws oa approval list-pending`; optional --limit maps to pageSize (float64). -// -// 2. defensively unwrap the returned instance list (multiple candidate -// container keys, one nested level) and project each entry to a readable -// shape {title, originatorName, processInstanceId, createTime} — every field -// probed across several candidate keys. -// -// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty -// raw dump. -// -// dws oa +pending -// dws oa +pending --limit 10 -var PendingApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+pending", - Product: "oa", - Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", - Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" + - "若当前没有待你审批的任务则提示「当前没有待我审批的任务」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +pending`, - `dws oa +pending --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds; pageSize is optional. Keys copied verbatim from - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - params := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("当前没有待我审批的任务") - } - - return rt.Output(map[string]any{"count": len(results), "pending": results}) - }, -} - -// pendingApprovalsOriginator reads the initiator's display name, tolerating the -// common field names the gateway may use. -func pendingApprovalsOriginator(m map[string]any) string { - for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// pendingApprovalsCreateTime reads a pending approval's create time, returning -// the raw value (usually epoch millis) under whichever candidate key is present. -func pendingApprovalsCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil -} - -func init() { - shortcut.Register(PendingApprovals) -} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 5c1e6546..41ae4e61 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -29,6 +29,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ] From 2cb83d388b11aad5231ae59b8780480975beccc0 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:10:54 +0800 Subject: [PATCH 224/267] fix(shortcut): publish OA validation constraints --- internal/shortcut/oa/oa.go | 14 +++++++++----- internal/shortcut/oa/smart.go | 18 +++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 31364c46..e6dc70aa 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -77,7 +77,8 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") @@ -126,7 +127,8 @@ var ListForms = shortcut.Shortcut{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, - Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Int("cursor") < 0 { return apperrors.NewValidation("--cursor 不能小于 0") @@ -172,8 +174,9 @@ var SearchForms = shortcut.Shortcut{ []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", ), - Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, - Tips: []string{`dws oa +search-forms --query 报销`}, + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, + Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Str("query") == "" { return apperrors.NewValidation("--query 不能为空") @@ -208,7 +211,8 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, } declaration.Validate = func(rt *shortcut.RuntimeContext) error { page, err := parseOAStringPage(rt, "page", 1) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 90c86950..8acc5d14 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -52,8 +52,9 @@ var PendingApprovals = shortcut.Shortcut{ oaCollectionResult("pending", "严格验证的待审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", ), - Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, - Tips: []string{`dws oa +pending --limit 10`}, + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +pending --limit 10`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { return apperrors.NewValidation("--limit 必须在 1 到 100 之间") @@ -83,8 +84,9 @@ var DoneApprovals = shortcut.Shortcut{ oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", ), - Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, - Tips: []string{`dws oa +done-approvals --limit 10`}, + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { return apperrors.NewValidation("--limit 必须在 1 到 100 之间") @@ -119,8 +121,9 @@ var MyInitiated = shortcut.Shortcut{ {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, }, - Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, - Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/get_submitted_instances" params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} @@ -184,7 +187,8 @@ var Approve = shortcut.Shortcut{ {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, }, - Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Str("keyword") == "" { return apperrors.NewValidation("--keyword 不能为空") From 4da5a07d1dfdb9cf41ab71c2da37e1a87b981f2c Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:18:26 +0800 Subject: [PATCH 225/267] feat(shortcut): harden DING reads and availability --- docs/shortcut-downstream-requirements/ding.md | 78 +++++ docs/shortcut-public-catalog.json | 28 +- internal/app/schema_shortcut_contract_test.go | 2 +- .../builtin/ding_semantic_catalog_test.go | 83 +++++ internal/shortcut/ding/common.go | 315 ++++++++++++++++++ internal/shortcut/ding/ding.go | 303 +++++++---------- ...ing_strict_cross_platform_coverage_test.go | 231 +++++++++++++ internal/shortcut/public_catalog_generated.go | 3 - internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_ding.json | 43 +++ scripts/gen_shortcut_public_catalog.py | 1 + 11 files changed, 880 insertions(+), 211 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/ding.md create mode 100644 internal/shortcut/builtin/ding_semantic_catalog_test.go create mode 100644 internal/shortcut/ding/common.go create mode 100644 internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/semantic_catalog_ding.json diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md new file mode 100644 index 00000000..0d35fa99 --- /dev/null +++ b/docs/shortcut-downstream-requirements/ding.md @@ -0,0 +1,78 @@ +# DING Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut-only DING surface + +## 1. 执行摘要 + +- 源码 Shortcut:5;公开:1;unavailable:4。 +- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。 +- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。 +- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。 +- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。 +- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。 + +## 2. 用户任务与 Lark 映射 + +| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 | +|---|---|---|---| +| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 | +| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 | +| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable | +| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable | +| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable | +| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | +| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | + +## 3. 真实 E2E 聚合矩阵 + +| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 | +|---|---:|---:|---|---|---|---| +| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable | +| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public | +| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | + +## 4. 下游需求 + +### DS-DING-001 — 稳定写回执与接收人身份 + +- 优先级:P1;类型:contract insufficient。 +- 影响:`+send-personal`、`+send-by-message`。 +- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。 +- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。 +- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。 +- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。 + +### DS-DING-002 — 可验证撤回终态 + +- 优先级:P1;类型:missing capability。 +- 影响:`+recall-personal` 及两种发送能力的清理闭环。 +- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。 +- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。 +- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。 + +### DS-DING-003 — 游标边界语义 + +- 优先级:P2;类型:contract insufficient。 +- 影响:`+list` 的跨页聚合调用方。 +- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。 +- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。 + +## 5. Fixture、权限与清理 + +| 需求 | 安全资源模型 | 到期/回收 | 状态 | +|---|---|---|---| +| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED | +| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED | +| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED | +| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS | + +## 6. 安全与脱敏声明 + +- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。 +- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 +- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8499b8bc..3df4be3e 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -2494,32 +2494,14 @@ "status": "real-ok" }, { - "suite": "read", - "service": "ding", - "command": "+list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+recall-personal", - "risk": "high-risk-write", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "ding", "command": "+receiver-status", "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+send-personal", - "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 82f982fb..68a1db5e 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -18,7 +18,7 @@ import ( const ( publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, - // including the hidden historical minutes.shortcut_minutes_search contract. + // including reviewed hidden compatibility and unavailable contracts. schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. publiclyDeliveredShortcutCount = 422 diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go new file mode 100644 index 00000000..f944093d --- /dev/null +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_ding.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "ding" { + registered[item.Command] = item + } + } + if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 4 { + t.Fatalf("public/unavailable=%d/%d, want 1/4", public, unavailable) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go new file mode 100644 index 00000000..719b15eb --- /dev/null +++ b/internal/shortcut/ding/common.go @@ -0,0 +1,315 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "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 ( + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications." +) + +type dingPageEvidence struct { + HasMore bool + Next string +} + +func dingReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func dingWriteSafety(destructive bool) contract.SafetySpec { + effect, risk := "write", "medium" + if destructive { + effect, risk = "destructive", "high" + } + return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"} +} + +func dingListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`), + SensitivePaths: []string{"messages.content", "messages.senderNick"}, + } +} + +func dingReceiverResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`), + SensitivePaths: []string{"receivers.receiverNick"}, + } +} + +func dingPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "ding " + command + availability, reason := contract.InterfaceAvailable, dingCompositeReason + if !available { + availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "ding", Name: name, CanonicalPath: "ding." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"}, + Examples: examples, + }, + } +} + +func dingResponseError(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 dingRequireResult(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空") + } + success, present := data["success"] + if !present { + return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态") + } + succeeded, ok := success.(bool) + if !ok { + return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success)) + } + if !succeeded { + message := "DING 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return nil, dingResponseError(operation, "remote_failure", message) + } + result, ok := data["result"].(map[string]any) + if !ok || result == nil { + return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象") + } + return result, nil +} + +func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) { + raw, present := result[field] + if !present { + return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field)) + } + values, ok := raw.([]any) + if !ok { + return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw)) + } + items := make([]map[string]any, 0, len(values)) + for index, value := range values { + item, ok := value.(map[string]any) + if !ok || len(item) == 0 { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index)) + } + items = append(items, item) + } + return items, nil +} + +func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field)) + } + return value, nil +} + +func dingOptionalString(item map[string]any, field string) (string, error) { + value, present := item[field] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value) + } + return strings.TrimSpace(text), nil +} + +func dingInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, dingPageEvidence{}, err + } + items, err := dingRequireObjectCollection(result, operation, "dingMessages") + if err != nil { + return nil, dingPageEvidence{}, err + } + messages := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, dingPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId") + } + seen[id] = struct{}{} + projected := map[string]any{"openDingId": id} + for source, target := range map[string]string{ + "dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId", + "sendTime": "sendTime", "senderNick": "senderNick", + } { + value, valueErr := dingOptionalString(item, source) + if valueErr != nil { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error())) + } + if value != "" { + projected[target] = value + } + } + messages = append(messages, projected) + } + hasMoreValue, present := result["hasMore"] + if !present { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值") + } + page := dingPageEvidence{HasMore: hasMore} + if !hasMore { + if next, exists := result["nextCursor"]; exists && next != nil { + if numeric, valid := dingInteger(next); !valid || numeric != 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页") + } + } + return messages, page, nil + } + if len(messages) == 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进") + } + next, valid := dingInteger(result["nextCursor"]) + if !valid || next <= 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor") + } + page.Next = strconv.FormatInt(next, 10) + return messages, page, nil +} + +func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, err + } + items, err := dingRequireObjectCollection(result, operation, "receivers") + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态") + } + receivers := make([]map[string]any, 0, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, identityErr + } + if id != expectedID { + return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致") + } + status, ok := dingInteger(item["confirmedStatus"]) + if !ok { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index)) + } + nick, nickErr := dingRequiredString(item, operation, "receiverNick", index) + if nickErr != nil { + return nil, nickErr + } + receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick}) + } + return receivers, nil +} + +func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error { + payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func dingUnavailable(operation string) error { + return apperrors.NewDiscovery("该 DING 写 Shortcut 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行", + apperrors.WithOperation(operation), + apperrors.WithOrigin("shortcut_registry"), + apperrors.WithFailureStage("capability_gate"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithReason("write_fixture_unavailable"), + ) +} diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index e5e3bb2b..1698cdde 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -1,226 +1,161 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are -// copied verbatim from internal/helpers/ding.go. Some tools route to the "im" -// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product. +// Package ding registers strict declarative shortcuts for DingTalk DING. package ding import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "strconv" + "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" ) var List = shortcut.Shortcut{ Service: "ding", Command: "+list", Product: "im", - Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_list", - CanonicalPath: "ding.shortcut_list", - CLIPath: "ding +list", - PrimaryCLIPath: "ding +list", - }, - Description: "查询 DING 消息列表", - 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: "查询 DING 消息列表", - UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +list"}, - }, - }, + Description: "查询 DING 消息列表", + Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+list", "查询 DING 消息列表", + "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + true, dingListResult(), dingPagination(), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}}, + "dws ding +list --type ALL", "dws ding +list --type DELETED", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型", Enum: []string{"ALL", "UNREAD", "SEND", "NEW_COMMENT", "DELETED"}}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, + Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - // type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。 - msgType := rt.Str("type") - if msgType == "" { - msgType = "ALL" - } - params := map[string]any{"type": msgType} + params := map[string]any{"type": rt.Str("type")} if rt.Changed("cursor") { params["cursor"] = rt.Int("cursor") } - return rt.CallMCP("list_ding_messages", params) + data, err := rt.CallMCPData("im", "list_ding_messages", params) + if err != nil { + return err + } + messages, page, err := dingProjectMessages(data, "im/list_ding_messages") + if err != nil { + return err + } + if page.HasMore { + current := int64(rt.Int("cursor")) + next, parseErr := strconv.ParseInt(page.Next, 10, 64) + if parseErr != nil || next <= current { + return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进") + } + } + return outputDingPage(rt, messages, page) }, } var ReceiverStatus = shortcut.Shortcut{ Service: "ding", Command: "+receiver-status", Product: "im", - Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_receiver_status", - CanonicalPath: "ding.shortcut_receiver_status", - CLIPath: "ding +receiver-status", - PrimaryCLIPath: "ding +receiver-status", - }, - Description: "查询 DING 消息接收人已读状态", - 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: "查询 DING 消息接收人已读状态", - UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +receiver-status --ding-id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, + Description: "查询 DING 消息接收人已读状态", + Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+receiver-status", "查询 DING 消息接收人已读状态", + "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + true, dingReceiverResult(), nil, + []contract.ParamDecl{{Name: "ding-id", Property: "openDingId"}}, + "dws ding +receiver-status --ding-id ", + ), + Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + Tips: []string{"dws ding +receiver-status --ding-id "}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_ding_receiver_status", map[string]any{ - "openDingId": rt.Str("ding-id"), - }) + const operation = "im/list_ding_receiver_status" + data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")}) + if err != nil { + return err + } + receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id")) + if err != nil { + return err + } + payload := map[string]any{"count": len(receivers), "receivers": receivers} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(receivers))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) }, } -var SendPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+send-personal", Product: "im", - Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "medium", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_send_personal", - CanonicalPath: "ding.shortcut_send_personal", - CLIPath: "ding +send-personal", - PrimaryCLIPath: "ding +send-personal", - }, - Description: "以本人身份发送 DING 给指定人", - 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: "以本人身份发送 DING 给指定人", - UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +send-personal --users --content "}, - }, - }, - Flags: []shortcut.Flag{ +var SendPersonal = unavailableDingWrite( + "+send-personal", "以本人身份发送 DING 给指定人", + "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "content": rt.Str("content"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_personal_ding", params) + []contract.ParamDecl{ + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, -} + "dws ding +send-personal --users --content ", +) -var SendByMessage = shortcut.Shortcut{ - Service: "ding", Command: "+send-by-message", Product: "im", - Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ +var SendByMessage = unavailableDingWrite( + "+send-by-message", "针对某条消息发起 DING 提醒", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但安全 fixture 与精确清理闭环未具备。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "openConversationId": rt.Str("group"), - "openMessageId": rt.Str("message-id"), - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_ding_by_message", params) + []contract.ParamDecl{ + {Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, -} + "dws ding +send-by-message --group --message-id --users ", +) -var RecallPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+recall-personal", Product: "im", - Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite, - Safety: contract.SafetySpec{ - Effect: "destructive", Risk: "high", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_recall_personal", - CanonicalPath: "ding.shortcut_recall_personal", - CLIPath: "ding +recall-personal", - PrimaryCLIPath: "ding +recall-personal", +var RecallPersonal = unavailableDingWrite( + "+recall-personal", "撤回本人发起的 DING", + "需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。", + shortcut.RiskHighWrite, true, + []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, + "dws ding +recall-personal --id ", +) + +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Flags: flags, Tips: []string{example}, + Execute: func(*shortcut.RuntimeContext) error { + return dingUnavailable("ding/" + command) }, - Description: "撤回本人发起的 DING", - 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: "撤回本人发起的 DING", - UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +recall-personal --id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("recall_personal_ding", map[string]any{ - "openDingId": rt.Str("id"), - }) - }, + } } func init() { - shortcut.Register( - List, - ReceiverStatus, - SendPersonal, - SendByMessage, - RecallPersonal, - ) + shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..b6fba7eb --- /dev/null +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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 dingCoverageCaller struct { + responses map[string][]string + history []string +} + +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing DING fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*dingCoverageCaller) Format() string { return "json" } +func (*dingCoverageCaller) DryRun() bool { return false } +func (*dingCoverageCaller) Fields() string { return "" } +func (*dingCoverageCaller) JQ() string { return "" } + +func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Contract.Empty() { + t.Errorf("%s lacks Contract", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} { + if declaration.Contract.Result == nil { + t.Errorf("%s lacks Result", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s is not interface-available", declaration.Command) + } + } + for _, declaration := range []shortcut.Shortcut{SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s write safety/interface drift", declaration.Command) + } + } + if List.Contract.Pagination == nil { + t.Fatal("+list lacks cursor pagination") + } +} + +func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}}, + "hasMore": true, + "nextCursor": float64(2), + }, + } + messages, page, err := dingProjectMessages(valid, "im/list_ding_messages") + if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" { + t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}} + if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore { + t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err) + } + fixtures := map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "missing result": {"success": true}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}}, + "missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}}, + "duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}}, + "wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}}, + "empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}}, + "missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}}, + "wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}}, + "conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}}, + "wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}}, + } + for name, fixture := range fixtures { + if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}} + items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1") + if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) { + t.Fatalf("valid receiver projection=%#v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing collection": {"success": true, "result": map[string]any{}}, + "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, + "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, + "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, + } { + if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { + listCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, + }} + cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("list unified projection=%#v", data) + } + if strings.Join(listCaller.history, ",") != "list_ding_messages" { + t.Fatalf("list call history=%v", listCaller.history) + } + + receiverCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil { + t.Fatal(err) + } + if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" { + t.Fatalf("receiver call history=%v", receiverCaller.history) + } +} + +func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { + cases := []struct { + declaration shortcut.Shortcut + args []string + }{ + {SendPersonal, []string{"--users", "D-fixture", "--content", "fixture"}}, + {SendByMessage, []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}}, + {RecallPersonal, []string{"--id", "ding-fixture"}}, + } + for _, test := range cases { + t.Run(test.declaration.Command, func(t *testing.T) { + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, test.declaration, unconfirmed, false, test.args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) + } + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, test.declaration, confirmed, true, test.args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } + }) + } +} + +func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "ding"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + argv := []string{"ding", declaration.Command} + argv = append(argv, args...) + if yes { + argv = append(argv, "--yes") + } + root.SetArgs(argv) + return root.Execute() +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index c1fa1f33..edf28fd4 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -265,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "devapp\u0000+version-status": {}, "devapp\u0000+webapp-config": {}, "devapp\u0000+webapp-get": {}, - "ding\u0000+list": {}, - "ding\u0000+recall-personal": {}, "ding\u0000+receiver-status": {}, - "ding\u0000+send-personal": {}, "doc\u0000+access-change": {}, "doc\u0000+access-grant": {}, "doc\u0000+access-revoke": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 35efab99..535a3f0d 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -43,6 +43,9 @@ var mailSemanticCatalogJSON []byte //go:embed semantic_catalog_oa.json var oaSemanticCatalogJSON []byte +//go:embed semantic_catalog_ding.json +var dingSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -73,6 +76,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, oaSemanticCatalogJSON, + dingSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json new file mode 100644 index 00000000..46f34233 --- /dev/null +++ b/internal/shortcut/semantic_catalog_ding.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "service": "ding", + "default_availability": "unavailable", + "shortcuts": { + "+list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+receiver-status": { + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+send-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+send-by-message": { + "disposition": "semantic_adapter", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+recall-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。", + "risk": "high-risk-write", + "public": false, + "reviewed": true + } + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 41ae4e61..7d4fa695 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -30,6 +30,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", ] From 161687ae4c9659cbdb87d14646d5d5ef9f208649 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:46:16 +0800 Subject: [PATCH 226/267] fix(shortcut): deliver OA validation evidence --- internal/app/command_path_fallback_e2e_test.go | 2 +- internal/shortcut/oa/oa.go | 13 ++++++++----- internal/shortcut/oa/smart.go | 13 ++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index 1532bd08..f5e083c3 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index e6dc70aa..79bd12d6 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -174,7 +174,7 @@ var SearchForms = shortcut.Shortcut{ []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", ), - Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { @@ -207,12 +207,15 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, - Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, } declaration.Validate = func(rt *shortcut.RuntimeContext) error { page, err := parseOAStringPage(rt, "page", 1) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 8acc5d14..c62705b1 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -118,12 +118,15 @@ var MyInitiated = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, - Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, - Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/get_submitted_instances" params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} From c6417e35273c6b6477c738e606b2748b1042a6e5 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:09:34 +0800 Subject: [PATCH 227/267] feat(shortcut): harden Report reads and availability --- .../report.md | 93 ++++ docs/shortcut-public-catalog.json | 16 +- .../builtin/report_semantic_catalog_test.go | 83 +++ internal/shortcut/public_catalog_generated.go | 2 +- internal/shortcut/report/common.go | 477 ++++++++++++++++++ internal/shortcut/report/latest.go | 96 ++++ internal/shortcut/report/report.go | 465 +++++++++-------- .../report/report_list_projection_test.go | 38 -- ...ort_strict_cross_platform_coverage_test.go | 309 ++++++++++++ internal/shortcut/semantic_catalog.go | 4 + .../shortcut/semantic_catalog_report.json | 37 ++ internal/shortcut/smart/report_latest.go | 220 -------- scripts/gen_shortcut_public_catalog.py | 1 + 13 files changed, 1342 insertions(+), 499 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/report.md create mode 100644 internal/shortcut/builtin/report_semantic_catalog_test.go create mode 100644 internal/shortcut/report/common.go create mode 100644 internal/shortcut/report/latest.go delete mode 100644 internal/shortcut/report/report_list_projection_test.go create mode 100644 internal/shortcut/report/report_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/semantic_catalog_report.json delete mode 100644 internal/shortcut/smart/report_latest.go diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md new file mode 100644 index 00000000..8456eb4b --- /dev/null +++ b/docs/shortcut-downstream-requirements/report.md @@ -0,0 +1,93 @@ +# Report Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 对比基线:lark-cli 1.0.87(无 Report 产品域) +> 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 + +## 1. 执行摘要 + +- 源码 Shortcut 共 4 个;公开 2 个,unavailable 2 个。 +- `+template-search` 已完成严格模板集合、已知非空和保证零命中证明,可公开。 +- `+inbox-list` 已完成已知非空、保证零项、多页 cursor 严格前进、相邻页稳定身份无重复和明确耗尽证明,可公开。 +- `+outbox-list` 在多个有界窗口只得到合法零项结果;缺少已知非空证明,因此保持 unavailable。 +- `+report-latest` 依赖完整非空发件箱和精确详情读回;当前没有安全 fixture,因此保持 unavailable,且不再回退原始列表行。 +- Report 原子层具有模板、收件箱、发件箱、详情、统计和创建能力;创建缺少可验证删除/回收能力,本轮未执行写操作。 + +| ID | 优先级 | 类型 | 用户任务 | 当前处置 | 解锁的 Shortcut | +|---|---|---|---|---|---| +| `DS-Report-001` | P1 | tenant-or-fixture / missing capability | 读取自己发出的日志及最新详情 | hidden + unavailable | `+outbox-list`、`+report-latest` | + +## 2. 用户任务与证据矩阵 + +| 用户任务 | Shortcut | Lark CLI 对应 | Exact Shortcut 证据 | Atomic/raw 对照 | 结论 | +|---|---|---|---|---|---| +| 按名称搜索可用日志模板 | `+template-search` | 无 | `PASS-RPT-TEMPLATE-NONEMPTY`:1 项;`PASS-RPT-TEMPLATE-ZERO`:0 项 | `PASS-RPT-TEMPLATE-COLLECTION`:完整集合聚合数量 73 | available / public | +| 分页查看收到的日志 | `+inbox-list` | 无 | `PASS-RPT-INBOX-PAGES`:页大小依次为 20、20、20、20、12,随后明确耗尽;`PASS-RPT-INBOX-ZERO`:0 项 | `PASS-RPT-INBOX-CURSOR`:非终止 cursor 严格递增;首对相邻页稳定身份交集为 0 | available / public | +| 分页查看自己发出的日志 | `+outbox-list` | 无 | `PASS-RPT-OUTBOX-ZERO`:多个有界窗口均为 0 项 | `PASS-RPT-OUTBOX-TERMINAL`:零项页具有明确成功与终止证据 | unavailable | +| 读取自己最新提交的日志详情 | `+report-latest` | 无 | `PASS-RPT-LATEST-ZERO`:发件箱为空时在一次列表调用后停止 | `PASS-RPT-DETAIL-SHAPE`:独立详情能力可返回稳定身份与严格字段数组,但没有安全发件箱目标可串联 | unavailable | + +## 3. 已验证分页合同 + +- `PASS-RPT-INBOX-CURSOR`:所有非终止页均返回正整数、相对请求严格前进的 continuation。 +- `PASS-RPT-INBOX-PAGES`:exact Shortcut 沿服务端返回 cursor 读取 5 个非空页,聚合数量 92,末页明确 `hasMore=false`。 +- `PASS-RPT-INBOX-OVERLAP`:首对相邻非空页的稳定身份交集为 0。 +- `PASS-RPT-INBOX-ZERO`:独立有界查询返回合法 0 项、`complete=true` 与 endpoint exhausted。 +- 服务端在终止页仍回显整数 cursor;DWS 只把它当页面收据,不发布 `next_token`。错型或冲突 cursor 仍失败。 +- DWS 严格拒绝空响应、缺/错型 `success`、缺/错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。 + +## 4. 下游需求明细 + +### `DS-Report-001` — 提供可回收的发件箱 E2E fixture + +#### 用户结果 + +用户能够可靠列出自己发出的日志,并在完整候选集合中选出最新一篇,再按稳定 `reportId` 读取身份一致的详情。 + +#### 当前证据与归因 + +- `PASS-RPT-OUTBOX-ZERO`:多个独立有界窗口均为合法 0 项;这只能证明零结果合同,不能证明非空项目投影。 +- `PASS-RPT-LATEST-ZERO`:组合命令遇到空发件箱后停止,没有调用详情,也没有把空结果伪装成成功详情。 +- `PASS-RPT-DETAIL-SHAPE`:在独立安全读取中,详情结果具备稳定身份、时间字段与非空字段集合;统计读取也具备明确成功对象。 +- 原子层可以创建日志,但没有对应删除/回收能力。仅凭创建成功回执不能满足写后精确读回、清理和零残留要求,因此本轮没有创建测试数据。 + +#### 所需 fixture / 能力 + +- 首选:租户提供专用、无业务内容、定期自动过期的已发送日志 fixture,并保证测试身份可读取。 +- 或者:新增精确删除/回收能力,使测试可按创建回执中的稳定身份读回并在 `defer` 清理,最后证明发件箱与详情均无残留。 +- 非空发件箱响应必须包含布尔 `success=true`、精确集合、每项稳定 `reportId`、正整数 `createTime` 和真实分页终止证据。 +- 详情响应必须返回与请求完全一致的稳定身份;字段集合必须为数组,坏元素不能被跳过。 +- 若创建执行结果不确定,必须返回 commit-unknown 分类与安全的核查动作,不能建议盲目重试。 + +#### 验收标准 + +1. exact `+outbox-list` 同时证明已知非空与保证零项。 +2. 非空项目均有稳定身份与排序时间,分页遍历至终止且无重复。 +3. exact `+report-latest` 只调用一次完整列表和一次精确详情,详情身份与所选目标一致。 +4. 若用创建产生 fixture:确认前 0 次写调用;确认后稳定回执;按回执精确读回;无条件清理;最终证明零残留。 +5. 没有可验证清理能力时,不执行创建,并继续保持相关 Shortcut unavailable。 + +## 5. Lark 对齐与平台差异 + +lark-cli 1.0.87 没有 Report/日志产品域,因此 4 个 Report Shortcut 均无一对一 Lark 任务。它们是 DWS 的平台原生增量能力,不应虚构映射: + +| DWS 用户任务 | Lark 映射 | 推荐结论 | +|---|---|---| +| 搜索日志模板 | 无 | DWS 原生能力;公开 `+template-search` | +| 查看收到的日志 | 无 | DWS 原生能力;公开 `+inbox-list` | +| 查看发出的日志或最新详情 | 无 | DWS 原生能力;非空链路验证前保持 unavailable | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 已完成修复 | 回归证据 | +|---|---|---| +| 全部 4 个 | 统一 Result/Safety/Contract、unified output;空响应、缺/错型 success、缺集合、坏元素与重复身份全部失败 | `PASS-RPT-STRICT-MATRIX` | +| `+inbox-list`、`+outbox-list` | 发布真实 cursor pagination;缺失、冲突、错型和不前进 cursor 全部失败;终止页不发布回显 cursor | `PASS-RPT-PAGINATION-MATRIX` | +| `+template-search` | 严格验证完整模板集合后本地筛选;不从坏元素或未知形状制造空结果 | `PASS-RPT-TEMPLATE-MATRIX` | +| `+report-latest` | 删除原始列表行回退;仅接受完整候选集合、可排序时间和身份匹配详情 | `PASS-RPT-LATEST-READBACK` | + +## 7. 安全与脱敏声明 + +- 本文不包含真实 ID、标题、人名、邮箱、正文、原始响应、trace/request ID、token 或签名 URL。 +- 所有真实证据只以 `PASS-RPT-*` 标签和聚合数量表达;不得用这些标签反查或推断具体资源。 +- 写能力因缺少可验证清理合同未执行;不存在为了测试而遗留的 Report 数据。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 3df4be3e..eeb72aa2 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -3624,18 +3624,24 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+inbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", - "command": "+outbox-list", + "command": "+template-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "availability": "available" }, { "suite": "read", diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go new file mode 100644 index 00000000..06ece7a0 --- /dev/null +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_report.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "report" { + registered[item.Command] = item + } + } + if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("report", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 2 || unavailable != 2 { + t.Fatalf("public/unavailable=%d/%d, want 2/2", public, unavailable) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index edf28fd4..9dc6742d 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -379,7 +379,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, - "report\u0000+outbox-list": {}, + "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, "todo\u0000+assign": {}, diff --git a/internal/shortcut/report/common.go b/internal/shortcut/report/common.go new file mode 100644 index 00000000..2bac4e58 --- /dev/null +++ b/internal/shortcut/report/common.go @@ -0,0 +1,477 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "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 reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification." + +type reportPageEvidence struct { + HasMore bool + Next string +} + +func reportReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func reportCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"}, + } +} + +func reportTemplateSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`), + SensitivePaths: []string{"templates.name"}, + } +} + +func reportLatestResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`), + SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"}, + } +} + +func reportPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "report " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "report", Name: name, CanonicalPath: "report." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"}, + Examples: examples, + }, + } +} + +func reportResponseError(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 reportRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空") + } + raw, present := data["success"] + if !present { + return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态") + } + success, ok := raw.(bool) + if !ok { + return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw)) + } + if success { + return nil + } + message := "Report 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return reportResponseError(operation, "remote_failure", message) +} + +func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, nil, err + } + result, present := data["result"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合") + } + if _, ok := result.([]any); ok { + items, err := reportObjectCollection(result, operation, "result") + return items, data, err + } + container, ok := result.(map[string]any) + if !ok || container == nil { + return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result)) + } + raw, present := container["report_list"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组") + } + items, err := reportObjectCollection(raw, operation, "result.report_list") + return items, container, err +} + +func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) { + var selected string + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key)) + } + if selected != "" && selected != value { + return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index)) + } + selected = value + } + if selected == "" { + return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index)) + } + return selected, nil +} + +func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw)) + } + return strings.TrimSpace(value), nil + } + return "", nil +} + +func reportInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key)) + } + return value, true, nil + } + return 0, false, nil +} + +func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) { + items, pageContainer, err := reportListCollection(data, operation) + if err != nil { + return nil, reportPageEvidence{}, err + } + entries := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id") + if identityErr != nil { + return nil, reportPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId") + } + seen[id] = struct{}{} + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"}, + "creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"}, + "creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"}, + } { + value, valueErr := reportOptionalString(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time", "gmtCreate", "sendTime"}, + "modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"}, + } { + value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if present { + projected[target] = value + } + } + entries = append(entries, projected) + } + page, err := reportPage(data, pageContainer, operation, len(entries)) + if err != nil { + return nil, reportPageEvidence{}, err + } + return entries, page, nil +} + +func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) { + raw, present := data["hasMore"] + if nested, exists := result["hasMore"]; exists { + if present && !sameReportScalar(raw, nested) { + return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突") + } + raw, present = nested, true + } + if !present { + return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值") + } + page := reportPageEvidence{HasMore: hasMore} + next, nextPresent, err := reportNextCursor(data, result, operation) + if err != nil { + return reportPageEvidence{}, err + } + if !hasMore { + // The Report service echoes an integer cursor on terminal pages. It is a + // page receipt, not a continuation: hasMore=false is authoritative and + // the unified projection intentionally omits next_token. Parsing above + // still rejects wrong types and conflicting cursor fields. + return page, nil + } + if count == 0 { + return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true") + } + if !nextPresent || next <= 0 { + return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor") + } + page.Next = strconv.FormatInt(next, 10) + return page, nil +} + +func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) { + var selected int64 + found := false + for _, object := range []map[string]any{data, result} { + for _, key := range []string{"nextCursor", "cursor"} { + raw, present := object[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key)) + } + if found && selected != value { + return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突") + } + selected, found = value, true + } + } + return selected, found, nil +} + +func sameReportScalar(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) +} + +func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + raw, present := data["items"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组") + } + items, err := reportObjectCollection(raw, operation, "items") + if err != nil { + return nil, err + } + templates := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "report_template_id") + if identityErr != nil { + return nil, identityErr + } + name, nameErr := reportRequiredString(item, operation, index, "report_template_name") + if nameErr != nil { + return nil, nameErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId") + } + seen[id] = struct{}{} + projected := map[string]any{"templateId": id, "name": name} + modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time") + if modifiedErr != nil { + return nil, modifiedErr + } + if present { + projected["lastModifiedTime"] = modified + } + templates = append(templates, projected) + } + return templates, nil +} + +func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + result, ok := data["result"].(map[string]any) + if !ok || len(result) == 0 { + return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象") + } + id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id") + if err != nil { + return nil, err + } + if id != expectedID { + return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致") + } + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "title": {"report_name", "reportName", "title"}, + "templateName": {"report_template_name", "templateName"}, + "creatorName": {"creatorName", "senderName"}, + } { + value, valueErr := reportOptionalString(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"}, + } { + value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if present { + projected[target] = value + } + } + rawFields, present := result["report_content"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组") + } + fields, err := reportObjectCollection(rawFields, operation, "result.report_content") + if err != nil { + return nil, err + } + projectedFields := make([]map[string]any, 0, len(fields)) + for index, field := range fields { + key, keyErr := reportRequiredString(field, operation, index, "key") + if keyErr != nil { + return nil, keyErr + } + rawValue, valuePresent := field["value"] + value, valueOK := rawValue.(string) + if !valuePresent || !valueOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index)) + } + sortValue, sortOK := reportInteger(field["sort"]) + typeValue, typeOK := reportInteger(field["type"]) + if !sortOK || !typeOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index)) + } + projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue}) + } + projected["fields"] = projectedFields + return projected, nil +} + +func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error { + payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return reportResponseError("report/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go new file mode 100644 index 00000000..86d6b644 --- /dev/null +++ b/internal/shortcut/report/latest.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "strings" + "time" + + "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" +) + +var ReportLatest = shortcut.Shortcut{ + Service: "report", Command: "+report-latest", Product: "report", + Description: "读取我最近提交的一篇日志详情", + Intent: "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+report-latest", "读取我最近提交的一篇日志详情", + "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + reportLatestResult(), nil, + []contract.ParamDecl{{Name: "keyword", Property: "report_template_name"}}, + "dws report +report-latest", "dws report +report-latest --keyword 周报", + ), + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}}, + Tips: []string{"dws report +report-latest", "dws report +report-latest --keyword 周报"}, + Execute: func(rt *shortcut.RuntimeContext) error { + const listOperation = "report/get_send_report_list" + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + params := map[string]any{ + "cursor": 0, "size": 20, + "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), + } + if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" { + params["report_template_name"] = keyword + } + data, err := rt.CallMCPData("report", "get_send_report_list", params) + if err != nil { + return err + } + entries, page, err := reportProjectEntries(data, listOperation) + if err != nil { + return err + } + if page.HasMore { + return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志") + } + if len(entries) == 0 { + return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture")) + } + latestID, err := reportLatestEntryID(entries, listOperation) + if err != nil { + return err + } + const detailOperation = "report/get_report_entry_details" + detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID}) + if err != nil { + return err + } + detail, err := reportProjectDetail(detailData, detailOperation, latestID) + if err != nil { + return err + } + return rt.Output(map[string]any{"report": detail}) + }, +} + +func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { + var latestID string + var latestTime int64 + for index, entry := range entries { + created, ok := entry["createTime"].(int64) + if !ok || created <= 0 { + return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新") + } + id, ok := entry["reportId"].(string) + if !ok || strings.TrimSpace(id) == "" { + return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId") + } + if index == 0 || created > latestTime { + latestID, latestTime = id, created + } + } + return latestID, nil +} + +func init() { + shortcut.Register(ReportLatest) +} diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 28aa7938..d527597c 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -1,103 +1,126 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package report declares declarative shortcuts for the DingTalk -// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a -// single MCP tool call; tool names and parameter keys mirror the DWS report -// helper (internal/helpers/report.go) verbatim. +// Package report registers strict declarative shortcuts for DingTalk reports. package report import ( "fmt" + "strconv" "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" ) -// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00) -// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior. -func reportParseISOMillis(name, s string) (int64, error) { - s = strings.TrimSpace(s) - t, err := time.Parse(time.RFC3339, s) +const ( + reportMaximumInboxDays = 180 + reportMaximumOutboxDays = 20 +) + +func reportParseISOMillis(name, value string) (int64, error) { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { - return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err) + return 0, apperrors.NewValidation( + fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name), + apperrors.WithReason("invalid_time_format"), + ) } - return t.UnixMilli(), nil + return parsed.UnixMilli(), nil +} + +func reportValidatePage(cursor, size int) error { + if cursor < 0 { + return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor")) + } + if size < 1 || size > 20 { + return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size")) + } + return nil +} + +func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) { + start, err := reportParseISOMillis(startName, startValue) + if err != nil { + return 0, 0, err + } + end, err := reportParseISOMillis(endName, endValue) + if err != nil { + return 0, 0, err + } + if end <= start { + return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range")) + } + if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) { + return 0, 0, apperrors.NewValidation( + fmt.Sprintf("时间范围不能超过 %d 天", maximumDays), + apperrors.WithReason("time_range_too_large"), + ) + } + return start, end, nil +} + +func reportValidateContinuation(page reportPageEvidence, current int, operation string) error { + if !page.HasMore { + return nil + } + next, err := strconv.ParseInt(page.Next, 10, 64) + if err != nil || next <= int64(current) { + return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进") + } + return nil } -// TemplateList lists the report templates available to the current user. -// TemplateGet reads a single report template's field definitions by name. -// InboxList lists reports received by the current user within a time window. var InboxList = shortcut.Shortcut{ - Service: "report", - Command: "+inbox-list", - Product: "report", - Description: "列出我收到的日报(按时间范围分页)", - Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_inbox_list", - CanonicalPath: "report.shortcut_inbox_list", - CLIPath: "report +inbox-list", - PrimaryCLIPath: "report +inbox-list", + Service: "report", Command: "+inbox-list", Product: "report", + Description: "列出我收到的日志", + Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+inbox-list", "列出我收到的日志", + "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, - 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{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"}, - }, - }, + "dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true}, - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"}, }, - Tips: []string{ - `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - startMs, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { return err } - endMs, err := reportParseISOMillis("end", rt.Str("end")) + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_received_report_list" + start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) if err != nil { return err } params := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + "startTime": start, "endTime": end, + "cursor": rt.Int("cursor"), "size": rt.Int("size"), } if rt.Changed("sender-user-ids") { params["senderUserIds"] = rt.StrSlice("sender-user-ids") @@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{ if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// reportEntryListProject reshapes the raw report-list responses -// (get_received_report_list / get_send_report_list) into a clean, stable list -// of report summaries — the output-projection fidelity the framework applies to -// every list command. Both the list container and the per-item field names are -// probed defensively across candidate keys so the projection tolerates -// response-shape drift and never fabricates data: an empty/unknown shape yields -// an empty list rather than a crash. -func reportEntryListProject(data map[string]any) []map[string]any { - raw := reportEntryListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok { - row["reportId"] = v - } - if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok { - row["templateName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok { - row["creatorName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok { - row["creatorUserId"] = v - } - if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok { - row["createTime"] = v - } - if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok { - row["modifiedTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// reportEntryListResolveList locates the list payload inside the response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func reportEntryListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // get_received_report_list / get_send_report_list nest the list under - // result.report_list (snake_case); "report_list" MUST be probed — the - // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently - // empty despite the backend returning reports. - for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} { - v, ok := data[key] - 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", "report_list", "reportList", "records", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// reportEntryListFirst returns the first present candidate key's value. -func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// OutboxList lists reports sent/created by the current user. var OutboxList = shortcut.Shortcut{ - Service: "report", - Command: "+outbox-list", - Product: "report", - Description: "列出我发出的日报(可选时间/模版名过滤)", - Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_outbox_list", - CanonicalPath: "report.shortcut_outbox_list", - CLIPath: "report +outbox-list", - PrimaryCLIPath: "report +outbox-list", + Service: "report", Command: "+outbox-list", Product: "report", + Description: "列出我发出的日志", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+outbox-list", "列出我发出的日志", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "modified-start", Property: "modifiedStartTime"}, {Name: "modified-end", Property: "modifiedEndTime"}, + {Name: "template-name", Property: "report_template_name"}, }, - 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{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws report +outbox-list --cursor 0 --size 20", - "dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"", - }, - }, - }, + "dws report +outbox-list --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"}, - {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"}, - {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"}, - {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"}, }, - Tips: []string{ - `dws report +outbox-list --cursor 0 --size 20`, - `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + Tips: []string{"dws report +outbox-list --cursor 0 --size 20"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { + return err } - if rt.Changed("start") { - ms, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + if rt.Changed("start") && rt.Changed("end") { + if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil { return err } - params["startTime"] = ms } - if rt.Changed("end") { - ms, err := reportParseISOMillis("end", rt.Str("end")) - if err != nil { - return err - } - params["endTime"] = ms + if rt.Changed("modified-start") != rt.Changed("modified-end") { + return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range")) } if rt.Changed("modified-start") { - ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start")) - if err != nil { - return err - } - params["modifiedStartTime"] = ms + _, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + return err } - if rt.Changed("modified-end") { - ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end")) - if err != nil { - return err - } - params["modifiedEndTime"] = ms + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_send_report_list" + now := time.Now() + defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339) + endValue := defaultEnd.Format(time.RFC3339) + if rt.Changed("start") { + startValue = rt.Str("start") } - if rt.Changed("template-name") { - params["report_template_name"] = rt.Str("template-name") + if rt.Changed("end") { + endValue = rt.Str("end") + } + start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays) + if err != nil { + return err + } + params := map[string]any{ + "cursor": rt.Int("cursor"), "size": rt.Int("size"), + "startTime": start, "endTime": end, + } + if rt.Changed("modified-start") { + modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + if rangeErr != nil { + return rangeErr + } + params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd + } + if name := strings.TrimSpace(rt.Str("template-name")); name != "" { + params["report_template_name"] = name } data, err := rt.CallMCPData("report", "get_send_report_list", params) if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// EntryGet reads the full body of a single report by id. -// EntryStats reads the read-receipt statistics of a single report. -// EntrySubmit submits a new report against a template. +var TemplateSearch = shortcut.Shortcut{ + Service: "report", Command: "+template-search", Product: "report", + Description: "按名称搜索可用日志模板", + Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+template-search", "按名称搜索可用日志模板", + "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + reportTemplateSearchResult(), nil, + []contract.ParamDecl{{Name: "query", Property: "report_template_name"}}, + "dws report +template-search --query 周报", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}}, + Tips: []string{"dws report +template-search --query 周报"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query")) + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_available_report_templates" + data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{}) + if err != nil { + return err + } + templates, err := reportProjectTemplates(data, operation) + if err != nil { + return err + } + query := strings.ToLower(strings.TrimSpace(rt.Str("query"))) + matches := make([]map[string]any, 0) + for _, template := range templates { + if strings.Contains(strings.ToLower(template["name"].(string)), query) { + matches = append(matches, template) + } + } + payload := map[string]any{"count": len(matches), "templates": matches} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(matches))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) + }, +} + func init() { - shortcut.Register( - InboxList, - OutboxList, - ) + shortcut.Register(InboxList, OutboxList, TemplateSearch) } diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go deleted file mode 100644 index faa8c95e..00000000 --- a/internal/shortcut/report/report_list_projection_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package report - -import ( - "encoding/json" - "testing" -) - -// TestReportEntryListResolveListSnakeShape guards against projection-data-loss: -// get_received_report_list / get_send_report_list nest the list under -// result.report_list (snake_case); the resolver must probe "report_list" or -// +inbox-list / +outbox-list silently return empty despite the backend having -// reports. -func TestReportEntryListResolveListSnakeShape(t *testing.T) { - const raw = `{"result":{"report_list":[ - {"reportId":"r1","templateName":"daily report"}, - {"reportId":"r2","templateName":"weekly report"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := reportEntryListResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got)) - } -} diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..1c4a09be --- /dev/null +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -0,0 +1,309 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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 reportCoverageCall struct { + tool string + args map[string]any +} + +type reportCoverageCaller struct { + responses map[string][]string + history []reportCoverageCall +} + +func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing Report fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*reportCoverageCaller) Format() string { return "json" } +func (*reportCoverageCaller) DryRun() bool { return false } +func (*reportCoverageCaller) Fields() string { return "" } +func (*reportCoverageCaller) JQ() string { return "" } + +func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { + declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} + for _, declaration := range declarations { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract/Result", declaration.Command) + } + if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil { + t.Fatal("Report list shortcuts must publish cursor pagination") + } + if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil { + t.Fatal("non-paginated Report shortcuts published pagination") + } +} + +func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}}, + }, + "hasMore": false, + } + entries, page, err := reportProjectEntries(valid, "report/get_received_report_list") + if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) { + t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil} + if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore { + t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err) + } + terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)} + if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" { + t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err) + } + fixtures := map[string]map[string]any{ + "empty response": map[string]any{}, + "missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "missing result": map[string]any{"success": true, "hasMore": false}, + "wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false}, + "missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false}, + "wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false}, + "bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false}, + "empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false}, + "missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false}, + "wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false}, + "duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false}, + "wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false}, + "missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}}, + "wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"}, + "empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)}, + "missing continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, + }, + "wrong continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2", + }, + "wrong terminal cursor": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2", + }, + "conflicting has more": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false, + }, + "conflicting continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2), + }, + } + for name, fixture := range fixtures { + if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil { + t.Fatal("stalled continuation returned success") + } +} + +func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "items": []any{ + map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)}, + map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"}, + }} + templates, err := reportProjectTemplates(valid, "report/get_available_report_templates") + if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) { + t.Fatalf("valid templates=%#v err=%v", templates, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"items": []any{}}, + "wrong success": {"success": float64(1), "items": []any{}}, + "missing collection": {"success": true}, + "wrong collection": {"success": true, "items": map[string]any{}}, + "bad item": {"success": true, "items": []any{"bad"}}, + "missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}}, + "missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}}, + "wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}}, + "duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}}, + "wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}}, + } { + if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 { + t.Fatalf("explicit empty template collection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "report_Id": "report-2", "report_name": "fixture", "createTime": float64(2), + "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}}, + }} + detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2") + if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("valid detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing result": {"success": true}, + "empty result": {"success": true, "result": map[string]any{}}, + "missing id": {"success": true, "result": map[string]any{"report_content": []any{}}}, + "identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}}, + "wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}}, + "bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}}, + "missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}}, + "wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) { + templateCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`}, + }} + cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 { + t.Fatalf("template search data=%#v", data) + } + if _, leaked := data["items"]; leaked { + t.Fatalf("template search leaked raw collection: %#v", data) + } + + inboxCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`}, + }} + cmd, err = runReportCoverage(t, InboxList, inboxCaller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + stdout.Reset() + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr) + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("inbox data=%#v", data) + } +} + +func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) { + missingOrder := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 { + t.Fatalf("missing order err=%v history=%v", err, missingOrder.history) + } + + incomplete := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) + } + + caller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, + }} + cmd, err := runReportCoverage(t, ReportLatest, caller) + if err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { + t.Fatalf("exact call history=%#v", caller.history) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("latest emit=(%t,%v)", emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + projected := envelope["data"].(map[string]any)["report"].(map[string]any) + _, leaked := projected["success"] + if projected["reportId"] != "report-2" || leaked { + t.Fatalf("latest projection=%s", stdout.String()) + } +} + +func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) { + tests := []struct { + declaration shortcut.Shortcut + args []string + }{ + {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, + {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + } + for _, test := range tests { + caller := &reportCoverageCaller{responses: map[string][]string{}} + if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 { + t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history) + } + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 535a3f0d..2542142f 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -46,6 +46,9 @@ var oaSemanticCatalogJSON []byte //go:embed semantic_catalog_ding.json var dingSemanticCatalogJSON []byte +//go:embed semantic_catalog_report.json +var reportSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -77,6 +80,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( mailSemanticCatalogJSON, oaSemanticCatalogJSON, dingSemanticCatalogJSON, + reportSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json new file mode 100644 index 00000000..b1f6c45f --- /dev/null +++ b/internal/shortcut/semantic_catalog_report.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "service": "report", + "default_availability": "unavailable", + "shortcuts": { + "+inbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+outbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、项目身份和分页终止证据;exact Shortcut 只证明多个时间窗口的零项结果,缺少已知非空 fixture,不能发布。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+template-search": { + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+report-latest": { + "disposition": "primary_smart", + "semantic_delta": "完整验证最近 20 天发件箱后按每项 createTime 选择最新稳定 reportId,再精确读回身份匹配的详情;当前账号没有非空发件箱 fixture,无法证明完整两步链路,保持不可用。", + "risk": "read", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go deleted file mode 100644 index c756573a..00000000 --- a/internal/shortcut/smart/report_latest.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报) -// in one step (DingTalk-native). -// -// Steps: -// -// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/ -// endTime mirror helpers.runReportSent — the server caps a single query span -// at 20 days, so we default to the last-20-days window just like the helper. -// An optional --keyword is passed through as report_template_name (the same -// filter the helper exposes as --template-name). -// -// 2. locate the newest entry: the item with the largest create time -// (createTime/gmtCreate/sendTime), falling back to the first item that -// carries a reportId (lists come back newest-first). -// -// 3. print that report's body via get_report_entry_details (report_id param -// mirrors helpers.runReportDetail). If nothing carries a reportId we fall -// back to printing the picked list row via rt.Output. -// -// If I have not sent any report in the window it reports "暂无日志" instead of -// failing obscurely. -// -// dws report +report-latest -// dws report +report-latest --keyword 日报 -var ReportLatest = shortcut.Shortcut{ - Service: "report", - Command: "+report-latest", - Product: "report", - Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)", - Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" + - "内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," + - "自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" + - "这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false}, - }, - Tips: []string{ - `dws report +report-latest`, - `dws report +report-latest --keyword 日报`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list the reports I sent. Mirror helpers.runReportSent: the - // server caps a single get_send_report_list query at a 20-day span, so - // default startTime/endTime to the last-20-days window. cursor/size are - // floats and report_template_name is the optional keyword filter. - now := time.Now() - startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli() - endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli() - - listArgs := map[string]any{ - "cursor": float64(0), - "size": float64(20), - "startTime": float64(startMs), - "endTime": float64(endMs), - } - if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" { - listArgs["report_template_name"] = kw - } - - data, err := rt.CallMCPData("report", "get_send_report_list", listArgs) - if err != nil { - return err - } - - // Step 2 — pick the newest sent report. - row, reportID := shortcutReportLatestPick(data) - if row == nil { - return apperrors.NewValidation("暂无日志") - } - - // Step 3 — print its detail (report_id mirrors helpers.runReportDetail). - // If we somehow could not read a reportId, fall back to the list row. - if reportID == "" { - return rt.Output(row) - } - return rt.CallMCP("get_report_entry_details", map[string]any{ - "report_id": reportID, - }) - }, -} - -// shortcutReportLatestPick walks a get_send_report_list response, finds the -// report entries, and returns the newest one (largest create time, falling back -// to the first entry that carries a reportId) together with its reportId. -// Returns (nil, "") when there are no entries. -func shortcutReportLatestPick(data map[string]any) (map[string]any, string) { - items := shortcutReportLatestItems(data) - if len(items) == 0 { - return nil, "" - } - - var firstItem map[string]any - firstID := "" - var bestItem map[string]any - bestID := "" - var bestTime int64 - haveTime := false - - for _, m := range items { - id := shortcutReportLatestID(m) - if id == "" { - continue - } - if firstItem == nil { - firstItem = m - firstID = id - } - if t, ok := shortcutReportLatestCreateMillis(m); ok { - if !haveTime || t > bestTime { - haveTime = true - bestTime = t - bestItem = m - bestID = id - } - } - } - - if haveTime && bestItem != nil { - return bestItem, bestID - } - if firstItem != nil { - return firstItem, firstID - } - // No entry carried a reportId; still surface the first raw item so the - // caller can print something rather than mis-report "暂无日志". - return items[0], "" -} - -// shortcutReportLatestItems pulls the report entries out of a -// get_send_report_list response, probing the same container keys the helper's -// findReportListItems uses (list/items/data/reports/records/report_list/ -// reportList nested under the top level or under result). -func shortcutReportLatestItems(data map[string]any) []map[string]any { - keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - } - } - } - return nil -} - -func shortcutReportLatestToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in -// helpers.reportIDFromMap. -func shortcutReportLatestID(m map[string]any) string { - for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} { - if id, ok := m[key].(string); ok { - if id = strings.TrimSpace(id); id != "" { - return id - } - } - } - return "" -} - -// shortcutReportLatestCreateMillis reads an entry's create time in millis, -// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes. -func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) { - for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} { - switch v := m[key].(type) { - case float64: - if v > 0 { - return int64(v), true - } - case string: - if s := strings.TrimSpace(v); s != "" { - if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 { - return n, true - } - } - } - } - return 0, false -} - -func init() { - shortcut.Register(ReportLatest) -} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 7d4fa695..de3a1f2d 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -31,6 +31,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", ] From 91974ce981e358871557a03dfebf68905bbe6e11 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:43:31 +0800 Subject: [PATCH 228/267] docs(shortcut): close OA residual audit gaps --- docs/shortcut-downstream-requirements/oa.md | 26 +++++++++++++++++---- internal/shortcut/semantic_catalog_oa.json | 6 ++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index 3e5cdf11..aef56745 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -11,6 +11,21 @@ - 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 - 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 +### Residual-gap 分类 + +| Shortcut / 用户路线 | 最终分类 | 依据 | +|---|---|---| +| `+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms` | fully unlocked | exact 非空与保证零命中均通过;public | +| `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | +| `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | +| `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | +| `+pending` | unavailable | 隐藏兼容入口路由到同一受阻待办能力,没有独立安全 fixture | +| `+list-cc` | delivery hardened but downstream blocked | exact 与 raw 隔离到错型业务状态与 `null` 集合,不能解释为合法零项 | +| `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | +| 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | + +OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 4 条 fully unlocked,1 条兼容路由,3 条下游阻塞,2 条 unavailable。 + | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| | `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | @@ -20,8 +35,11 @@ | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | +| `+list-pending` fail-closed | PASS | exact 与 raw 均隔离到布尔成功、对象结果、`null` 集合;拒绝伪造空数组 | +| `+pending` fail-closed | PASS | 与主入口一致拒绝 `null` 集合;没有独立宽松回退 | +| `+list-cc` fail-closed | PASS | exact 与 raw 均拒绝错型业务状态及 `null` 集合 | -未列为 PASS 的能力没有安全的已知非空或可逆写 fixture,保持 unavailable;显式空结果不替代非空证明。 +未列为 fully unlocked 的能力没有安全的已知非空或可逆写 fixture,或下游响应合同本身不合法;`null` 集合不是显式空结果,不能替代合法零项证明。 ## Lark 1.0.87 用户任务映射 @@ -36,9 +54,9 @@ Lark approval 共 14 个任务;映射按用户结果而不是命令名判断 | instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | | instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | | instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | partial | 已办/已发起公开;待办/抄送/任务 unavailable | +| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起公开;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | | tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | -| tasks approve | `oa approval approve` / `+approve-by` | partial | Shortcut unavailable;原子写需用户确认 | +| tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | | tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | | tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | | tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | @@ -63,7 +81,7 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 ## `DS-oa-002`:可重复的审批读写 E2E fixture - 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 -- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。 +- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。当前 raw 待办集合为 `null`;抄送同时存在错型业务状态与 `null` 集合,二者都不是合法空页。 - 所需 fixture: - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 4cdb5696..b853853c 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -54,14 +54,14 @@ }, "+list-pending": { "disposition": "semantic_adapter", - "semantic_delta": "严格待办投影与分页已实现,但当前安全账号只有显式空结果,缺少已知非空待审批 fixture。", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", "risk": "read", "public": false, "reviewed": true }, "+pending": { "disposition": "alias_internal", - "semantic_delta": "历史首屏待办摘要缺少页码且当前没有已知非空安全 fixture;新调用应使用 +list-pending,当前保持不可用。", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均不可用。", "risk": "read", "primary": "+list-pending", "public": false, @@ -69,7 +69,7 @@ }, "+list-cc": { "disposition": "semantic_adapter", - "semantic_delta": "严格抄送列表与审核过的空页终态已实现,但当前没有已知非空抄送审批 fixture,不能完成 exact Shortcut 非空证明。", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", "risk": "read", "public": false, "reviewed": true From 03001eb4e079584dbcd7542d7dc476f0ac5477fd Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:43:48 +0800 Subject: [PATCH 229/267] fix(shortcut): harden DING write routing evidence --- docs/shortcut-downstream-requirements/ding.md | 103 +++++++++--------- internal/helpers/ding.go | 22 +++- .../helpers/ding_personal_remind_type_test.go | 18 +++ internal/shortcut/ding/common.go | 4 +- internal/shortcut/ding/ding.go | 6 +- internal/shortcut/semantic_catalog_ding.json | 6 +- 6 files changed, 97 insertions(+), 62 deletions(-) create mode 100644 internal/helpers/ding_personal_remind_type_test.go diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index 0d35fa99..4c66a53c 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -5,74 +5,73 @@ > 对比基线:lark-cli 1.0.87 > 范围:Shortcut-only DING surface -## 1. 执行摘要 +## 1. 最终 surface - 源码 Shortcut:5;公开:1;unavailable:4。 -- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。 -- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。 -- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。 -- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。 -- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。 +- 公开 `+receiver-status` fully unlocked;其余 4 条保持 fail-closed,不能把 DING 整体写效果称为完成。 +- 读取严格化:PASS。只接受显式布尔成功、准确结果路径、正确集合类型、合法元素和稳定 DING 身份。 +- 本地可修缺口:PASS。`app/sms/call` 已在原子 helper 映射为下游要求的 `APP/SMS/PHONE`,未知类型在调用前拒绝。 +- 写门禁:PASS。3 个写 Shortcut 均需用户确认,且当前 capability gate 不发起远端调用;隔离诊断只通过精确 atomic/raw 路径执行。 -## 2. 用户任务与 Lark 映射 +## 2. Residual-gap 分类 -| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 | +| 能力 | 最终分类 | 归因 | Shortcut 状态 | |---|---|---|---| -| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 | -| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 | -| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable | -| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable | -| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable | -| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | -| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | +| `+receiver-status` | fully unlocked | exact 稳定 DING 身份读取返回 1 项且全部匹配 | public | +| `+list` | delivery hardened but downstream blocked | 真实 continuation 不前进并返回重复边界项;本地不能安全猜测跳游标或去重 | unavailable | +| `+send-personal --type app` | delivery hardened but downstream blocked | 稳定 DING 回执已证明;接收项没有稳定接收人身份,撤回无可查询终态 | unavailable | +| `+send-by-message --type app` | delivery hardened but downstream blocked | 源消息和 DING 稳定身份已证明;接收人与清理终态仍不可证明 | unavailable | +| `+send-personal/+send-by-message --type sms/call` | unavailable | 没有隔离号码、显式成本授权和通知残留核验 fixture,本轮未执行 | unavailable | +| `+recall-personal` | delivery hardened but downstream blocked | 撤回只返回布尔成功,不返回目标或终态;撤回后精确接收状态仍保留记录 | unavailable | +| Lark `urgent_app/sms/phone` 用户任务 | routed | 精确原子命令存在;仅在用户明确目标、提醒方式与成本后执行,不等于 Shortcut 已解锁 | 非公开 Shortcut 路线 | -## 3. 真实 E2E 聚合矩阵 +## 3. Lark 用户任务映射 -| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 | -|---|---:|---:|---|---|---|---| -| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable | -| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public | -| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | -| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | -| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| 用户任务 | DWS 路线 | lark-cli 1.0.87 | 结论 | +|---|---|---|---| +| 查询 DING 历史 | `ding +list` | 无直接任务 | delivery hardened but downstream blocked | +| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | fully unlocked;平台身份模型不同 | +| 基于消息发送应用内加急 | `ding message send-by-message --type app` | `im messages urgent_app` | routed;Shortcut unavailable | +| 基于消息发送短信加急 | `ding message send-by-message --type sms` | `im messages urgent_sms` | routed,需成本授权;Shortcut unavailable | +| 基于消息发送电话加急 | `ding message send-by-message --type call` | `im messages urgent_phone` | routed,需成本授权;Shortcut unavailable | +| 新建个人 DING | `ding message send-personal` | 无直接任务 | routed;Shortcut unavailable | +| 撤回个人 DING | `ding message recall-personal` | 无直接任务 | routed;Shortcut unavailable | -## 4. 下游需求 +## 4. Exact Shortcut 与隔离 raw 证据 -### DS-DING-001 — 稳定写回执与接收人身份 +| 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | +|---|---|---|---| +| `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | +| `+receiver-status` | 精确身份返回 1,全部 DING 身份匹配 | 集合为数组、业务成功为布尔值 | fully unlocked | +| `+send-personal` | capability gate,远端调用 0 | APP 自收件:稳定 DING 回执 1、接收状态 1、DING 身份匹配;稳定接收人身份字段 0 | downstream blocked | +| `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | +| `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | + +隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 + +## 5. 下游需求 + +### `DS-DING-001` — 稳定接收人身份读回 - 优先级:P1;类型:contract insufficient。 -- 影响:`+send-personal`、`+send-by-message`。 -- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。 -- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。 -- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。 -- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。 +- 已关闭部分:`PASS-DING-WRITE-RECEIPT`,两种 APP 发送路径均返回唯一稳定 `openDingId`;提醒类型枚举映射已在本地修正。 +- 剩余缺口:接收状态项只有 DING 身份、确认状态和显示名,没有可与请求 `openDingTalkId` 集合精确比对的稳定接收人身份。 +- 验收:返回逐接收人的稳定身份和 typed 受理状态;多接收人集合与请求集合精确一致;缺项、重复项、额外项和错型均机器可检测。 -### DS-DING-002 — 可验证撤回终态 +### `DS-DING-002` — 可查询撤回终态 - 优先级:P1;类型:missing capability。 -- 影响:`+recall-personal` 及两种发送能力的清理闭环。 -- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。 -- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。 -- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。 +- 当前事实:撤回响应不含目标身份或终态;精确接收状态在撤回后仍保留;列表首屏缺席不能越过坏分页证明全局终态。 +- 验收:按同一 `openDingId` 返回 typed `recalled` 终态或提供专用只读查询;重复撤回语义稳定;能够区分完成、处理中、部分撤回和 commit-unknown,并证明无残留通知。 -### DS-DING-003 — 游标边界语义 +### `DS-DING-003` — 游标边界与前进语义 - 优先级:P2;类型:contract insufficient。 -- 影响:`+list` 的跨页聚合调用方。 -- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。 -- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。 +- 当前事实:首屏 50 项;真实 continuation 再返回 50 项,下一游标不前进,跨页身份交集 1。对包含式边界加一仍返回不前进页面,排除安全的本地 `+1` 修补。 +- 验收:连续页凭据严格前进;跨页无重复,或合同明确包含式边界且可证明去重不遗漏;终页明确 `hasMore=false`。 -## 5. Fixture、权限与清理 +## 6. Fixture、安全与脱敏 -| 需求 | 安全资源模型 | 到期/回收 | 状态 | -|---|---|---|---| -| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED | -| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED | -| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED | -| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS | - -## 6. 安全与脱敏声明 - -- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。 -- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 -- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。 +- PASS:APP 仅使用当前账号自收件、合成无业务正文与立即撤回;没有执行短信或电话提醒。 +- PASS:没有提交用户、组织、会话、消息、DING 的真实 ID,也没有标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 +- PASS:文档只保留命令级标签与聚合计数;DING 写 Shortcut 仍为 unavailable,未把原子诊断回执当成公开能力完成。 diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index 79cda622..c3db7200 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -17,6 +17,16 @@ import ( // remindType: 服务端 API 1=应用内 2=短信 3=电话 var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3} +var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"} + +func dingPersonalRemindType(value string) (string, error) { + remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return "", fmt.Errorf("--type must be one of app, sms, call") + } + return remindType, nil +} + func newDingCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/ding.json // products.ding). Catalog assembly stamps provenance contract_final. @@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "users", "content"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "receiverOpenDingTalkIds": users, "content": mustGetFlag(cmd, "content"), - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v @@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), "openMessageId": mustGetFlag(cmd, "message-id"), "receiverOpenDingTalkIds": users, - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v diff --git a/internal/helpers/ding_personal_remind_type_test.go b/internal/helpers/ding_personal_remind_type_test.go new file mode 100644 index 00000000..b94a323d --- /dev/null +++ b/internal/helpers/ding_personal_remind_type_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import "testing" + +func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) { + for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want) + } + } + if got, err := dingPersonalRemindType("push"); err == nil || got != "" { + t.Fatalf("unsupported remind type=(%q,%v)", got, err) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go index 719b15eb..bb45a46c 100644 --- a/internal/shortcut/ding/common.go +++ b/internal/shortcut/ding/common.go @@ -19,7 +19,7 @@ import ( const ( dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." - dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." ) type dingPageEvidence struct { @@ -304,7 +304,7 @@ func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page } func dingUnavailable(operation string) error { - return apperrors.NewDiscovery("该 DING 写 Shortcut 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行", + return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行", apperrors.WithOperation(operation), apperrors.WithOrigin("shortcut_registry"), apperrors.WithFailureStage("capability_gate"), diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 1698cdde..196250cd 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -100,7 +100,7 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = unavailableDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", - "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。", + "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, @@ -117,7 +117,7 @@ var SendPersonal = unavailableDingWrite( var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", - "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但安全 fixture 与精确清理闭环未具备。", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, @@ -136,7 +136,7 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = unavailableDingWrite( "+recall-personal", "撤回本人发起的 DING", - "需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。", + "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index 46f34233..cfcea69d 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -20,21 +20,21 @@ }, "+send-personal": { "disposition": "semantic_adapter", - "semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。", + "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", "risk": "write", "public": false, "reviewed": true }, "+send-by-message": { "disposition": "semantic_adapter", - "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。", "risk": "write", "public": false, "reviewed": true }, "+recall-personal": { "disposition": "semantic_adapter", - "semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。", + "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", "risk": "high-risk-write", "public": false, "reviewed": true From 7bc56f3dea69ea422ef43ed917ebcea4a3879fc8 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:44:05 +0800 Subject: [PATCH 230/267] feat(shortcut): unlock Report outbox workflows --- .../report.md | 113 +++++++----------- .../builtin/report_semantic_catalog_test.go | 4 +- internal/shortcut/report/latest.go | 46 ++++++- internal/shortcut/report/report.go | 4 +- ...ort_strict_cross_platform_coverage_test.go | 15 ++- .../shortcut/semantic_catalog_report.json | 10 +- 6 files changed, 110 insertions(+), 82 deletions(-) diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 8456eb4b..51a6cc80 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -5,89 +5,68 @@ > 对比基线:lark-cli 1.0.87(无 Report 产品域) > 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 -## 1. 执行摘要 +## 1. 最终 surface -- 源码 Shortcut 共 4 个;公开 2 个,unavailable 2 个。 -- `+template-search` 已完成严格模板集合、已知非空和保证零命中证明,可公开。 -- `+inbox-list` 已完成已知非空、保证零项、多页 cursor 严格前进、相邻页稳定身份无重复和明确耗尽证明,可公开。 -- `+outbox-list` 在多个有界窗口只得到合法零项结果;缺少已知非空证明,因此保持 unavailable。 -- `+report-latest` 依赖完整非空发件箱和精确详情读回;当前没有安全 fixture,因此保持 unavailable,且不再回退原始列表行。 -- Report 原子层具有模板、收件箱、发件箱、详情、统计和创建能力;创建缺少可验证删除/回收能力,本轮未执行写操作。 +- 源码 Shortcut:4;公开:4;unavailable:0。 +- `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 +- 30 个连续 19 天窗口覆盖 570 天发件箱历史:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 +- Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 -| ID | 优先级 | 类型 | 用户任务 | 当前处置 | 解锁的 Shortcut | -|---|---|---|---|---|---| -| `DS-Report-001` | P1 | tenant-or-fixture / missing capability | 读取自己发出的日志及最新详情 | hidden + unavailable | `+outbox-list`、`+report-latest` | +## 2. Residual-gap 分类 -## 2. 用户任务与证据矩阵 +| 能力 | 最终分类 | PASS 证据 | 公开状态 | +|---|---|---|---| +| `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | +| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:20、20、20、20、12 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | +| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:30 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | +| `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | +| 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | +| 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | +| Report Shortcut residual unavailable | unavailable | `PASS-RPT-SURFACE-CLOSURE`:源码 4 条中为 0 | 无 | -| 用户任务 | Shortcut | Lark CLI 对应 | Exact Shortcut 证据 | Atomic/raw 对照 | 结论 | -|---|---|---|---|---|---| -| 按名称搜索可用日志模板 | `+template-search` | 无 | `PASS-RPT-TEMPLATE-NONEMPTY`:1 项;`PASS-RPT-TEMPLATE-ZERO`:0 项 | `PASS-RPT-TEMPLATE-COLLECTION`:完整集合聚合数量 73 | available / public | -| 分页查看收到的日志 | `+inbox-list` | 无 | `PASS-RPT-INBOX-PAGES`:页大小依次为 20、20、20、20、12,随后明确耗尽;`PASS-RPT-INBOX-ZERO`:0 项 | `PASS-RPT-INBOX-CURSOR`:非终止 cursor 严格递增;首对相邻页稳定身份交集为 0 | available / public | -| 分页查看自己发出的日志 | `+outbox-list` | 无 | `PASS-RPT-OUTBOX-ZERO`:多个有界窗口均为 0 项 | `PASS-RPT-OUTBOX-TERMINAL`:零项页具有明确成功与终止证据 | unavailable | -| 读取自己最新提交的日志详情 | `+report-latest` | 无 | `PASS-RPT-LATEST-ZERO`:发件箱为空时在一次列表调用后停止 | `PASS-RPT-DETAIL-SHAPE`:独立详情能力可返回稳定身份与严格字段数组,但没有安全发件箱目标可串联 | unavailable | +Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked” 项。未来若增加写 Shortcut,仍需先解决下文的写 fixture 生命周期,不得把四条读取已解锁解释为 Report 写能力也已完成。 -## 3. 已验证分页合同 +## 3. Exact Shortcut E2E 矩阵 -- `PASS-RPT-INBOX-CURSOR`:所有非终止页均返回正整数、相对请求严格前进的 continuation。 -- `PASS-RPT-INBOX-PAGES`:exact Shortcut 沿服务端返回 cursor 读取 5 个非空页,聚合数量 92,末页明确 `hasMore=false`。 -- `PASS-RPT-INBOX-OVERLAP`:首对相邻非空页的稳定身份交集为 0。 -- `PASS-RPT-INBOX-ZERO`:独立有界查询返回合法 0 项、`complete=true` 与 endpoint exhausted。 -- 服务端在终止页仍回显整数 cursor;DWS 只把它当页面收据,不发布 `next_token`。错型或冲突 cursor 仍失败。 -- DWS 严格拒绝空响应、缺/错型 `success`、缺/错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。 +| Exact Shortcut | 已知非空 | 保证零项/零命中 | 身份与分页 | 结论 | +|---|---:|---:|---|---| +| `+template-search` | PASS(1) | PASS(0) | 完整模板集合验证后本地筛选;稳定模板身份 | fully unlocked | +| `+inbox-list` | PASS(累计 92) | PASS(0) | 5 个非空页;cursor 严格前进;首对相邻页身份交集 0;终页明确耗尽 | fully unlocked | +| `+outbox-list` | PASS(1) | PASS(0) | 非空页 `complete=true` 且 endpoint exhausted;570 天有界扫描合同异常 0 | fully unlocked | +| `+report-latest` | PASS(候选 1) | 不适用:精确组合读取 | 显式不超过 20 天窗口;按唯一最高创建时间选取;详情身份精确匹配;字段集合 3 | fully unlocked | -## 4. 下游需求明细 +所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 -### `DS-Report-001` — 提供可回收的发件箱 E2E fixture +## 4. 已关闭需求 -#### 用户结果 +### `DS-Report-001` — 非空发件箱与最新详情链路 -用户能够可靠列出自己发出的日志,并在完整候选集合中选出最新一篇,再按稳定 `reportId` 读取身份一致的详情。 +- 状态:`PASS-RPT-RESIDUAL-CLOSED`。 +- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。扩大为连续 570 天有界 exact 扫描后发现 1 个非空窗口。 +- 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 +- 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 -#### 当前证据与归因 +## 5. 未解决但不阻塞当前 Shortcut 的下游缺口 -- `PASS-RPT-OUTBOX-ZERO`:多个独立有界窗口均为合法 0 项;这只能证明零结果合同,不能证明非空项目投影。 -- `PASS-RPT-LATEST-ZERO`:组合命令遇到空发件箱后停止,没有调用详情,也没有把空结果伪装成成功详情。 -- `PASS-RPT-DETAIL-SHAPE`:在独立安全读取中,详情结果具备稳定身份、时间字段与非空字段集合;统计读取也具备明确成功对象。 -- 原子层可以创建日志,但没有对应删除/回收能力。仅凭创建成功回执不能满足写后精确读回、清理和零残留要求,因此本轮没有创建测试数据。 +### `DS-Report-002` — Report 写 fixture 生命周期 -#### 所需 fixture / 能力 +- 分类:routed;影响未来日志提交类 Shortcut,不影响当前四条只读 Shortcut。 +- 当前事实:原子层可提交日志,但没有按稳定回执删除、回收或证明自动过期的能力;因此本轮写调用数为 0。 +- 解锁条件:专用无业务内容 fixture,确认前 0 写调用,确认后稳定创建回执、精确读回、无条件清理,以及发件箱和详情的零残留证明。 -- 首选:租户提供专用、无业务内容、定期自动过期的已发送日志 fixture,并保证测试身份可读取。 -- 或者:新增精确删除/回收能力,使测试可按创建回执中的稳定身份读回并在 `defer` 清理,最后证明发件箱与详情均无残留。 -- 非空发件箱响应必须包含布尔 `success=true`、精确集合、每项稳定 `reportId`、正整数 `createTime` 和真实分页终止证据。 -- 详情响应必须返回与请求完全一致的稳定身份;字段集合必须为数组,坏元素不能被跳过。 -- 若创建执行结果不确定,必须返回 commit-unknown 分类与安全的核查动作,不能建议盲目重试。 +## 6. Lark 对齐 -#### 验收标准 +lark-cli 1.0.87 没有 Report/日志产品域,四条公开 Shortcut 都是 DWS 原生增量能力,不虚构一对一映射: -1. exact `+outbox-list` 同时证明已知非空与保证零项。 -2. 非空项目均有稳定身份与排序时间,分页遍历至终止且无重复。 -3. exact `+report-latest` 只调用一次完整列表和一次精确详情,详情身份与所选目标一致。 -4. 若用创建产生 fixture:确认前 0 次写调用;确认后稳定回执;按回执精确读回;无条件清理;最终证明零残留。 -5. 没有可验证清理能力时,不执行创建,并继续保持相关 Shortcut unavailable。 - -## 5. Lark 对齐与平台差异 - -lark-cli 1.0.87 没有 Report/日志产品域,因此 4 个 Report Shortcut 均无一对一 Lark 任务。它们是 DWS 的平台原生增量能力,不应虚构映射: - -| DWS 用户任务 | Lark 映射 | 推荐结论 | +| DWS 用户任务 | Lark 映射 | 结论 | |---|---|---| -| 搜索日志模板 | 无 | DWS 原生能力;公开 `+template-search` | -| 查看收到的日志 | 无 | DWS 原生能力;公开 `+inbox-list` | -| 查看发出的日志或最新详情 | 无 | DWS 原生能力;非空链路验证前保持 unavailable | +| 搜索日志模板 | 无 | DWS 原生;fully unlocked | +| 查看收到的日志 | 无 | DWS 原生;fully unlocked | +| 查看发出的日志 | 无 | DWS 原生;fully unlocked | +| 读取指定窗口内最新日志详情 | 无 | DWS 原生;fully unlocked | -## 6. 无需下游变更的上游修复 +## 7. 安全与脱敏 -| Shortcut | 已完成修复 | 回归证据 | -|---|---|---| -| 全部 4 个 | 统一 Result/Safety/Contract、unified output;空响应、缺/错型 success、缺集合、坏元素与重复身份全部失败 | `PASS-RPT-STRICT-MATRIX` | -| `+inbox-list`、`+outbox-list` | 发布真实 cursor pagination;缺失、冲突、错型和不前进 cursor 全部失败;终止页不发布回显 cursor | `PASS-RPT-PAGINATION-MATRIX` | -| `+template-search` | 严格验证完整模板集合后本地筛选;不从坏元素或未知形状制造空结果 | `PASS-RPT-TEMPLATE-MATRIX` | -| `+report-latest` | 删除原始列表行回退;仅接受完整候选集合、可排序时间和身份匹配详情 | `PASS-RPT-LATEST-READBACK` | - -## 7. 安全与脱敏声明 - -- 本文不包含真实 ID、标题、人名、邮箱、正文、原始响应、trace/request ID、token 或签名 URL。 -- 所有真实证据只以 `PASS-RPT-*` 标签和聚合数量表达;不得用这些标签反查或推断具体资源。 -- 写能力因缺少可验证清理合同未执行;不存在为了测试而遗留的 Report 数据。 +- PASS:本文不含真实 ID、标题、人名、邮箱、正文、精确业务时间、原始响应、trace/request ID、token 或签名 URL。 +- PASS:真实证据只保留命令级标签和聚合计数,无法反推出具体资源。 +- PASS:没有为测试创建 Report 数据,因此没有新增业务残留。 diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go index 06ece7a0..7e2e66de 100644 --- a/internal/shortcut/builtin/report_semantic_catalog_test.go +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurfac if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 2 || unavailable != 2 { - t.Fatalf("public/unavailable=%d/%d, want 2/2", public, unavailable) + if public != 4 || unavailable != 0 { + t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable) } } diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go index 86d6b644..0d49db9d 100644 --- a/internal/shortcut/report/latest.go +++ b/internal/shortcut/report/latest.go @@ -16,24 +16,51 @@ import ( var ReportLatest = shortcut.Shortcut{ Service: "report", Command: "+report-latest", Product: "report", Description: "读取我最近提交的一篇日志详情", - Intent: "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", Risk: shortcut.RiskRead, Safety: reportReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: reportContract( "+report-latest", "读取我最近提交的一篇日志详情", - "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", reportLatestResult(), nil, - []contract.ParamDecl{{Name: "keyword", Property: "report_template_name"}}, - "dws report +report-latest", "dws report +report-latest --keyword 周报", + []contract.ParamDecl{ + {Name: "keyword", Property: "report_template_name"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + }, + "dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`, ), - Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}}, - Tips: []string{"dws report +report-latest", "dws report +report-latest --keyword 周报"}, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;必须与 --end 同时提供,跨度不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;必须与 --start 同时提供,跨度不得超过 20 天"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("start") != rt.Changed("end") { + return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range")) + } + if rt.Changed("start") { + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + return err + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { const listOperation = "report/get_send_report_list" now := time.Now() end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + if rt.Changed("start") { + startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + if err != nil { + return err + } + start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis) + } params := map[string]any{ "cursor": 0, "size": 20, "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), @@ -75,6 +102,7 @@ var ReportLatest = shortcut.Shortcut{ func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { var latestID string var latestTime int64 + latestCount := 0 for index, entry := range entries { created, ok := entry["createTime"].(int64) if !ok || created <= 0 { @@ -86,8 +114,14 @@ func reportLatestEntryID(entries []map[string]any, operation string) (string, er } if index == 0 || created > latestTime { latestID, latestTime = id, created + latestCount = 1 + } else if created == latestTime { + latestCount++ } } + if latestCount != 1 { + return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志") + } return latestID, nil } diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index d527597c..7413db26 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -143,13 +143,13 @@ var InboxList = shortcut.Shortcut{ var OutboxList = shortcut.Shortcut{ Service: "report", Command: "+outbox-list", Product: "report", Description: "列出我发出的日志", - Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", Risk: shortcut.RiskRead, Safety: reportReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: reportContract( "+outbox-list", "列出我发出的日志", - "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), []contract.ParamDecl{ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go index 1c4a09be..ad5459ce 100644 --- a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -264,17 +264,28 @@ func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactRea t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) } + tied := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 { + t.Fatalf("tied latest err=%v history=%v", err, tied.history) + } + caller := &reportCoverageCaller{responses: map[string][]string{ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, }} - cmd, err := runReportCoverage(t, ReportLatest, caller) + cmd, err := runReportCoverage(t, ReportLatest, caller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00") if err != nil { t.Fatal(err) } if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { t.Fatalf("exact call history=%#v", caller.history) } + if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) { + t.Fatalf("explicit range args=%#v", caller.history[0].args) + } var stdout bytes.Buffer cmd.SetOut(&stdout) if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { @@ -299,6 +310,8 @@ func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *t {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, } for _, test := range tests { caller := &reportCoverageCaller{responses: map[string][]string{}} diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json index b1f6c45f..f80fd538 100644 --- a/internal/shortcut/semantic_catalog_report.json +++ b/internal/shortcut/semantic_catalog_report.json @@ -13,9 +13,10 @@ }, "+outbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证发件箱集合、项目身份和分页终止证据;exact Shortcut 只证明多个时间窗口的零项结果,缺少已知非空 fixture,不能发布。", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;exact Shortcut 已在连续有界历史窗口中同时证明已知非空与保证零项。", "risk": "read", - "public": false, + "availability": "available", + "public": true, "reviewed": true }, "+template-search": { @@ -28,9 +29,10 @@ }, "+report-latest": { "disposition": "primary_smart", - "semantic_delta": "完整验证最近 20 天发件箱后按每项 createTime 选择最新稳定 reportId,再精确读回身份匹配的详情;当前账号没有非空发件箱 fixture,无法证明完整两步链路,保持不可用。", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;要求唯一最高 createTime,按稳定 reportId 精确读回身份匹配且字段集合严格的详情。", "risk": "read", - "public": false, + "availability": "available", + "public": true, "reviewed": true } } From f6ad2fa01aa73bb5f519c9be53dcfe61e5ce7aaa Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:06:21 +0800 Subject: [PATCH 231/267] fix(shortcut): publish Report range constraints --- internal/shortcut/report/latest.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go index 0d49db9d..1d633c5b 100644 --- a/internal/shortcut/report/latest.go +++ b/internal/shortcut/report/latest.go @@ -32,8 +32,8 @@ var ReportLatest = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;必须与 --end 同时提供,跨度不得超过 20 天"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;必须与 --start 同时提供,跨度不得超过 20 天"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, From 2346dfba4e42660f832c5893b18e1cc5bad8b3f9 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:48:54 +0800 Subject: [PATCH 232/267] fix(shortcut): require OA zero-page pagination evidence --- docs/shortcut-downstream-requirements/oa.md | 37 ++++++++++++++----- .../builtin/oa_semantic_catalog_test.go | 4 +- internal/shortcut/oa/common.go | 11 +----- internal/shortcut/oa/oa.go | 2 +- .../oa_strict_cross_platform_coverage_test.go | 13 +++---- internal/shortcut/oa/smart.go | 6 +-- internal/shortcut/semantic_catalog_oa.json | 15 +++----- 7 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index aef56745..4fb493ef 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -7,15 +7,17 @@ ## 结论与 surface - 源码 Shortcut:10。 -- 公开:4(`+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms`)。 -- 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 +- 公开:1(`+search-forms`)。 +- 隐藏:9,其中 unavailable 8;`+done-approvals` 是 available 但隐藏的兼容入口。 - 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 ### Residual-gap 分类 | Shortcut / 用户路线 | 最终分类 | 依据 | |---|---|---| -| `+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms` | fully unlocked | exact 非空与保证零命中均通过;public | +| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 1、保证零命中 0,稳定身份集合一致;public | +| `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | +| `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | | `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | | `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | @@ -24,14 +26,22 @@ | `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | | 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | -OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 4 条 fully unlocked,1 条兼容路由,3 条下游阻塞,2 条 unavailable。 +OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unlocked,1 条兼容路由,6 条 delivery hardened but downstream blocked,2 条业务能力 unavailable;Catalog unavailable 共 8 条。 + +### Current HEAD 公开 Shortcut 双层发布矩阵 + +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | +|---|---|---|---|---| +| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 1/1,稳定 `processCode` 集合相等;随机 UUID 查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | + +该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| -| `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | -| `+list-submitted` | PASS | 已知非空 14;保证零命中 0;分页终态明确 | -| `+my-initiated` | PASS | 已知非空 14;保证零命中 0;不再回退原始响应 | -| `+search-forms` | PASS | 已知非空 2;保证零命中 0;显式完整结果 | +| `+list-executed` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+list-submitted` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+my-initiated` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | +| `+search-forms` | PASS | current HEAD 已知非空 1、保证零命中 0;exact/raw 稳定身份集合一致 | | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | @@ -49,12 +59,12 @@ Lark approval 共 14 个任务;映射按用户结果而不是命令名判断 |---|---|---|---| | approvals get | `oa approval form-schema` | routed | 使用原子读取 | | approvals search | `oa +search-forms` | covered | 公开 Shortcut | -| instances initiated | `oa +list-submitted` / `+my-initiated` | covered | 公开 Shortcut | +| instances initiated | `oa approval list-submitted` | routed | 两条 Shortcut 因 guaranteed-zero 缺分页证据而 unavailable;使用精确原子读取 | | instances get | `oa approval detail` | routed | 使用原子读取 | | instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | | instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | | instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起公开;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | +| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起非空可读,但零命中缺分页;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | | tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | | tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | | tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | @@ -103,6 +113,13 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 - 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 - 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 +## `DS-oa-004`:编号列表零命中的分页终态 + +- 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 +- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。current HEAD 的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。 +- 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 +- 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 + ## 超越 Lark 的机会 在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index a9836911..e5ae28f9 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 4 || unavailable != 5 { - t.Fatalf("public/unavailable=%d/%d, want 4/5", public, unavailable) + if public != 1 || unavailable != 8 { + t.Fatalf("public/unavailable=%d/%d, want 1/8", public, unavailable) } } diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index aae53aae..f51c11bd 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -347,17 +347,10 @@ func oaCursorPage(result map[string]any, operation string, current int) (oaPageE return page, nil } -func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { +func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) { raw, present := result["hasMore"] if !present { - // Reviewed OA zero-page encoding: get_done_tasks, - // get_submitted_instances, and get_noticed_instances omit hasMore only - // when result.values is an explicit empty array. No other missing - // pagination shape is accepted. - if itemCount == 0 { - return oaPageEvidence{Known: true}, nil - } - return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "非空审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") } hasMore, ok := raw.(bool) if !ok { diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 79bd12d6..13d3564e 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -41,7 +41,7 @@ func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string] if err != nil { return err } - evidence, err := oaHasMorePage(result, operation, len(instances), page) + evidence, err := oaHasMorePage(result, operation, page) if err != nil { return err } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index 1e84ca4d..c63bbcd4 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -116,16 +116,13 @@ func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { } func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { - if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1, 1); err == nil { - t.Fatal("nonempty numbered page without hasMore was accepted") + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil { + t.Fatal("numbered page without hasMore was accepted") } - if page, err := oaHasMorePage(map[string]any{}, "oa/page", 0, 1); err != nil || !page.Known || page.HasMore { - t.Fatalf("reviewed empty numbered terminal page=%+v err=%v", page, err) - } - if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 0, 1); err == nil { + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil { t.Fatal("wrong hasMore type was accepted") } - page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 1, 2) + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2) if err != nil || !page.HasMore || page.Next != "3" { t.Fatalf("numbered continuation=%+v err=%v", page, err) } @@ -146,7 +143,7 @@ func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { caller := &oaCoverageCaller{responses: map[string][]string{ - "get_submitted_instances": {`{"success":"true","result":{"values":[]}}`}, + "get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`}, }} cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") if err != nil { diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index c62705b1..6b2ff1ed 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -29,7 +29,7 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param if err != nil { return err } - page, err := oaHasMorePage(result, operation, len(instances), 1) + page, err := oaHasMorePage(result, operation, 1) if err != nil { return err } @@ -145,7 +145,7 @@ var MyInitiated = shortcut.Shortcut{ if err != nil { return err } - page, err := oaHasMorePage(result, operation, len(items), rt.Int("page")) + page, err := oaHasMorePage(result, operation, rt.Int("page")) if err != nil { return err } @@ -215,7 +215,7 @@ var Approve = shortcut.Shortcut{ if err != nil { return err } - page, err := oaHasMorePage(result, "oa/list_pending_approvals", len(items), 1) + page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) if err != nil { return err } diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index b853853c..262d6465 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -5,27 +5,24 @@ "shortcuts": { "+list-executed": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", "risk": "read", - "availability": "available", - "public": true, + "public": false, "reviewed": true }, "+list-submitted": { "disposition": "semantic_adapter", - "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", "risk": "read", - "availability": "available", - "public": true, + "public": false, "reviewed": true }, "+my-initiated": { "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持不可用。", "risk": "read", - "availability": "available", "primary": "+list-submitted", - "public": true, + "public": false, "reviewed": true }, "+search-forms": { From d2cbd928e24316685d2d8087b046954b38949a30 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:49:42 +0800 Subject: [PATCH 233/267] docs(shortcut): record DING double-layer release proof --- docs/shortcut-downstream-requirements/ding.md | 8 ++++++++ internal/shortcut/semantic_catalog_ding.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index 4c66a53c..f37835cb 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -47,6 +47,14 @@ | `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | | `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | +### Current HEAD 公开 Shortcut 双层发布矩阵 + +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | 稳定身份与结果 | current HEAD 结论 | +|---|---|---|---|---| +| `ding +receiver-status` | `dws ding +receiver-status --ding-id --format json` | 先以 `dws ding message list --type ALL --format json` 取得同一稳定 ID,再执行 `dws ding message receiver-status --ding-id --format json` | 原子来源页 50;exact/raw 接收项均为 1;请求 DING ID、每项 DING ID 与完整接收行集合一致 | `PASS-DING-RECEIVER-DOUBLE`;fully unlocked | + +这是精确稳定 ID 读取,不是 list/search,因此不制造随机不存在 ID 的“零命中”。实现与严格响应测试位于 `internal/shortcut/ding/ding.go`、`internal/shortcut/ding/common.go` 和 `internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go`。 + 隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 ## 5. 下游需求 diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index cfcea69d..d1a12dfb 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -12,7 +12,7 @@ }, "+receiver-status": { "disposition": "semantic_adapter", - "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", "risk": "read", "availability": "available", "public": true, From f4ad1a15f592371de27cbc75107c5614c1fa755b Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:50:28 +0800 Subject: [PATCH 234/267] docs(shortcut): record Report double-layer release proof --- .../shortcut-downstream-requirements/report.md | 18 +++++++++++------- internal/shortcut/semantic_catalog_report.json | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 51a6cc80..9a83b9ff 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -28,14 +28,18 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked ## 3. Exact Shortcut E2E 矩阵 -| Exact Shortcut | 已知非空 | 保证零项/零命中 | 身份与分页 | 结论 | -|---|---:|---:|---|---| -| `+template-search` | PASS(1) | PASS(0) | 完整模板集合验证后本地筛选;稳定模板身份 | fully unlocked | -| `+inbox-list` | PASS(累计 92) | PASS(0) | 5 个非空页;cursor 严格前进;首对相邻页身份交集 0;终页明确耗尽 | fully unlocked | -| `+outbox-list` | PASS(1) | PASS(0) | 非空页 `complete=true` 且 endpoint exhausted;570 天有界扫描合同异常 0 | fully unlocked | -| `+report-latest` | PASS(候选 1) | 不适用:精确组合读取 | 显式不超过 20 天窗口;按唯一最高创建时间选取;详情身份精确匹配;字段集合 3 | fully unlocked | +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | +|---|---|---|---|---| +| `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | +| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 20/20,稳定 ID 集合一致,exact/raw next cursor 一致;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | +| `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | +| `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | -所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 +`+inbox-list` 的长期分页证据仍为 20、20、20、20、12 后明确终止,累计 92,首对相邻页身份交集 0;`+outbox-list` 的 fixture 扫描仍为 30 个连续 19 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 + +所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 + +实现与严格响应测试位于 `internal/shortcut/report/report.go`、`internal/shortcut/report/latest.go`、`internal/shortcut/report/common.go` 和 `internal/shortcut/report/report_strict_cross_platform_coverage_test.go`;原子命令实现位于 `internal/helpers/report.go`。本文只保留命令模板、PASS 标签与聚合事实,运行时稳定 ID、标题和原始响应均未落盘。 ## 4. 已关闭需求 diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json index f80fd538..59f11983 100644 --- a/internal/shortcut/semantic_catalog_report.json +++ b/internal/shortcut/semantic_catalog_report.json @@ -5,7 +5,7 @@ "shortcuts": { "+inbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", "risk": "read", "availability": "available", "public": true, @@ -13,7 +13,7 @@ }, "+outbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;exact Shortcut 已在连续有界历史窗口中同时证明已知非空与保证零项。", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", "risk": "read", "availability": "available", "public": true, @@ -21,7 +21,7 @@ }, "+template-search": { "disposition": "semantic_adapter", - "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", "risk": "read", "availability": "available", "public": true, @@ -29,7 +29,7 @@ }, "+report-latest": { "disposition": "primary_smart", - "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;要求唯一最高 createTime,按稳定 reportId 精确读回身份匹配且字段集合严格的详情。", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", "risk": "read", "availability": "available", "public": true, From 8fa93ef030edb6ed42cab71e7431658a8478f390 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:56:16 +0800 Subject: [PATCH 235/267] fix(shortcut): retire OA discovery aliases after downgrade --- internal/cli/command_path_fallbacks.json | 4 ++-- internal/cli/command_path_fallbacks_generated.go | 4 ++-- internal/cli/command_path_fallbacks_test.go | 2 +- internal/cli/param_aliases_generated.go | 13 ------------- internal/cli/param_concepts.json | 3 +-- 5 files changed, 6 insertions(+), 20 deletions(-) diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fea4b829..f2b3c930 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -192,11 +192,11 @@ "mode": "ambiguous", "candidates": [ "oa +search-forms", - "oa +my-initiated", + "oa approval list-submitted", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 598cabb7..00f52d50 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 5136a8c0..1129efdd 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index c64abdcb..b7bf4f50 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -6655,19 +6655,6 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "minutes upload create", Blocked: []string{"file", "file-path", "path", "template-ids"}, }, - { - CLIPath: "oa +list-executed", - Aliases: map[string]string{ - "max-result": "limit", - "max-results": "limit", - "page-size": "limit", - "per-page": "limit", - "size": "limit", - "take": "limit", - "top": "limit", - }, - Blocked: []string{"count", "cursor"}, - }, { CLIPath: "oa +search-forms", Aliases: map[string]string{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index c8688ca7..0563dead 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -11,7 +11,7 @@ "concepts": { "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"}, "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"}, "content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"}, @@ -458,7 +458,6 @@ {"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2}, {"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2}, - {"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, From 1155b9b5c0c0547184dd58edff04cbafba7e3e35 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 19:12:38 +0800 Subject: [PATCH 236/267] test(shortcut): align OA reviewed input fixtures --- internal/app/command_path_fallback_e2e_test.go | 2 +- internal/app/param_alias_payload_equivalence_test.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index f5e083c3..db8e6133 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 2d6ef0ba..cfd54910 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -226,7 +226,6 @@ var paramAliasCompleteCommands = map[string][]string{ "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, - "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, From db938778af1767333a0ec68ea43b69ee5e2383a2 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:00:33 +0800 Subject: [PATCH 237/267] chore(shortcut): sync OA DING Report with current main --- docs/shortcut-public-catalog.json | 62 ++++++++----------- internal/app/schema_shortcut_contract_test.go | 6 +- internal/shortcut/public_catalog_generated.go | 5 +- skills/mono/SKILL.md | 6 +- skills/multi/dingtalk-misc/references/ding.md | 3 - skills/multi/dingtalk-misc/references/oa.md | 6 -- .../multi/dingtalk-misc/references/report.md | 6 +- 7 files changed, 38 insertions(+), 56 deletions(-) diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index eeb72aa2..6e6904cb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-19T10:35:58.304269", - "count": 423, + "generated_at": "2026-08-20T11:51:44.156046", + "count": 416, "results": [ { "suite": "semantic", @@ -2500,7 +2500,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", "availability": "available" }, { @@ -3583,36 +3583,6 @@ "semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.", "availability": "available" }, - { - "suite": "semantic", - "service": "oa", - "command": "+list-executed", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "oa", - "command": "+list-submitted", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "oa", - "command": "+my-initiated", - "risk": "read", - "status": "reviewed_available", - "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", - "availability": "available" - }, { "suite": "semantic", "service": "oa", @@ -3630,7 +3600,27 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+outbox-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+report-latest", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", "availability": "available" }, { @@ -3640,7 +3630,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", "availability": "available" }, { @@ -4068,4 +4058,4 @@ "availability": "available" } ] -} +} \ No newline at end of file diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 68a1db5e..ed89cc80 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 422 + publicShortcutCount = 415 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 447 + schemaPublishedShortcutCount = 453 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 422 + publiclyDeliveredShortcutCount = 415 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 9dc6742d..1d81b103 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -374,11 +374,10 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-executed": {}, - "oa\u0000+list-submitted": {}, - "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, + "report\u0000+outbox-list": {}, + "report\u0000+report-latest": {}, "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index d695e969..3d226752 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -49,13 +49,13 @@ cli_version: ">=1.0.15" | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | -| `ding` | 4 | `dingtalk-misc` | +| `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | | `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | -| `oa` | 7 | `dingtalk-misc` | -| `report` | 2 | `dingtalk-misc` | +| `oa` | 1 | `dingtalk-misc` | +| `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | | `wiki` | 20 | `dingtalk-wiki` | diff --git a/skills/multi/dingtalk-misc/references/ding.md b/skills/multi/dingtalk-misc/references/ding.md index 5cce1ce6..d2485596 100644 --- a/skills/multi/dingtalk-misc/references/ding.md +++ b/skills/multi/dingtalk-misc/references/ding.md @@ -173,10 +173,7 @@ dws ding message recall-personal --id --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws ding +list` | read | 查询 DING 消息列表 | -| `dws ding +recall-personal` | high-risk-write | 撤回本人发起的 DING | | `dws ding +receiver-status` | read | 查询 DING 消息接收人已读状态 | -| `dws ding +send-personal` | write | 以本人身份发送 DING 给指定人 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index f526c23c..5044bd0c 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -963,12 +963,6 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws oa +list-cc` | read | 获取抄送当前用户的审批单列表 | -| `dws oa +list-executed` | read | 获取当前用户已经处理过的审批单列表 | -| `dws oa +list-forms` | read | 获取当前用户可见的审批表单列表 | -| `dws oa +list-pending` | read | 查询待我处理的审批(时间范围为 epoch 毫秒) | -| `dws oa +list-submitted` | read | 获取当前用户已发起的审批单列表 | -| `dws oa +my-initiated` | read | 列出我发起(提交)的审批单据 | | `dws oa +search-forms` | read | 按关键字模糊搜索当前用户可见的审批表单 | diff --git a/skills/multi/dingtalk-misc/references/report.md b/skills/multi/dingtalk-misc/references/report.md index 9ff940e9..cf73d9d5 100644 --- a/skills/multi/dingtalk-misc/references/report.md +++ b/skills/multi/dingtalk-misc/references/report.md @@ -449,8 +449,10 @@ dws report outbox list --cursor 0 --size 20 --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws report +inbox-list` | read | 列出我收到的日报(按时间范围分页) | -| `dws report +outbox-list` | read | 列出我发出的日报(可选时间/模版名过滤) | +| `dws report +inbox-list` | read | 列出我收到的日志 | +| `dws report +outbox-list` | read | 列出我发出的日志 | +| `dws report +report-latest` | read | 读取我最近提交的一篇日志详情 | +| `dws report +template-search` | read | 按名称搜索可用日志模板 | ## 意图表 From 5bbaa304e3f87d87699d205012b289bc81a29a02 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:17:07 +0800 Subject: [PATCH 238/267] docs(shortcut): refresh OA DING Report live evidence --- docs/shortcut-downstream-requirements/ding.md | 6 ++++-- docs/shortcut-downstream-requirements/oa.md | 16 ++++++++-------- docs/shortcut-downstream-requirements/report.md | 16 ++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index f37835cb..d4e1b879 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -1,7 +1,7 @@ # DING Shortcut 下游业务能力需求规格 -> 日期:2026-08-18 -> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 日期:2026-08-20 +> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` > 对比基线:lark-cli 1.0.87 > 范围:Shortcut-only DING surface @@ -39,6 +39,8 @@ ## 4. Exact Shortcut 与隔离 raw 证据 +下表中 `+receiver-status` 是本轮 current-HEAD 发布证据;其余非公开入口为先前同租户隔离诊断,只用于说明下游缺口,不计入本轮公开能力 PASS。 + | 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | |---|---|---|---| | `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index 4fb493ef..2eba3c1a 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -1,6 +1,6 @@ # OA Shortcut 下游能力需求 -基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-18。 +当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-20。 本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 @@ -15,7 +15,7 @@ | Shortcut / 用户路线 | 最终分类 | 依据 | |---|---|---| -| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 1、保证零命中 0,稳定身份集合一致;public | +| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 2、保证零命中 0,稳定身份集合一致;public | | `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | @@ -32,16 +32,16 @@ OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unl | EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | |---|---|---|---|---| -| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 1/1,稳定 `processCode` 集合相等;随机 UUID 查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | +| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 2/2,稳定 `processCode` 集合相等;随机唯一查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | 该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| -| `+list-executed` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+list-submitted` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+my-initiated` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | -| `+search-forms` | PASS | current HEAD 已知非空 1、保证零命中 0;exact/raw 稳定身份集合一致 | +| `+list-executed` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+list-submitted` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+my-initiated` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | +| `+search-forms` | PASS | current HEAD 已知非空 2、保证零命中 0;exact/raw 稳定身份集合一致 | | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | @@ -116,7 +116,7 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 ## `DS-oa-004`:编号列表零命中的分页终态 - 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。current HEAD 的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。 +- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。先前同租户隔离诊断的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。这些非公开入口未计入本轮 current-HEAD 发布 PASS。 - 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 - 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 9a83b9ff..e3558f61 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -1,7 +1,7 @@ # Report Shortcut 下游业务能力需求规格 -> 日期:2026-08-18 -> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 日期:2026-08-20 +> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` > 对比基线:lark-cli 1.0.87(无 Report 产品域) > 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 @@ -9,7 +9,7 @@ - 源码 Shortcut:4;公开:4;unavailable:0。 - `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 -- 30 个连续 19 天窗口覆盖 570 天发件箱历史:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 +- 本轮从近期起有界扫描 15 个连续 20 天窗口:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 - Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 ## 2. Residual-gap 分类 @@ -17,8 +17,8 @@ | 能力 | 最终分类 | PASS 证据 | 公开状态 | |---|---|---|---| | `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | -| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:20、20、20、20、12 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | -| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:30 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | +| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:16 页、总计 308 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | +| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:15 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | | `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | | 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | | 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | @@ -31,11 +31,11 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked | EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | |---|---|---|---|---| | `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | -| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 20/20,稳定 ID 集合一致,exact/raw next cursor 一致;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | +| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 16 页总计 308,逐页稳定 ID 集合与 next cursor 一致、全局身份无重复;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | | `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | | `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | -`+inbox-list` 的长期分页证据仍为 20、20、20、20、12 后明确终止,累计 92,首对相邻页身份交集 0;`+outbox-list` 的 fixture 扫描仍为 30 个连续 19 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 +`+inbox-list` 的 current-HEAD 分页证据为 16 页后明确终止,累计 308,逐页 exact/raw 身份一致且全局身份无重复;`+outbox-list` 的 fixture 扫描为 15 个连续 20 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 @@ -46,7 +46,7 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked ### `DS-Report-001` — 非空发件箱与最新详情链路 - 状态:`PASS-RPT-RESIDUAL-CLOSED`。 -- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。扩大为连续 570 天有界 exact 扫描后发现 1 个非空窗口。 +- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。本轮从近期起按 20 天窗口有界扫描,在第 15 个窗口发现 1 个非空窗口。 - 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 - 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 From fd2ed2174f00ec28049fe9bf9337b955e0dfce81 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:20:51 +0800 Subject: [PATCH 239/267] chore(release): add OA DING Report fragment --- .changes/oa-ding-report-shortcuts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/oa-ding-report-shortcuts.md diff --git a/.changes/oa-ding-report-shortcuts.md b/.changes/oa-ding-report-shortcuts.md new file mode 100644 index 00000000..c24c62e4 --- /dev/null +++ b/.changes/oa-ding-report-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. From 3b5cb3b0cbf9c74dccb34685f843366f8086fc80 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 14:01:14 +0800 Subject: [PATCH 240/267] test(shortcut): close OA DING Report coverage gaps --- ...ing_strict_cross_platform_coverage_test.go | 166 +++++- internal/shortcut/oa/oa.go | 10 +- .../oa_strict_cross_platform_coverage_test.go | 487 ++++++++++++++++++ internal/shortcut/oa/smart.go | 15 +- ...ort_strict_cross_platform_coverage_test.go | 303 +++++++++++ 5 files changed, 959 insertions(+), 22 deletions(-) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index b6fba7eb..a5dace6e 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "math" "strings" "testing" @@ -22,11 +23,17 @@ import ( type dingCoverageCaller struct { responses map[string][]string + failures map[string]error history []string + arguments []map[string]any } -func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } queue := caller.responses[tool] if len(queue) == 0 { return nil, errors.New("missing DING fake response for " + tool) @@ -52,6 +59,20 @@ func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCo return cmd, cmd.Execute() } +func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.Bytes(), err +} + func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { if declaration.Contract.Empty() { @@ -103,7 +124,7 @@ func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { "empty": {}, "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, - "false success": {"success": false, "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, "missing result": {"success": true}, "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, @@ -123,6 +144,40 @@ func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { t.Errorf("%s returned success: %#v", name, projected) } } + if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") { + t.Fatalf("remote failure message was not preserved: %v", err) + } +} + +func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) { + for name, test := range map[string]struct { + value any + want int64 + }{ + "float64": {value: float64(1), want: 1}, + "int": {value: int(2), want: 2}, + "int64": {value: int64(3), want: 3}, + "json number": {value: json.Number("4"), want: 4}, + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(test.value); !ok || got != test.want { + t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want) + } + }) + } + for name, value := range map[string]any{ + "nan": math.NaN(), + "fraction": 1.5, + "overflow": math.MaxFloat64, + "bad json number": json.Number("not-an-integer"), + "wrong type": "1", + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(value); ok { + t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got) + } + }) + } } func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { @@ -136,6 +191,7 @@ func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { "missing collection": {"success": true, "result": map[string]any{}}, "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, @@ -146,6 +202,112 @@ func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { } } +func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) { + t.Run("negative cursor validates before call", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{}} + if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 { + t.Fatalf("negative cursor error=%v calls=%v", err, caller.history) + } + }) + + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" { + t.Fatalf("transport error=%v calls=%v", err, caller.history) + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller); err == nil { + t.Fatal("malformed empty continuation returned success") + } + }) + + t.Run("stalled cursor", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") { + t.Fatalf("stalled cursor error=%v", err) + } + }) + + t.Run("advancing cursor legacy output", func(t *testing.T) { + legacy := List + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2") + if err != nil { + t.Fatalf("legacy advancing page: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false { + t.Fatalf("legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 { + t.Fatalf("legacy cursor arguments=%#v", caller.arguments) + } + }) + + t.Run("invalid unified page evidence", func(t *testing.T) { + cmd := corecmd.New(shortcut.FromShortcut(List)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + rt := shortcut.RuntimeContextForTest(cmd, List) + if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil { + t.Fatal("continuation without next cursor returned success") + } + }) +} + +func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) { + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("receiver transport failure returned success") + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("empty receiver response returned success") + } + }) + + t.Run("legacy output", func(t *testing.T) { + legacy := ReceiverStatus + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1") + if err != nil { + t.Fatalf("receiver legacy output: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) { + t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" { + t.Fatalf("receiver arguments=%#v", caller.arguments) + } + }) +} + func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { listCaller := &dingCoverageCaller{responses: map[string][]string{ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 13d3564e..1313a8b9 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -37,10 +37,7 @@ func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string] if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) evidence, err := oaHasMorePage(result, operation, page) if err != nil { return err @@ -148,10 +145,7 @@ var ListForms = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaCursorPage(result, operation, rt.Int("cursor")) if err != nil { return err diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index c63bbcd4..57d783f7 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "reflect" "strings" "testing" @@ -57,6 +58,24 @@ func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCovera return cmd, cmd.Execute() } +func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs(append([]string{"oa", "+approve-by"}, args...)) + return root.Execute() +} + func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { for _, declaration := range []shortcut.Shortcut{ ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, @@ -223,3 +242,471 @@ func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { t.Fatalf("identity was not preserved: %#v", confirmed.arguments) } } + +func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) { + if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil { + t.Fatal("typed object result and post-write error must be constructed") + } + + for name, fixture := range map[string]map[string]any{ + "false with backend message": {"success": false, "errorMessage": "fixture"}, + "false without message": {"success": false}, + "null success": {"success": nil}, + "wrong success string": {"success": "TRUE"}, + } { + if err := oaRequireSuccess(fixture, "oa/test"); err == nil { + t.Errorf("%s was accepted", name) + } + } + + if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok { + t.Fatal("lookup traversed a non-object") + } + if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok { + t.Fatal("lookup accepted a missing segment") + } + for name, fixture := range map[string]map[string]any{ + "nil object": {"success": true, "result": nil}, + "wrong object": {"success": true, "result": "bad"}, + "empty object": {"success": true, "result": map[string]any{}}, + } { + if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil { + t.Errorf("%s returned object %#v", name, value) + } + } + if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil { + t.Fatalf("failed envelope returned object %#v", value) + } + object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result") + if err != nil || object["id"] != "fixture" { + t.Fatalf("valid object=%#v err=%v", object, err) + } + + for name, value := range map[string]any{ + "string": " fixture ", + "json number": json.Number("42"), + "float": float64(42), + "int": int(42), + "int64": int64(42), + "unsupported": true, + } { + got := oaScalarString(value) + if name == "unsupported" && got != "" { + t.Errorf("unsupported scalar=%q", got) + } + if name != "unsupported" && got == "" { + t.Errorf("%s scalar was empty", name) + } + } + + identityFixture := map[string]any{"id": int64(42)} + if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil { + t.Fatal(err) + } + if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil { + t.Fatal("missing identity was accepted") + } + if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil { + t.Fatal("mismatched identity was accepted") + } + + formsFixture := map[string]any{"success": true, "result": []any{map[string]any{ + "processCode": "p", "name": "fixture", "iconUrl": "icon", + }}} + forms, err := oaProjectForms(formsFixture, "oa/forms", "result") + if err != nil || forms[0]["iconUrl"] != "icon" { + t.Fatalf("fallback form fields=%#v err=%v", forms, err) + } + + instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{ + "processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"), + "businessId": 7, "originatorUserName": "owner", "createTime": int64(9), + }}}} + instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values") + if err != nil { + t.Fatal(err) + } + wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)} + if !reflect.DeepEqual(instances[0], wantInstance) { + t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance) + } + + for name, fixture := range map[string]map[string]any{ + "task projection error": {"success": true, "result": map[string]any{}}, + "missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}}, + "duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{ + map[string]any{"taskId": 1}, map[string]any{"taskId": 1}, + }}}, + } { + if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil { + t.Errorf("%s returned %#v", name, tasks) + } + } + + for name, fixture := range map[string]map[string]any{ + "wrong cursor hasMore": {"hasMore": "true"}, + "terminal conflict": {"hasMore": false, "nextCursor": 1}, + } { + if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil { + t.Errorf("%s returned %+v", name, page) + } + } + terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0) + if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" { + t.Fatalf("terminal cursor=%+v err=%v", terminal, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil { + t.Fatal("continuation from invalid current page was accepted") + } + terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1) + if err != nil || !terminal.Known || terminal.HasMore { + t.Fatalf("terminal numbered page=%+v err=%v", terminal, err) + } + + if err := validateOAPage(0, 1); err == nil { + t.Fatal("zero page was accepted") + } + if err := validateOAPage(1, 0); err == nil { + t.Fatal("zero limit was accepted") + } + if err := validateOAPage(1, 101); err == nil { + t.Fatal("oversized limit was accepted") + } +} + +func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) { + legacyPage := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"}) + }, + } + _, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}}) + if err != nil { + t.Fatal(err) + } + unknownPage := legacyPage + unknownPage.Command = "+coverage-unknown-page" + unknownPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{}) + } + if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unknown pagination was accepted") + } + + legacyComplete := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) }, + } + if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + unifiedComplete := legacyComplete + unifiedComplete.Command = "+coverage-unified-complete" + unifiedComplete.OutputRollout = output.RolloutUnifiedActive + if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + badUnifiedPage := legacyPage + badUnifiedPage.Command = "+coverage-unified-bad-page" + badUnifiedPage.OutputRollout = output.RolloutUnifiedActive + badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true}) + } + if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unified continuation without token was accepted") + } +} + +func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + + validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + for name, args := range map[string][]string{ + "bad interval": {"--start", "2", "--end", "1"}, + "bad page": {"--start", "1", "--end", "2", "--page", "bad"}, + "bad limit": {"--start", "1", "--end", "2", "--limit", "bad"}, + "zero page": {"--start", "1", "--end", "2", "--page", "0"}, + } { + t.Run("list-pending validate "+name, func(t *testing.T) { + caller := expectError(t, ListPending, map[string][]string{}, args...) + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } + t.Run("list-pending call failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending projection failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending null result fails closed", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending pagination failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending full params", func(t *testing.T) { + caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}}, + "--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture") + want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want) + } + }) + + for name, args := range map[string][]string{ + "negative cursor": {"--cursor", "-1"}, + "zero limit": {"--limit", "0"}, + "oversized limit": {"--limit", "101"}, + } { + t.Run("list-forms validate "+name, func(t *testing.T) { + expectError(t, ListForms, map[string][]string{}, args...) + }) + } + t.Run("list-forms call failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}}) + }) + t.Run("list-forms projection failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("list-forms null result fails closed", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}}) + }) + t.Run("list-forms pagination failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}}) + }) + t.Run("list-forms success", func(t *testing.T) { + expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2") + }) + + t.Run("search empty query", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{}, "--query", " ") + }) + t.Run("search call failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") + }) + t.Run("search projection failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") + }) + t.Run("search known nonempty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", "fixture") + }) + t.Run("search legitimate empty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") + }) + + for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} { + declaration := declaration + tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command] + t.Run(declaration.Command+" invalid page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--page", "bad") + }) + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--limit", "bad") + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" full params", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ") + want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("params=%#v want=%#v", caller.arguments[0], want) + } + }) + } +} + +func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + + for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} { + declaration := declaration + tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command] + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + caller := expectError(t, declaration, map[string][]string{}, "--limit", "0") + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" projection failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run(declaration.Command+" null result fails closed", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}}) + }) + t.Run(declaration.Command+" pagination failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}) + }) + t.Run(declaration.Command+" default limit", func(t *testing.T) { + expectSuccess(t, declaration, map[string][]string{tool: {terminal}}) + }) + t.Run(declaration.Command+" explicit limit", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3") + if caller.arguments[0]["pageSize"] != float64(3) { + t.Fatalf("explicit limit params=%#v", caller.arguments[0]) + } + }) + } + + for name, args := range map[string][]string{ + "bad page": {"--page", "0"}, + "bad limit": {"--limit", "101"}, + } { + t.Run("my-initiated validate "+name, func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{}, args...) + }) + } + t.Run("my-initiated call failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}}) + }) + t.Run("my-initiated projection failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("my-initiated null result fails closed", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}}) + }) + t.Run("my-initiated pagination failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run("my-initiated query and continuation", func(t *testing.T) { + caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3") + want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want) + } + }) +} + +func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) { + for _, tc := range []struct { + name string + flagName string + validate func(*shortcut.RuntimeContext) error + }{ + {name: "search forms", flagName: "query", validate: SearchForms.Validate}, + {name: "approve", flagName: "keyword", validate: Approve.Validate}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}}, + Execute: tc.validate, + } + caller := &oaCoverageCaller{responses: map[string][]string{}} + if _, err := runOACoverage(t, declaration, caller); err == nil { + t.Fatal("empty required semantic value was accepted") + } + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) { + pending := func(values string, hasMore bool) string { + return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}` + } + tasks := func(values string) string { + return `{"success":true,"result":{"taskIdList":` + values + `}}` + } + validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false) + validTasks := tasks(`[{"taskId":1}]`) + + cases := []struct { + name string + responses map[string][]string + }{ + {name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}}, + {name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}}, + {name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}}, + {name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}}, + {name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}}, + {name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}}, + {name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}}, + {name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}}, + {name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}}, + {name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}}, + {name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}}, + {name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}}, + {name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}}, + {name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}}, + {name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}}, + {name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}}, + {name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &oaCoverageCaller{responses: tc.responses} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil { + t.Fatalf("failure scenario succeeded; calls=%v", caller.history) + } + }) + } + + t.Run("empty keyword validates before calls", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{}} + if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil { + t.Fatal("blank keyword succeeded") + } + if len(caller.history) != 0 { + t.Fatalf("blank keyword made calls: %v", caller.history) + } + }) + + t.Run("comment is sent only after confirmation", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {validPending}, + "list_pending_tasks": {validTasks, tasks(`[]`)}, + "approve_processInstance": {`{"success":true}`}, + }} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil { + t.Fatal(err) + } + if caller.arguments[2]["remark"] != "approved" { + t.Fatalf("write args=%#v", caller.arguments[2]) + } + }) +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 6b2ff1ed..134fc0fd 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -25,10 +25,7 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaHasMorePage(result, operation, 1) if err != nil { return err @@ -141,10 +138,7 @@ var MyInitiated = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaHasMorePage(result, operation, rt.Int("page")) if err != nil { return err @@ -211,10 +205,7 @@ var Approve = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(pending, "oa/list_pending_approvals", "result") - if err != nil { - return err - } + result, _ := pending["result"].(map[string]any) page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) if err != nil { return err diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go index ad5459ce..48fae36f 100644 --- a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "math" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -56,6 +57,20 @@ func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *repo return cmd, cmd.Execute() } +func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} for _, declaration := range declarations { @@ -320,3 +335,291 @@ func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *t } } } + +func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) { + operation := "report/coverage" + if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil { + t.Fatal("remote failure with message returned success") + } + arrayEntries, container, err := reportListCollection(map[string]any{ + "success": true, + "result": []any{map[string]any{"report_id": "report-array"}}, + "hasMore": false, + }, operation) + if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false { + t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err) + } + if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil { + t.Fatal("conflicting aliases returned success") + } + if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil { + t.Fatal("non-string optional value returned success") + } + + integerCases := []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false}, + } + for _, test := range integerCases { + got, ok := reportInteger(test.value) + if got != test.want || ok != test.ok { + t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + fullEntry := map[string]any{ + "success": true, + "result": map[string]any{ + "hasMore": true, + "nextCursor": json.Number("2"), + "report_list": []any{map[string]any{ + "report_id": "report-full", "report_template_name": "Weekly", + "senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13), + }}, + }, + } + entries, page, err := reportProjectEntries(fullEntry, operation) + if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) { + t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err) + } + if err := reportValidateContinuation(page, 1, operation); err != nil { + t.Fatalf("advancing continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil { + t.Fatalf("terminal continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil { + t.Fatal("malformed continuation returned success") + } + badOptional := map[string]any{ + "success": true, "hasMore": false, + "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}}, + } + if _, _, err := reportProjectEntries(badOptional, operation); err == nil { + t.Fatal("bad optional entry field returned success") + } + + completeDetail := map[string]any{"success": true, "result": map[string]any{ + "reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator", + "create_time": int(4), "modified_time": json.Number("5"), + "report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}}, + }} + detail, err := reportProjectDetail(completeDetail, operation, "report-detail") + if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("complete detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}}, + "bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}}, + "missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) { + if _, err := reportParseISOMillis("start", "not-a-time"); err == nil { + t.Fatal("invalid ISO timestamp returned success") + } + if err := reportValidatePage(-1, 20); err == nil { + t.Fatal("negative cursor returned success") + } + if err := reportValidatePage(0, 0); err == nil { + t.Fatal("zero page size returned success") + } + if err := reportValidatePage(0, 20); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, start, end string + maximumDays int + }{ + {"bad start", "bad", "2026-07-02T00:00:00+08:00", 20}, + {"bad end", "2026-07-01T00:00:00+08:00", "bad", 20}, + {"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20}, + } { + if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil { + t.Errorf("%s returned success", test.name) + } + } + if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil { + t.Fatalf("unbounded valid range: %v", err) + } +} + +func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) { + start := "2026-07-01T00:00:00+08:00" + end := "2026-07-02T00:00:00+08:00" + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}` + + inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}} + if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil { + t.Fatal(err) + } + if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 { + t.Fatalf("inbox args/history=%#v", inbox.history) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil { + t.Errorf("inbox %s returned success", test.name) + } + } + directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := InboxList.Execute(directInbox); err == nil { + t.Fatal("direct inbox invalid range returned success") + } + + outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}} + if _, err := runReportCoverage(t, OutboxList, outboxAll, + "--cursor", "2", "--start", start, "--end", end, + "--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil { + t.Fatal(err) + } + if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil { + t.Fatalf("outbox args/history=%#v", outboxAll.history) + } + defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}} + if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil { + t.Errorf("outbox %s returned success", test.name) + } + } + directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad creation range returned success") + } + directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, + "--modified-start", "bad", "--modified-end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad modified range returned success") + } + + for _, test := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"outbox invalid page", OutboxList, []string{"--size", "0"}}, + {"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}}, + {"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}}, + {"template empty query", TemplateSearch, []string{"--query", " "}}, + } { + caller := &reportCoverageCaller{responses: map[string][]string{}} + _, err := runReportCoverage(t, test.declaration, caller, test.args...) + if test.name == "outbox valid modified range" { + if err == nil || len(caller.history) != 1 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + continue + } + if err == nil || len(caller.history) != 0 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + } + + for name, caller := range map[string]*reportCoverageCaller{ + "call error": {responses: map[string][]string{}}, + "projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}}, + } { + if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil { + t.Errorf("template %s returned success", name) + } + } + directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ") + if err := TemplateSearch.Validate(directTemplate); err == nil { + t.Fatal("direct template whitespace query returned success") + } + legacyTemplate := TemplateSearch + legacyTemplate.OutputRollout = output.RolloutLegacyOnly + legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}} + if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil { + t.Fatalf("legacy template: %v", err) + } +} + +func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) { + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}` + validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}` + defaultCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {validDetail}, + }} + if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil { + t.Fatal(err) + } + if defaultCaller.history[0].args["report_template_name"] != "Weekly" { + t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args) + } + + for name, caller := range map[string]*reportCoverageCaller{ + "list call error": {responses: map[string][]string{}}, + "list projection error": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true}`}, + }}, + "empty candidates": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`}, + }}, + "detail call error": {responses: map[string][]string{ + "get_send_report_list": {validList}, + }}, + "detail projection error": {responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`}, + }}, + } { + if _, err := runReportCoverage(t, ReportLatest, caller); err == nil { + t.Errorf("latest %s returned success", name) + } + } + directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}}, + "--start", "bad", "--end", "2026-07-02T00:00:00+08:00") + if err := ReportLatest.Execute(directLatest); err == nil { + t.Fatal("direct latest invalid range returned success") + } + if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil { + t.Fatal("non-string latest report identity returned success") + } +} + +func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) { + command := &cobra.Command{Use: "report-page"} + command.SetOut(io.Discard) + command.SetContext(context.Background()) + declaration := shortcut.Shortcut{Service: "report", Product: "report"} + rt := shortcut.RuntimeContextForTest(command, declaration) + output.SetCommandRollout(command, output.RolloutLegacyOnly) + if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil { + t.Fatalf("legacy report page: %v", err) + } + + ctx, _ := output.WithResultStore(context.Background()) + command.SetContext(ctx) + output.SetCommandRollout(command, output.RolloutUnifiedActive) + if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil { + t.Fatal("unified page without continuation returned success") + } +} From c0641dbf64cc5648b628727f1cc26a8cdb4c61e7 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:07:59 +0800 Subject: [PATCH 241/267] fix(shortcut): preserve OA and DING CLI compatibility --- docs/shortcut-downstream-requirements/ding.md | 87 ------------ docs/shortcut-downstream-requirements/oa.md | 125 ------------------ .../report.md | 76 ----------- .../builtin/ding_semantic_catalog_test.go | 23 +++- .../builtin/oa_semantic_catalog_test.go | 26 +++- internal/shortcut/semantic_catalog_ding.json | 3 + internal/shortcut/semantic_catalog_oa.json | 6 + 7 files changed, 48 insertions(+), 298 deletions(-) delete mode 100644 docs/shortcut-downstream-requirements/ding.md delete mode 100644 docs/shortcut-downstream-requirements/oa.md delete mode 100644 docs/shortcut-downstream-requirements/report.md diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md deleted file mode 100644 index d4e1b879..00000000 --- a/docs/shortcut-downstream-requirements/ding.md +++ /dev/null @@ -1,87 +0,0 @@ -# DING Shortcut 下游业务能力需求规格 - -> 日期:2026-08-20 -> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` -> 对比基线:lark-cli 1.0.87 -> 范围:Shortcut-only DING surface - -## 1. 最终 surface - -- 源码 Shortcut:5;公开:1;unavailable:4。 -- 公开 `+receiver-status` fully unlocked;其余 4 条保持 fail-closed,不能把 DING 整体写效果称为完成。 -- 读取严格化:PASS。只接受显式布尔成功、准确结果路径、正确集合类型、合法元素和稳定 DING 身份。 -- 本地可修缺口:PASS。`app/sms/call` 已在原子 helper 映射为下游要求的 `APP/SMS/PHONE`,未知类型在调用前拒绝。 -- 写门禁:PASS。3 个写 Shortcut 均需用户确认,且当前 capability gate 不发起远端调用;隔离诊断只通过精确 atomic/raw 路径执行。 - -## 2. Residual-gap 分类 - -| 能力 | 最终分类 | 归因 | Shortcut 状态 | -|---|---|---|---| -| `+receiver-status` | fully unlocked | exact 稳定 DING 身份读取返回 1 项且全部匹配 | public | -| `+list` | delivery hardened but downstream blocked | 真实 continuation 不前进并返回重复边界项;本地不能安全猜测跳游标或去重 | unavailable | -| `+send-personal --type app` | delivery hardened but downstream blocked | 稳定 DING 回执已证明;接收项没有稳定接收人身份,撤回无可查询终态 | unavailable | -| `+send-by-message --type app` | delivery hardened but downstream blocked | 源消息和 DING 稳定身份已证明;接收人与清理终态仍不可证明 | unavailable | -| `+send-personal/+send-by-message --type sms/call` | unavailable | 没有隔离号码、显式成本授权和通知残留核验 fixture,本轮未执行 | unavailable | -| `+recall-personal` | delivery hardened but downstream blocked | 撤回只返回布尔成功,不返回目标或终态;撤回后精确接收状态仍保留记录 | unavailable | -| Lark `urgent_app/sms/phone` 用户任务 | routed | 精确原子命令存在;仅在用户明确目标、提醒方式与成本后执行,不等于 Shortcut 已解锁 | 非公开 Shortcut 路线 | - -## 3. Lark 用户任务映射 - -| 用户任务 | DWS 路线 | lark-cli 1.0.87 | 结论 | -|---|---|---|---| -| 查询 DING 历史 | `ding +list` | 无直接任务 | delivery hardened but downstream blocked | -| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | fully unlocked;平台身份模型不同 | -| 基于消息发送应用内加急 | `ding message send-by-message --type app` | `im messages urgent_app` | routed;Shortcut unavailable | -| 基于消息发送短信加急 | `ding message send-by-message --type sms` | `im messages urgent_sms` | routed,需成本授权;Shortcut unavailable | -| 基于消息发送电话加急 | `ding message send-by-message --type call` | `im messages urgent_phone` | routed,需成本授权;Shortcut unavailable | -| 新建个人 DING | `ding message send-personal` | 无直接任务 | routed;Shortcut unavailable | -| 撤回个人 DING | `ding message recall-personal` | 无直接任务 | routed;Shortcut unavailable | - -## 4. Exact Shortcut 与隔离 raw 证据 - -下表中 `+receiver-status` 是本轮 current-HEAD 发布证据;其余非公开入口为先前同租户隔离诊断,只用于说明下游缺口,不计入本轮公开能力 PASS。 - -| 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | -|---|---|---|---| -| `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | -| `+receiver-status` | 精确身份返回 1,全部 DING 身份匹配 | 集合为数组、业务成功为布尔值 | fully unlocked | -| `+send-personal` | capability gate,远端调用 0 | APP 自收件:稳定 DING 回执 1、接收状态 1、DING 身份匹配;稳定接收人身份字段 0 | downstream blocked | -| `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | -| `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | - -### Current HEAD 公开 Shortcut 双层发布矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | 稳定身份与结果 | current HEAD 结论 | -|---|---|---|---|---| -| `ding +receiver-status` | `dws ding +receiver-status --ding-id --format json` | 先以 `dws ding message list --type ALL --format json` 取得同一稳定 ID,再执行 `dws ding message receiver-status --ding-id --format json` | 原子来源页 50;exact/raw 接收项均为 1;请求 DING ID、每项 DING ID 与完整接收行集合一致 | `PASS-DING-RECEIVER-DOUBLE`;fully unlocked | - -这是精确稳定 ID 读取,不是 list/search,因此不制造随机不存在 ID 的“零命中”。实现与严格响应测试位于 `internal/shortcut/ding/ding.go`、`internal/shortcut/ding/common.go` 和 `internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go`。 - -隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 - -## 5. 下游需求 - -### `DS-DING-001` — 稳定接收人身份读回 - -- 优先级:P1;类型:contract insufficient。 -- 已关闭部分:`PASS-DING-WRITE-RECEIPT`,两种 APP 发送路径均返回唯一稳定 `openDingId`;提醒类型枚举映射已在本地修正。 -- 剩余缺口:接收状态项只有 DING 身份、确认状态和显示名,没有可与请求 `openDingTalkId` 集合精确比对的稳定接收人身份。 -- 验收:返回逐接收人的稳定身份和 typed 受理状态;多接收人集合与请求集合精确一致;缺项、重复项、额外项和错型均机器可检测。 - -### `DS-DING-002` — 可查询撤回终态 - -- 优先级:P1;类型:missing capability。 -- 当前事实:撤回响应不含目标身份或终态;精确接收状态在撤回后仍保留;列表首屏缺席不能越过坏分页证明全局终态。 -- 验收:按同一 `openDingId` 返回 typed `recalled` 终态或提供专用只读查询;重复撤回语义稳定;能够区分完成、处理中、部分撤回和 commit-unknown,并证明无残留通知。 - -### `DS-DING-003` — 游标边界与前进语义 - -- 优先级:P2;类型:contract insufficient。 -- 当前事实:首屏 50 项;真实 continuation 再返回 50 项,下一游标不前进,跨页身份交集 1。对包含式边界加一仍返回不前进页面,排除安全的本地 `+1` 修补。 -- 验收:连续页凭据严格前进;跨页无重复,或合同明确包含式边界且可证明去重不遗漏;终页明确 `hasMore=false`。 - -## 6. Fixture、安全与脱敏 - -- PASS:APP 仅使用当前账号自收件、合成无业务正文与立即撤回;没有执行短信或电话提醒。 -- PASS:没有提交用户、组织、会话、消息、DING 的真实 ID,也没有标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 -- PASS:文档只保留命令级标签与聚合计数;DING 写 Shortcut 仍为 unavailable,未把原子诊断回执当成公开能力完成。 diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md deleted file mode 100644 index 2eba3c1a..00000000 --- a/docs/shortcut-downstream-requirements/oa.md +++ /dev/null @@ -1,125 +0,0 @@ -# OA Shortcut 下游能力需求 - -当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-20。 - -本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 - -## 结论与 surface - -- 源码 Shortcut:10。 -- 公开:1(`+search-forms`)。 -- 隐藏:9,其中 unavailable 8;`+done-approvals` 是 available 但隐藏的兼容入口。 -- 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 - -### Residual-gap 分类 - -| Shortcut / 用户路线 | 最终分类 | 依据 | -|---|---|---| -| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 2、保证零命中 0,稳定身份集合一致;public | -| `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | -| `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | -| `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | -| `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | -| `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | -| `+pending` | unavailable | 隐藏兼容入口路由到同一受阻待办能力,没有独立安全 fixture | -| `+list-cc` | delivery hardened but downstream blocked | exact 与 raw 隔离到错型业务状态与 `null` 集合,不能解释为合法零项 | -| `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | -| 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | - -OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unlocked,1 条兼容路由,6 条 delivery hardened but downstream blocked,2 条业务能力 unavailable;Catalog unavailable 共 8 条。 - -### Current HEAD 公开 Shortcut 双层发布矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | -|---|---|---|---|---| -| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 2/2,稳定 `processCode` 集合相等;随机唯一查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | - -该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 - -| Exact Shortcut 证明 | 标签 | 聚合事实 | -|---|---|---| -| `+list-executed` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+list-submitted` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+my-initiated` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | -| `+search-forms` | PASS | current HEAD 已知非空 2、保证零命中 0;exact/raw 稳定身份集合一致 | -| `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | -| `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | -| `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | -| `+list-pending` fail-closed | PASS | exact 与 raw 均隔离到布尔成功、对象结果、`null` 集合;拒绝伪造空数组 | -| `+pending` fail-closed | PASS | 与主入口一致拒绝 `null` 集合;没有独立宽松回退 | -| `+list-cc` fail-closed | PASS | exact 与 raw 均拒绝错型业务状态及 `null` 集合 | - -未列为 fully unlocked 的能力没有安全的已知非空或可逆写 fixture,或下游响应合同本身不合法;`null` 集合不是显式空结果,不能替代合法零项证明。 - -## Lark 1.0.87 用户任务映射 - -Lark approval 共 14 个任务;映射按用户结果而不是命令名判断。 - -| Lark 任务 | DWS 当前路线 | 分类 | 临时处置 | -|---|---|---|---| -| approvals get | `oa approval form-schema` | routed | 使用原子读取 | -| approvals search | `oa +search-forms` | covered | 公开 Shortcut | -| instances initiated | `oa approval list-submitted` | routed | 两条 Shortcut 因 guaranteed-zero 缺分页证据而 unavailable;使用精确原子读取 | -| instances get | `oa approval detail` | routed | 使用原子读取 | -| instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | -| instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | -| instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起非空可读,但零命中缺分页;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | -| tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | -| tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | -| tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | -| tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | -| tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | -| tasks transfer | `redirect-task` | routed | 无安全 fixture,保持原子入口 | - -DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评论、可回退节点等产品原生任务。它们当前应由精确原子命令承担;在取得稳定 Result、fixture 与写后验证前,不为增加数量而包装 Shortcut。 - -## `DS-oa-001`:可证明前进的可见表单分页 - -- 优先级:P1;类型:adapter defect / contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`oa +list-forms` 当前 unavailable。Exact Shortcut 与同参数 atomic/raw 均显示非空列表,但响应没有 `hasMore`、`nextCursor` 或等价 continuation;改变 cursor 后集合高度重叠,已排除 Shortcut 投影错误。 -- 所需合同: - - 请求明确 `cursor`(首次为 0)与 `pageSize`(1–100)。 - - 成功必须是布尔 `success=true`,并包含显式 `result.processCodeList` 数组。 - - 每个元素必须包含唯一、非空 `processCode` 和非空名称。 - - 必须返回布尔 `hasMore`;`hasMore=true` 时返回非空、与当前值不同且单调前进的 `nextCursor`;`hasMore=false` 时不得返回非空 continuation。 - - cursor 不得被忽略;重复页、回退游标与循环必须返回稳定非成功错误。 - - 明确页面大小上限、总页/总项边界和合法空首页语义。 -- 验收:Exact Shortcut 与 atomic/raw 对同一脱敏 fixture 逐页得到不重复稳定身份集合;已知非空和保证零命中均通过;缺失、重复、循环和错型 continuation 都返回非零。 -- 临时处置:`+list-forms` 保持 hidden/unavailable;需要精确定位定义时使用公开 `+search-forms`。 - -## `DS-oa-002`:可重复的审批读写 E2E fixture - -- 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 -- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。当前 raw 待办集合为 `null`;抄送同时存在错型业务状态与 `null` 集合,二者都不是合法空页。 -- 所需 fixture: - - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 - - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 - - 写操作可在隔离资源上完成,且具有确定的恢复、撤回或自动到期路径。 - - fixture 权限最小化,支持在测试结束后验证零残留;不得复用真实业务审批。 -- 验收:每个 list/search 同时通过已知非空与保证零命中;每个写在确认前 0 调用,确认后只写一次,按同一实例和 taskId 精确读回终态并完成清理。 -- 临时处置:相关 Shortcut 保持 hidden/unavailable;精确原子写仅在用户明确确认并提供真实目标时执行,不记为自动化 E2E PASS。 - -## `DS-oa-003`:审批写入的稳定终态回执与读回语义 - -- 优先级:P1;类型:contract insufficient;Owner:OA 业务服务与 MCP adapter。 -- 影响:即使写调用返回 nominal success,也难以区分已提交、处理中、已生效与 commit-unknown,阻止安全公开 `+approve-by` 及其他复合写任务。 -- 所需合同: - - 写回执必须包含 `success=true`、稳定 `processInstanceId`、稳定 `taskId`、终态或 typed pending 状态及操作类型。 - - `success=false`、缺 success、空响应、错型 ID、业务错误码冲突必须非零;不得以请求 echo 作为成功证据。 - - 同一 taskId 的查询必须提供明确 pending/approved/rejected/transferred/rolled_back 状态,或明确从待处理集合移除的强一致性窗口。 - - 异步操作返回可执行查询动作、轮询边界和 terminal failure;超时保留 commit-unknown,禁止自动重试非幂等写。 - - 批量/多步操作提供逐项 ledger;任一失败不得返回整体 success。 -- 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 -- 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 - -## `DS-oa-004`:编号列表零命中的分页终态 - -- 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。先前同租户隔离诊断的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。这些非公开入口未计入本轮 current-HEAD 发布 PASS。 -- 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 -- 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 - -## 超越 Lark 的机会 - -在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md deleted file mode 100644 index e3558f61..00000000 --- a/docs/shortcut-downstream-requirements/report.md +++ /dev/null @@ -1,76 +0,0 @@ -# Report Shortcut 下游业务能力需求规格 - -> 日期:2026-08-20 -> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` -> 对比基线:lark-cli 1.0.87(无 Report 产品域) -> 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 - -## 1. 最终 surface - -- 源码 Shortcut:4;公开:4;unavailable:0。 -- `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 -- 本轮从近期起有界扫描 15 个连续 20 天窗口:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 -- Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 - -## 2. Residual-gap 分类 - -| 能力 | 最终分类 | PASS 证据 | 公开状态 | -|---|---|---|---| -| `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | -| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:16 页、总计 308 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | -| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:15 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | -| `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | -| 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | -| 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | -| Report Shortcut residual unavailable | unavailable | `PASS-RPT-SURFACE-CLOSURE`:源码 4 条中为 0 | 无 | - -Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked” 项。未来若增加写 Shortcut,仍需先解决下文的写 fixture 生命周期,不得把四条读取已解锁解释为 Report 写能力也已完成。 - -## 3. Exact Shortcut E2E 矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | -|---|---|---|---|---| -| `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | -| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 16 页总计 308,逐页稳定 ID 集合与 next cursor 一致、全局身份无重复;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | -| `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | -| `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | - -`+inbox-list` 的 current-HEAD 分页证据为 16 页后明确终止,累计 308,逐页 exact/raw 身份一致且全局身份无重复;`+outbox-list` 的 fixture 扫描为 15 个连续 20 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 - -所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 - -实现与严格响应测试位于 `internal/shortcut/report/report.go`、`internal/shortcut/report/latest.go`、`internal/shortcut/report/common.go` 和 `internal/shortcut/report/report_strict_cross_platform_coverage_test.go`;原子命令实现位于 `internal/helpers/report.go`。本文只保留命令模板、PASS 标签与聚合事实,运行时稳定 ID、标题和原始响应均未落盘。 - -## 4. 已关闭需求 - -### `DS-Report-001` — 非空发件箱与最新详情链路 - -- 状态:`PASS-RPT-RESIDUAL-CLOSED`。 -- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。本轮从近期起按 20 天窗口有界扫描,在第 15 个窗口发现 1 个非空窗口。 -- 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 -- 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 - -## 5. 未解决但不阻塞当前 Shortcut 的下游缺口 - -### `DS-Report-002` — Report 写 fixture 生命周期 - -- 分类:routed;影响未来日志提交类 Shortcut,不影响当前四条只读 Shortcut。 -- 当前事实:原子层可提交日志,但没有按稳定回执删除、回收或证明自动过期的能力;因此本轮写调用数为 0。 -- 解锁条件:专用无业务内容 fixture,确认前 0 写调用,确认后稳定创建回执、精确读回、无条件清理,以及发件箱和详情的零残留证明。 - -## 6. Lark 对齐 - -lark-cli 1.0.87 没有 Report/日志产品域,四条公开 Shortcut 都是 DWS 原生增量能力,不虚构一对一映射: - -| DWS 用户任务 | Lark 映射 | 结论 | -|---|---|---| -| 搜索日志模板 | 无 | DWS 原生;fully unlocked | -| 查看收到的日志 | 无 | DWS 原生;fully unlocked | -| 查看发出的日志 | 无 | DWS 原生;fully unlocked | -| 读取指定窗口内最新日志详情 | 无 | DWS 原生;fully unlocked | - -## 7. 安全与脱敏 - -- PASS:本文不含真实 ID、标题、人名、邮箱、正文、精确业务时间、原始响应、trace/request ID、token 或签名 URL。 -- PASS:真实证据只保留命令级标签和聚合计数,无法反推出具体资源。 -- PASS:没有为测试创建 Report 数据,因此没有新增业务残留。 diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go index f944093d..faf2408c 100644 --- a/internal/shortcut/builtin/ding_semantic_catalog_test.go +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -32,7 +32,12 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) } - public, unavailable := 0, 0 + wantCompatibilityVisible := map[string]bool{ + "+list": true, + "+recall-personal": true, + "+send-personal": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { record, ok := source.Shortcuts[command] @@ -59,13 +64,21 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) } } else { - if !item.Hidden || shortcut.InPublicCatalog("ding", command) { - t.Errorf("%s nonpublic shortcut is visible", command) + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) } if availability == shortcut.AvailabilityUnavailable { unavailable++ } } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -77,7 +90,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 4 { - t.Fatalf("public/unavailable=%d/%d, want 1/4", public, unavailable) + if public != 1 || unavailable != 4 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/4/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index e5ae28f9..b6e63d4b 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -32,7 +32,15 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) } - public, unavailable := 0, 0 + wantCompatibilityVisible := map[string]bool{ + "+list-cc": true, + "+list-executed": true, + "+list-forms": true, + "+list-pending": true, + "+list-submitted": true, + "+my-initiated": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { record, ok := source.Shortcuts[command] @@ -59,13 +67,21 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) } } else { - if !item.Hidden || shortcut.InPublicCatalog("oa", command) { - t.Errorf("%s nonpublic shortcut is visible", command) + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) } if availability == shortcut.AvailabilityUnavailable { unavailable++ } } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -77,7 +93,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 8 { - t.Fatalf("public/unavailable=%d/%d, want 1/8", public, unavailable) + if public != 1 || unavailable != 8 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/8/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index d1a12dfb..d2abf1a8 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -8,6 +8,7 @@ "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+receiver-status": { @@ -23,6 +24,7 @@ "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", "risk": "write", "public": false, + "compatibility_visible": true, "reviewed": true }, "+send-by-message": { @@ -37,6 +39,7 @@ "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", "risk": "high-risk-write", "public": false, + "compatibility_visible": true, "reviewed": true } } diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 262d6465..7ebe91a6 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -8,6 +8,7 @@ "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+list-submitted": { @@ -15,6 +16,7 @@ "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+my-initiated": { @@ -23,6 +25,7 @@ "risk": "read", "primary": "+list-submitted", "public": false, + "compatibility_visible": true, "reviewed": true }, "+search-forms": { @@ -47,6 +50,7 @@ "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+list-pending": { @@ -54,6 +58,7 @@ "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+pending": { @@ -69,6 +74,7 @@ "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+approve-by": { From b19c52f61b50bd9b82eee5c71b38e9486b15a04c Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:20:01 +0800 Subject: [PATCH 242/267] fix(oa): make approval keyword normalization explicit --- internal/shortcut/oa/smart.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 134fc0fd..cc409b9e 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -187,13 +187,14 @@ var Approve = shortcut.Shortcut{ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("keyword") == "" { + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { return apperrors.NewValidation("--keyword 不能为空") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - keyword := rt.Str("keyword") + keyword := strings.TrimSpace(rt.Str("keyword")) now := time.Now() pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, From 56c5fb35b8f01fc1b61fb9d257ee36ce5a78cadd Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:49:32 +0800 Subject: [PATCH 243/267] chore(policy): retire completed flag migrations --- .../approved-flag-migrations-v1.json | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1474dd0..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -71,13 +71,29 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "file" + } }, "canonical": { "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." @@ -86,13 +102,29 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "content" + } }, "canonical": { "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." From 5c9f7380120e7e81f07a8024d2725833c05406af Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 17:08:30 +0800 Subject: [PATCH 244/267] fix(shortcuts): preserve published schema bindings --- internal/shortcut/ding/ding.go | 22 +++++++++---------- ...ing_strict_cross_platform_coverage_test.go | 9 +++++--- internal/shortcut/oa/oa.go | 12 +++++----- internal/shortcut/oa/smart.go | 6 ++--- internal/shortcut/report/report.go | 8 +++---- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 196250cd..d63ed23c 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -29,7 +29,7 @@ var List = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型", Enum: []string{"ALL", "UNREAD", "SEND", "NEW_COMMENT", "DELETED"}}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, @@ -74,7 +74,7 @@ var ReceiverStatus = shortcut.Shortcut{ "+receiver-status", "查询 DING 消息接收人已读状态", "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", true, dingReceiverResult(), nil, - []contract.ParamDecl{{Name: "ding-id", Property: "openDingId"}}, + []contract.ParamDecl{{Name: "ding-id", Property: "dingId"}}, "dws ding +receiver-status --ding-id ", ), Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, @@ -101,16 +101,16 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = unavailableDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", - shortcut.RiskWrite, false, + shortcut.RiskWrite, false, true, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"}, {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, []contract.ParamDecl{ - {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, - {Name: "content", Property: "content"}, {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, + {Name: "users", Property: "users", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, }, "dws ding +send-personal --users --content ", ) @@ -118,7 +118,7 @@ var SendPersonal = unavailableDingWrite( var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", - shortcut.RiskWrite, false, + shortcut.RiskWrite, false, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, @@ -137,18 +137,18 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = unavailableDingWrite( "+recall-personal", "撤回本人发起的 DING", "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", - shortcut.RiskHighWrite, true, + shortcut.RiskHighWrite, true, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, - []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, + []contract.ParamDecl{{Name: "id", Property: "id"}}, "dws ding +recall-personal --id ", ) -func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive, schemaCompatible bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Contract: dingContract(command, description, intent, schemaCompatible, nil, nil, params, example), Flags: flags, Tips: []string{example}, Execute: func(*shortcut.RuntimeContext) error { return dingUnavailable("ding/" + command) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index a5dace6e..493993d4 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -93,11 +93,14 @@ func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T t.Errorf("%s is not interface-available", declaration.Command) } } - for _, declaration := range []shortcut.Shortcut{SendPersonal, SendByMessage, RecallPersonal} { - if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { - t.Errorf("%s write safety/interface drift", declaration.Command) + for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s compatibility-visible write safety/interface drift", declaration.Command) } } + if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s write safety/interface drift", SendByMessage.Command) + } if List.Contract.Pagination == nil { t.Fatal("+list lacks cursor pagination") } diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 1313a8b9..17154691 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -59,10 +59,10 @@ var ListPending = shortcut.Shortcut{ oaCollectionResult("instances", "严格验证的待处理审批实例页"), oaPagePagination("page"), []contract.ParamDecl{ - {Name: "start", Property: "starTime"}, - {Name: "end", Property: "endTime"}, - {Name: "page", Property: "pageNum"}, - {Name: "limit", Property: "pageSize"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "page", Property: "page"}, + {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}, }, "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", @@ -117,7 +117,7 @@ var ListForms = shortcut.Shortcut{ "+list-forms", "获取当前用户可见的审批表单列表", "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), - []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "pageSize"}}, + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, "dws oa +list-forms --cursor 0 --limit 100", ), Flags: []shortcut.Flag{ @@ -198,7 +198,7 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract(command, description, intent, oaCollectionResult("instances", description), oaPagePagination("page"), - []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, + []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), Flags: []shortcut.Flag{ {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index cc409b9e..655b44bc 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -47,7 +47,7 @@ var PendingApprovals = shortcut.Shortcut{ "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", oaCollectionResult("pending", "严格验证的待审批摘要"), nil, - []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", ), Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, @@ -79,7 +79,7 @@ var DoneApprovals = shortcut.Shortcut{ "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, - []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", ), Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, @@ -110,7 +110,7 @@ var MyInitiated = shortcut.Shortcut{ "+my-initiated", "列出我发起(提交)的审批单据", "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), - []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}}, + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", ), Flags: []shortcut.Flag{ diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 7413db26..9fb8c52b 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -86,7 +86,7 @@ var InboxList = shortcut.Shortcut{ "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), []contract.ParamDecl{ - {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, @@ -153,9 +153,9 @@ var OutboxList = shortcut.Shortcut{ reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), []contract.ParamDecl{ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, - {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, - {Name: "modified-start", Property: "modifiedStartTime"}, {Name: "modified-end", Property: "modifiedEndTime"}, - {Name: "template-name", Property: "report_template_name"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"}, + {Name: "template-name", Property: "templateName"}, }, "dws report +outbox-list --cursor 0 --size 20", ), From 43ba4667839964b4bb1ab6aec56b9b7631e3b65d Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 20:28:35 +0800 Subject: [PATCH 245/267] fix(sheet): require fresh confirmation for version revert --- internal/helpers/sheet_version.go | 2 +- internal/helpers/sheet_version_test.go | 35 +++++++++++++++++++ .../products/sheet/sheet-version.md | 13 +++---- .../references/sheet/sheet-version.md | 13 +++---- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index a07fe014..a1a8c7c4 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -118,7 +118,7 @@ func newSheetVersionCmd() *cobra.Command { 通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时, 也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入 版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`, - Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`, + Example: ` dws sheet version revert --node SHEET_ID --version 3`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { diff --git a/internal/helpers/sheet_version_test.go b/internal/helpers/sheet_version_test.go index 12de7ba8..3288f64b 100644 --- a/internal/helpers/sheet_version_test.go +++ b/internal/helpers/sheet_version_test.go @@ -15,6 +15,7 @@ package helpers import ( "bytes" + "reflect" "strings" "testing" ) @@ -40,4 +41,38 @@ func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) { t.Fatalf("sheet version revert help missing %q:\n%s", expected, help) } } + if strings.Contains(help, "--yes") { + t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help) + } +} + +func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) { + args := []string{"version", "revert", "--node", "node-1", "--version", "37"} + + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...) + requireTypedConfirmationError(t, err) + if len(caller.calls) != 0 { + t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls) + } + + caller = &guardedMutationCaller{} + confirmedArgs := append(append([]string(nil), args...), "--yes") + if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil { + t.Fatalf("sheet version revert after confirmation returned error: %v", err) + } + want := guardedMutationCall{ + productID: "doc", + toolName: "revert_doc_version", + args: map[string]any{ + "nodeId": "node-1", + "version": 37, + }, + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls) + } + if !reflect.DeepEqual(caller.calls[0], want) { + t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want) + } } diff --git a/skills/mono/references/products/sheet/sheet-version.md b/skills/mono/references/products/sheet/sheet-version.md index 2217bb16..928e9ad4 100644 --- a/skills/mono/references/products/sheet/sheet-version.md +++ b/skills/mono/references/products/sheet/sheet-version.md @@ -45,13 +45,13 @@ Flags: Usage: dws sheet version revert [flags] Example: - dws sheet version revert --node SHEET_ID --version 3 --yes + dws sheet version revert --node SHEET_ID --version 3 Flags: --node string 表格文档 ID 或 URL (必填) --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本只有在已经取得用户确认时才可加全局 `--yes`。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。 `version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 @@ -61,9 +61,10 @@ Flags: 1. 用 `revision-get` 记录回滚前的当前 revision。 2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 -3. 向用户确认目标 revision 及当前内容将被覆盖,再执行 `version revert --version --yes`。 -4. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 -5. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 +3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。 +4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。 +5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 @@ -76,7 +77,7 @@ Flags: ## 注意事项 -- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` +- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认 - ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision - ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 - ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md index 2217bb16..928e9ad4 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md @@ -45,13 +45,13 @@ Flags: Usage: dws sheet version revert [flags] Example: - dws sheet version revert --node SHEET_ID --version 3 --yes + dws sheet version revert --node SHEET_ID --version 3 Flags: --node string 表格文档 ID 或 URL (必填) --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本只有在已经取得用户确认时才可加全局 `--yes`。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。 `version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 @@ -61,9 +61,10 @@ Flags: 1. 用 `revision-get` 记录回滚前的当前 revision。 2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 -3. 向用户确认目标 revision 及当前内容将被覆盖,再执行 `version revert --version --yes`。 -4. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 -5. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 +3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。 +4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。 +5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 @@ -76,7 +77,7 @@ Flags: ## 注意事项 -- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` +- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认 - ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision - ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 - ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) From 33631502c968130f950f99089f83cb2082b63eb9 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 20:58:02 +0800 Subject: [PATCH 246/267] fix(shortcuts): align unavailable writes and query validation --- internal/shortcut/ding/ding.go | 56 ++++++++++++-- ...ing_strict_cross_platform_coverage_test.go | 75 +++++++++++++------ internal/shortcut/oa/oa.go | 4 +- .../oa_strict_cross_platform_coverage_test.go | 10 ++- 4 files changed, 112 insertions(+), 33 deletions(-) diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index d63ed23c..8c923b92 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -6,6 +6,7 @@ package ding import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -98,10 +99,10 @@ var ReceiverStatus = shortcut.Shortcut{ }, } -var SendPersonal = unavailableDingWrite( +var SendPersonal = compatibilityDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", - shortcut.RiskWrite, false, true, + shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, @@ -113,12 +114,27 @@ var SendPersonal = unavailableDingWrite( {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, }, "dws ding +send-personal --users --content ", + func(rt *shortcut.RuntimeContext) error { + remindType, err := dingPersonalRemindType(rt.Str("type")) + if err != nil { + return err + } + params := map[string]any{ + "receiverOpenDingTalkIds": rt.StrSlice("users"), + "content": rt.Str("content"), + "remindType": remindType, + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("send_personal_ding", params) + }, ) var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", - shortcut.RiskWrite, false, false, + shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, @@ -134,21 +150,34 @@ var SendByMessage = unavailableDingWrite( "dws ding +send-by-message --group --message-id --users ", ) -var RecallPersonal = unavailableDingWrite( +var RecallPersonal = compatibilityDingWrite( "+recall-personal", "撤回本人发起的 DING", "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", - shortcut.RiskHighWrite, true, true, + shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "id"}}, "dws ding +recall-personal --id ", + func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")}) + }, ) -func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive, schemaCompatible bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { +func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, schemaCompatible, nil, nil, params, example), + Contract: dingContract(command, description, intent, true, nil, nil, params, example), + Flags: flags, Tips: []string{example}, Execute: execute, + } +} + +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), Flags: flags, Tips: []string{example}, Execute: func(*shortcut.RuntimeContext) error { return dingUnavailable("ding/" + command) @@ -156,6 +185,19 @@ func unavailableDingWrite(command, description, intent string, risk shortcut.Ris } } +func dingPersonalRemindType(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "app": + return "APP", nil + case "sms": + return "SMS", nil + case "call": + return "PHONE", nil + default: + return "", apperrors.NewValidation("--type 必须是 app、sms 或 call") + } +} + func init() { shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index 493993d4..90e1bc8f 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -95,11 +95,11 @@ func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T } for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { - t.Errorf("%s compatibility-visible write safety/interface drift", declaration.Command) + t.Errorf("%s compatibility write safety/interface drift", declaration.Command) } } if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { - t.Errorf("%s write safety/interface drift", SendByMessage.Command) + t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command) } if List.Contract.Pagination == nil { t.Fatal("+list lacks cursor pagination") @@ -348,27 +348,58 @@ func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testin } func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { - cases := []struct { - declaration shortcut.Shortcut - args []string - }{ - {SendPersonal, []string{"--users", "D-fixture", "--content", "fixture"}}, - {SendByMessage, []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}}, - {RecallPersonal, []string{"--id", "ding-fixture"}}, + args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"} + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, SendByMessage, unconfirmed, false, args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) } - for _, test := range cases { - t.Run(test.declaration.Command, func(t *testing.T) { - unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} - err := runDingRoot(t, test.declaration, unconfirmed, false, test.args...) - if err == nil || len(unconfirmed.history) != 0 { - t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) - } - confirmed := &dingCoverageCaller{responses: map[string][]string{}} - err = runDingRoot(t, test.declaration, confirmed, true, test.args...) - if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { - t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) - } - }) + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, SendByMessage, confirmed, true, args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } +} + +func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} + unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { + t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history) + } + confirmedSend := &dingCoverageCaller{responses: map[string][]string{ + "send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`}, + }} + if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil { + t.Fatalf("confirmed send failed: %v", err) + } + if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 { + t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments) + } + sendParams := confirmedSend.arguments[0] + users, _ := sendParams["receiverOpenDingTalkIds"].([]string) + if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" { + t.Fatalf("confirmed send params=%#v", sendParams) + } + + invalidType := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 { + t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history) + } + + recallArgs := []string{"--id", "ding-fixture"} + unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 { + t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history) + } + confirmedRecall := &dingCoverageCaller{responses: map[string][]string{ + "recall_personal_ding": {`{"success":true,"result":true}`}, + }} + if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil { + t.Fatalf("confirmed recall failed: %v", err) + } + if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" { + t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments) } } diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 17154691..7e062b92 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -172,14 +172,14 @@ var SearchForms = shortcut.Shortcut{ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("query") == "" { + if strings.TrimSpace(rt.Str("query")) == "" { return apperrors.NewValidation("--query 不能为空") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/search_form" - data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": rt.Str("query")}) + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))}) if err != nil { return err } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index 57d783f7..adcaca63 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -499,7 +499,10 @@ func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { }) t.Run("search empty query", func(t *testing.T) { - expectError(t, SearchForms, map[string][]string{}, "--query", " ") + caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ") + if len(caller.history) != 0 { + t.Fatalf("blank query made calls: %v", caller.history) + } }) t.Run("search call failure", func(t *testing.T) { expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") @@ -508,7 +511,10 @@ func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") }) t.Run("search known nonempty", func(t *testing.T) { - expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", "fixture") + caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ") + if got := caller.arguments[0]["query"]; got != "fixture" { + t.Fatalf("normalized query=%#v, want fixture", got) + } }) t.Run("search legitimate empty", func(t *testing.T) { expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") From bda408d9662a32a5cf0a8acaeed7b6e6fd4d44d3 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 21:10:45 +0800 Subject: [PATCH 247/267] test(ding): cover compatibility reminder mappings --- .../ding/ding_strict_cross_platform_coverage_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index 90e1bc8f..f5340cf3 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -362,6 +362,13 @@ func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { } func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want) + } + } + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { From fe66ac18a4b8806f187c375dd5c9c2012cc9c486 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 21:19:55 +0800 Subject: [PATCH 248/267] feat(policy): govern flag requiredness changes --- docs/cli-interface-flag-migrations.md | 46 +++- internal/interfacesnapshot/migrations.go | 143 ++++++++-- internal/interfacesnapshot/migrations_test.go | 259 ++++++++++++++++++ .../schema-compat/command_migrations_test.go | 23 ++ scripts/policy/schema-compat/main.go | 79 +++++- scripts/policy/schema-compat/main_test.go | 138 ++++++++++ 6 files changed, 662 insertions(+), 26 deletions(-) diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index b349d95c..96acf2cb 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -1,6 +1,11 @@ # CLI Help / Schema 兼容迁移治理 -本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。 +本文定义两种受控 flag 迁移: + +1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。 +2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。 + +两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。 同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。 @@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json scripts/policy/interface-migrations/approved-command-migrations-v1.json ``` -清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 +清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 + +首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。 ## 跨命令迁移原语 @@ -103,12 +110,12 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` ## 两阶段迁移与回执清理 -每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: +rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期: | 阶段 | PR 可以做什么 | 必须满足的快照状态 | |---|---|---| | 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 | -| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 | +| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required | | 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` | | 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 | @@ -157,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。 +同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批: + +```json +{ + "version": 1, + "migrations": [ + { + "kind": "requiredness_change", + "command": "dws report entry submit", + "flag": { + "name": "to-user-ids", + "before": {"present": true, "type": "string", "scope": "local"}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reject report submissions that have no visible recipient." + } + ] +} +``` + ## `alias_of` 是框架来源的受评审关系证据 `alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证: @@ -177,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` ## 豁免边界 -一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding: +一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding: 1. legacy flag 的 `flag_became_hidden`(visible → hidden); 2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。 +3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。 以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖: @@ -188,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` - flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移; - `alias_of` 缺失、指向变化或 alias chain; - 命令路径及任何无关的阻塞性接口变化; +- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移; - 不精确、部分完成、超出记录范围的 surface 变化。 ## Schema 投影边界 @@ -215,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker 验证 candidate 是否仍与该 canonical contract 兼容。 +`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与 +`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter +不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、 +format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker, +任何不相干漂移继续阻塞。 + ## 本地验证 先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁: diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index d1e7f3d5..6d50aa2c 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1 const ( FlagMigrationPending = "pending" FlagMigrationConsumed = "consumed" + + FlagMigrationRename = "flag_rename" + FlagMigrationRequirednessChange = "requiredness_change" ) type FlagMigrationManifest struct { @@ -25,11 +28,13 @@ type FlagMigrationManifest struct { } type FlagMigration struct { - Command string `json:"command"` - Legacy FlagMigrationSide `json:"legacy"` - Canonical FlagMigrationSide `json:"canonical"` - State string `json:"state"` - Reason string `json:"reason"` + Kind string `json:"kind,omitempty"` + Command string `json:"command"` + Legacy FlagMigrationSide `json:"legacy"` + Canonical FlagMigrationSide `json:"canonical"` + Flag *FlagMigrationSide `json:"flag,omitempty"` + State string `json:"state"` + Reason string `json:"reason"` } type FlagMigrationSide struct { @@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error { } legacyTargets[legacyKey] = migration.Canonical.Name } + for _, requiredness := range m.Migrations { + if requiredness.EffectiveKind() != FlagMigrationRequirednessChange { + continue + } + for _, rename := range m.Migrations { + if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command { + continue + } + if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name { + return fmt.Errorf( + "flag requiredness migration %s overlaps rename migration %s", + requiredness.displayKey(), + rename.displayKey(), + ) + } + } + } return nil } @@ -294,15 +316,6 @@ func (m FlagMigration) validate() error { if !isExactCommandPath(m.Command) { return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command) } - if !isExactFlagName(m.Legacy.Name) { - return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name) - } - if !isExactFlagName(m.Canonical.Name) { - return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name) - } - if m.Legacy.Name == m.Canonical.Name { - return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name) - } if strings.TrimSpace(m.Reason) == "" { return fmt.Errorf("migration must include a non-empty reason") } @@ -312,6 +325,30 @@ func (m FlagMigration) validate() error { if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed { return fmt.Errorf("invalid state %q", m.State) } + + switch m.EffectiveKind() { + case FlagMigrationRename: + return m.validateRename() + case FlagMigrationRequirednessChange: + return m.validateRequirednessChange() + default: + return fmt.Errorf("invalid kind %q", m.Kind) + } +} + +func (m FlagMigration) validateRename() error { + if m.Flag != nil { + return fmt.Errorf("flag rename must not declare flag requiredness fields") + } + if !isExactFlagName(m.Legacy.Name) { + return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name) + } + if !isExactFlagName(m.Canonical.Name) { + return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name) + } + if m.Legacy.Name == m.Canonical.Name { + return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name) + } if err := m.Legacy.Before.validate("legacy before"); err != nil { return err } @@ -392,6 +429,45 @@ func (m FlagMigration) validate() error { return nil } +func (m FlagMigration) validateRequirednessChange() error { + if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) { + return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields") + } + if m.Flag == nil || !isExactFlagName(m.Flag.Name) { + return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag)) + } + if err := m.Flag.Before.validate("flag before"); err != nil { + return err + } + if err := m.Flag.After.validate("flag after"); err != nil { + return err + } + if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required { + return fmt.Errorf("flag requiredness migration must change exactly from optional to required") + } + if m.Flag.Before.Hidden || m.Flag.After.Hidden { + return fmt.Errorf("flag requiredness migration flag must stay visible") + } + if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" { + return fmt.Errorf("flag requiredness migration must not declare alias_of") + } + before := m.Flag.Before + after := m.Flag.After + before.Required = false + after.Required = false + if before != after { + return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness") + } + return nil +} + +func flagMigrationSideName(side *FlagMigrationSide) string { + if side == nil { + return "" + } + return side.Name +} + func (s FlagMigrationState) validate(label string) error { if !s.Present { if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" { @@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error { } func (m FlagMigration) key() string { - return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name + if m.EffectiveKind() == FlagMigrationRequirednessChange { + return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag) + } + return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name +} + +// EffectiveKind keeps manifests written before kinds were introduced valid. +// An omitted kind is the original flag rename primitive. +func (m FlagMigration) EffectiveKind() string { + if m.Kind == "" { + return FlagMigrationRename + } + return m.Kind } func isExactCommandPath(path string) bool { @@ -428,9 +516,9 @@ func isExactFlagName(name string) bool { } // CompareAllWithFlagMigrations applies the ordinary compatibility policy and -// then consumes only exact, merge-base-owned flag migrations. Candidate-owned -// records participate in the lifecycle check, but never authorize their own -// interface change. +// then consumes only exact, merge-base-owned flag rename or requiredness +// migrations. Candidate-owned records participate in the lifecycle check, but +// never authorize their own interface change. func CompareAllWithFlagMigrations( current Snapshot, references map[string]Snapshot, @@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig if !exists { return flagMigrationPartial } + if migration.EffectiveKind() == FlagMigrationRequirednessChange { + state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag)) + if state == migration.Flag.Before { + return flagMigrationBefore + } + if state == migration.Flag.After { + return flagMigrationAfter + } + return flagMigrationPartial + } legacy := flagMigrationStateForCommand(command, migration.Legacy.Name) canonical := flagMigrationStateForCommand(command, migration.Canonical.Name) if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before { @@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange( matchFlagMigrationPhase(current, migration) != flagMigrationAfter { continue } + if migration.EffectiveKind() == FlagMigrationRequirednessChange { + if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" { + return true + } + continue + } if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" { return true } @@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange( } func (m FlagMigration) displayKey() string { + if m.EffectiveKind() == FlagMigrationRequirednessChange { + return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag)) + } return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name) } diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go index f9c406f7..4b3e9e75 100644 --- a/internal/interfacesnapshot/migrations_test.go +++ b/internal/interfacesnapshot/migrations_test.go @@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{ ] }` +const validRequirednessMigrationManifestJSON = `{ + "version": 1, + "migrations": [ + { + "kind": "requiredness_change", + "command": "dws report entry submit", + "flag": { + "name": "to-user-ids", + "before": {"present": true, "type": "string", "scope": "local"}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reject report submissions that have no visible recipient." + } + ] +}` + func optionalFlagMigrationManifestJSON() string { manifest := strings.Replace( validFlagMigrationManifestJSON, @@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil { t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err) } + if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil { + t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err) + } tests := []struct { name string @@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * } } +func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "unknown kind", + input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1), + wantErr: "invalid kind", + }, + { + name: "missing flag name", + input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1), + wantErr: "exact flag", + }, + { + name: "invalid before state", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"before": {"present": true, "type": "string", "scope": "local"}`, + `"before": {"present": true, "type": "string", "scope": "anything"}`, + 1, + ), + wantErr: "flag before present state has invalid scope", + }, + { + name: "invalid after state", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`, + 1, + ), + wantErr: "flag after present state has invalid scope", + }, + { + name: "before already required", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"before": {"present": true, "type": "string", "scope": "local"}`, + `"before": {"present": true, "type": "string", "required": true, "scope": "local"}`, + 1, + ), + wantErr: "optional to required", + }, + { + name: "after remains optional", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "scope": "local"}`, + 1, + ), + wantErr: "optional to required", + }, + { + name: "type drift", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`, + 1, + ), + wantErr: "must preserve every flag attribute except requiredness", + }, + { + name: "visibility drift", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, + 1, + ), + wantErr: "must stay visible", + }, + { + name: "alias relation", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`, + 1, + ), + wantErr: "must not declare alias_of", + }, + { + name: "rename fields cannot be combined", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"flag": {`, + `"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`, + 1, + ), + wantErr: "must not declare legacy or canonical", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ReadFlagMigrationManifest(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr) + } + }) + } + + missingFlag := FlagMigration{ + Kind: FlagMigrationRequirednessChange, + Command: "dws report entry submit", + State: FlagMigrationPending, + Reason: "Missing exact flag state.", + } + if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") { + t.Fatalf("missing requiredness flag error = %v", err) + } + + rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + rename.Migrations[0].Kind = FlagMigrationRename + rename.Migrations[0].Flag = &FlagMigrationSide{ + Name: "unrelated", + Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + } + if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") { + t.Fatalf("rename with requiredness fields error = %v", err) + } +} + +func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) { + pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + consumed := pending + consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...) + consumed.Migrations[0].State = FlagMigrationConsumed + migration := pending.Migrations[0] + before := requirednessMigrationSnapshot(migration, false) + after := requirednessMigrationSnapshot(migration, true) + + ordinary := Compare(after, before, "merge-base") + if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) { + t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err) + } + partial := after + partial.Commands = append([]Command(nil), after.Commands...) + partial.Commands[len(partial.Commands)-1].LocalFlags = append( + []Flag(nil), + after.Commands[len(after.Commands)-1].LocalFlags..., + ) + partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice" + if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial { + t.Fatalf("drifted requiredness migration phase = %s, want partial", phase) + } + + beforeWithUnrelated := before + beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...) + beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append( + beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags, + Flag{Name: "unrelated", Type: "string"}, + ) + afterWithUnrelated := after + afterWithUnrelated.Commands = append([]Command(nil), after.Commands...) + afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append( + afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags, + Flag{Name: "unrelated", Type: "string", Required: true}, + ) + report, err = CompareAllWithFlagMigrations( + afterWithUnrelated, + map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated}, + pending, + consumed, + ) + if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") { + t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err) + } + + empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + if _, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error = %v", err) + } +} + +func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot { + state := migration.Flag.Before + if after { + state = migration.Flag.After + } + flag := Flag{ + Name: migration.Flag.Name, + Type: state.Type, + Required: state.Required, + Hidden: state.Hidden, + Shorthand: state.Shorthand, + NoOpt: state.NoOpt, + } + command := testCommandWithFlagScopes(migration.Command, nil, nil) + if state.Scope == "inherited" { + command.InheritedFlags = []Flag{flag} + } else { + command.LocalFlags = []Flag{flag} + } + return testSnapshot(testCommand("dws"), command) +} + func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) { manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)) if err != nil { @@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon if err == nil || !strings.Contains(err.Error(), "trailing") { t.Fatalf("trailing JSON error = %v", err) } + + requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + overlap := manifest + overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...) + rename := overlap.Migrations[0] + rename.Command = requiredness.Migrations[0].Command + rename.Canonical.Name = requiredness.Migrations[0].Flag.Name + rename.Legacy.After.AliasOf = rename.Canonical.Name + overlap.Migrations[0] = rename + overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0]) + if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") { + t.Fatalf("requiredness and rename overlap error = %v", err) + } } func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) { diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 5c6f32fb..613d449d 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -1080,6 +1080,29 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChec t.Fatalf("lineage hid positional drift: %s", failures) } + requiredness := interfacesnapshot.FlagMigration{ + Kind: interfacesnapshot.FlagMigrationRequirednessChange, + Command: "dws report cli-only", + Flag: &interfacesnapshot.FlagMigrationSide{ + Name: "recipient", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + }, + State: interfacesnapshot.FlagMigrationConsumed, + Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.", + } + flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...) + flagsWithRequiredness = append(flagsWithRequiredness, requiredness) + if _, err := normalizeSchemaCommandMigrationLineage( + stable, + base, + current, + flagsWithRequiredness, + schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations, + ); err != nil { + t.Fatalf("requiredness receipt interfered with command lineage: %v", err) + } + // Multiple historical names may converge only when every predecessor carries // the exact same contract and every receipt is already consumed. stable, base, current = schemaCommandLineageContracts() diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 9aa151cc..580eb7b6 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -1261,9 +1261,9 @@ type schemaToolRef struct { } // normalizeSchemaFlagMigrations projects only an already-authorized CLI flag -// rename onto a cloned historical Schema contract. The ordinary compatibility -// checker still makes the final decision; this adapter never drops findings by -// matching their rendered text. +// rename or requiredness change onto a cloned historical Schema contract. The +// ordinary compatibility checker still makes the final decision; this adapter +// never drops findings by matching their rendered text. func normalizeSchemaFlagMigrations( baseline schemaContract, current schemaContract, @@ -1273,6 +1273,14 @@ func normalizeSchemaFlagMigrations( renamesByTool := map[schemaToolRef]map[string]string{} for _, migration := range migrations { + if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange { + var err error + normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration) + if err != nil { + return schemaContract{}, err + } + continue + } primaryPath := strings.TrimPrefix(migration.Command, "dws ") matches := schemaToolsByPrimaryPath(baseline, primaryPath) if len(matches) == 0 { @@ -1394,6 +1402,65 @@ func normalizeSchemaFlagMigrations( return normalized, nil } +func normalizeSchemaFlagRequirednessMigration( + normalized schemaContract, + baseline schemaContract, + current schemaContract, + migration interfacesnapshot.FlagMigration, +) (schemaContract, error) { + primaryPath := strings.TrimPrefix(migration.Command, "dws ") + matches := schemaToolsByPrimaryPath(baseline, primaryPath) + if len(matches) == 0 { + // A reviewed CLI-only command has no Schema compatibility surface. + return normalized, nil + } + if len(matches) != 1 { + return schemaContract{}, fmt.Errorf( + "approved flag requiredness migration %q matches %d historical Schema tools", + migration.Command, + len(matches), + ) + } + + ref := matches[0] + oldTool := baseline.Products[ref.productID].Tools[ref.toolID] + oldParameter, existed := oldTool.Parameters[migration.Flag.Name] + if !existed { + // CLI requiredness is not authority to create or mutate an unrelated + // historical Schema parameter. + return normalized, nil + } + newProduct, productExists := current.Products[ref.productID] + newTool, toolExists := newProduct.Tools[ref.toolID] + if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath { + // Preserve the baseline so the ordinary checker reports the missing tool + // or primary path change. + return normalized, nil + } + newParameter, exists := newTool.Parameters[migration.Flag.Name] + if !exists { + // Preserve the baseline so the ordinary checker reports parameter loss. + return normalized, nil + } + if !newParameter.Required || !newParameter.CLIRequired { + return schemaContract{}, fmt.Errorf( + "approved flag requiredness migration %q Schema parameter %q must be required and cli_required", + migration.Command, + migration.Flag.Name, + ) + } + + normalizedProduct := normalized.Products[ref.productID] + normalizedTool := normalizedProduct.Tools[ref.toolID] + normalizedParameter := oldParameter + normalizedParameter.Required = newParameter.Required + normalizedParameter.CLIRequired = newParameter.CLIRequired + normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter + normalizedProduct.Tools[ref.toolID] = normalizedTool + normalized.Products[ref.productID] = normalizedProduct + return normalized, nil +} + // normalizeSchemaCommandMigrationLineage composes two independently reviewed // migration receipts without inventing a second alias authority. A consumed // flag migration may supply the historical predecessor of a command_move @@ -1482,6 +1549,9 @@ func stageSchemaCommandMigrationPredecessors( for _, parameter := range migration.Schema.Parameters { if _, direct := oldTool.Parameters[parameter.From]; direct { for _, flagMigration := range flagMigrations { + if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename { + continue + } if flagMigration.Command != migration.Legacy.Command || flagMigration.Canonical.Name != parameter.From { continue @@ -1501,6 +1571,9 @@ func stageSchemaCommandMigrationPredecessors( predecessors := make([]interfacesnapshot.FlagMigration, 0, 1) for _, flagMigration := range flagMigrations { + if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename { + continue + } if flagMigration.Command != migration.Legacy.Command || flagMigration.Canonical.Name != parameter.From { continue diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index d1067f0e..9e6c7794 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin } } +func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) { + baseline := schemaContract{ + Version: schemaContractVersion, + Products: map[string]productSchema{ + "report": { + Tools: map[string]toolSchema{ + "report.entry_submit": { + PrimaryCLIPath: "report entry submit", + Parameters: map[string]parameterSchema{ + "to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"}, + }, + }, + }, + }, + }, + } + current := cloneContract(baseline) + product := current.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.Required = true + parameter.CLIRequired = true + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + current.Products["report"] = product + + migration := interfacesnapshot.FlagMigration{ + Kind: interfacesnapshot.FlagMigrationRequirednessChange, + Command: "dws report entry submit", + Flag: &interfacesnapshot.FlagMigrationSide{ + Name: "to-user-ids", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + }, + State: interfacesnapshot.FlagMigrationConsumed, + Reason: "Reject report submissions that have no visible recipient.", + } + normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact requiredness migration should pass after normalization: %v", failures) + } + + t.Run("non-requiredness drift remains blocked", func(t *testing.T) { + drifted := cloneContract(current) + product := drifted.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.Type = `"array"` + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + drifted.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") { + t.Fatalf("requiredness migration hid type drift: %s", failures) + } + }) + + t.Run("partial Schema projection fails closed", func(t *testing.T) { + partial := cloneContract(current) + product := partial.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.CLIRequired = false + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + partial.Products["report"] = product + if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") { + t.Fatalf("partial requiredness projection error = %v", err) + } + }) + + t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) { + cliOnly := migration + cliOnly.Command = "dws report cli-only" + normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly}) + if err != nil || !reflect.DeepEqual(normalized, baseline) { + t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err) + } + }) + + t.Run("duplicate historical path fails closed", func(t *testing.T) { + duplicate := cloneContract(baseline) + product := duplicate.Products["report"] + product.Tools["report.duplicate"] = product.Tools["report.entry_submit"] + duplicate.Products["report"] = product + if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") { + t.Fatalf("duplicate requiredness Schema path error = %v", err) + } + }) + + t.Run("missing historical parameter is not authority", func(t *testing.T) { + missing := cloneContract(baseline) + product := missing.Products["report"] + tool := product.Tools["report.entry_submit"] + delete(tool.Parameters, "to-user-ids") + product.Tools["report.entry_submit"] = tool + missing.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration}) + if err != nil || !reflect.DeepEqual(normalized, missing) { + t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err) + } + }) + + t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) { + missing := cloneContract(current) + delete(missing.Products, "report") + normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(normalized, missing); len(failures) == 0 { + t.Fatal("requiredness migration hid missing current Schema tool") + } + }) + + t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) { + missing := cloneContract(current) + product := missing.Products["report"] + tool := product.Tools["report.entry_submit"] + delete(tool.Parameters, "to-user-ids") + product.Tools["report.entry_submit"] = tool + missing.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") { + t.Fatalf("requiredness migration hid missing current parameter: %s", failures) + } + }) +} + func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) { tests := []struct { name string From 3d59411a1ace3f361d549fffeece9e298e81a53d Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Thu, 20 Aug 2026 21:24:06 +0800 Subject: [PATCH 249/267] refresh slot repair org --- .changes/refresh-org-slot-fallback.md | 11 ++ internal/auth/oauth_helpers.go | 2 + internal/auth/oauth_provider.go | 8 +- .../auth/org_slot_refresh_fallback_test.go | 123 ++++++++++++++++++ internal/auth/token.go | 17 ++- internal/i18n/locales/en.json | 1 + internal/i18n/locales/zh.json | 1 + 7 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 .changes/refresh-org-slot-fallback.md diff --git a/.changes/refresh-org-slot-fallback.md b/.changes/refresh-org-slot-fallback.md new file mode 100644 index 00000000..bb484a97 --- /dev/null +++ b/.changes/refresh-org-slot-fallback.md @@ -0,0 +1,11 @@ +--- +category: Fixed +--- + +- **OAuth refresh falls back to the organization mirror** — when the server rejects the + current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound` + business code, `dws` now retries once with the still-valid token mirrored in the same + organization's slot (same corp, matching or backfilled user identity) before giving up, + and writes the rotated credential back to both the identity and the organization slots so + the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP + rejections without a reviewed business code do not trigger the fallback. diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index fe2c4728..c22a2a24 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName + updated.RepairOrganizationMirror = data.RepairOrganizationMirror if updated.CorpName == "" { updated.CorpName = data.CorpName } @@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName + updated.RepairOrganizationMirror = data.RepairOrganizationMirror if updated.CorpName == "" { updated.CorpName = data.CorpName } diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 2e20d5e5..02ad2380 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -817,7 +817,8 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { } // A stale identity slot can survive an older organization-only refresh. // Retry once with the same-corp organization mirror while holding the - // existing dual lock; the refresh path repairs both persisted slots. + // existing dual lock; the fallback marks the publication so the rotated + // credential is written back into the mirror slot it consumed. logging.AuthDebug( "auth.refresh.fallback.triggered", "corp_id", strings.TrimSpace(data.CorpID), @@ -873,6 +874,11 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa orgData.UserID = current.UserID orgData.UserName = current.UserName } + // The refresh below consumes the mirror's refresh_token. Mark the + // publication so persistence writes the rotated credential back into the + // organization slot even under an explicit runtime selector whose plan + // would otherwise skip it (for example a preserved unresolved sibling). + orgData.RepairOrganizationMirror = true refreshed, err := oauthRefreshToken(p, ctx, orgData) if err != nil { return nil, err diff --git a/internal/auth/org_slot_refresh_fallback_test.go b/internal/auth/org_slot_refresh_fallback_test.go index 1ad3cf4a..c5ab16cf 100644 --- a/internal/auth/org_slot_refresh_fallback_test.go +++ b/internal/auth/org_slot_refresh_fallback_test.go @@ -20,6 +20,8 @@ import ( "io" "log/slog" "net/http" + "net/http/httptest" + "sync/atomic" "testing" "time" @@ -140,6 +142,127 @@ func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) { } } +func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) { + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "legacy", CorpID: "corp-1", UserID: ""}, + {Name: "user-1", CorpID: "corp-1", UserID: "user-1"}, + }, + } + selector := profileSelector("corp-1", "user-1") + + without := &TokenData{CorpID: "corp-1", UserID: "user-1"} + if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization { + t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false") + } + + with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true} + if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization { + t.Fatalf("repair marker did not force the organization slot write") + } +} + +func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) { + isolateOAuthPersistence(t) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + // Fake MCP refresh endpoint: the first call rejects the stale identity + // refresh_token with the reviewed business code; the second call (the + // organization mirror) succeeds and returns a rotated credential. + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if refreshCalls.Add(1) == 1 { + fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`) + return + } + fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + resetAppConfigCache() + + // Seed the pre-fallback state: an identity slot whose refresh_token the + // server rejects, plus a legacy organization mirror (no userId) with a + // still-valid refresh_token and a preserved unresolved sibling profile so + // an explicit --profile refresh would normally skip the org slot. + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"}, + {Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"}, + }, + } + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + orgMirror := &TokenData{ + AccessToken: "org-access", + RefreshToken: "org-refresh", + ExpiresAt: time.Now().Add(-time.Minute), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + Source: "mcp", + ClientID: "mcp-client", + } + if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + staleIdentity := &TokenData{ + AccessToken: "stale-access", + RefreshToken: "stale-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + UserName: "User One", + Source: "mcp", + ClientID: "mcp-client", + } + if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil { + t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err) + } + + SetRuntimeProfile("corp-1:user-1") + t.Cleanup(func() { SetRuntimeProfile("") }) + + p := &OAuthProvider{ + configDir: configDir, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Output: io.Discard, + httpClient: srv.Client(), + } + got, err := p.lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" { + t.Fatalf("lockedRefresh() = %#v, want rotated credential", got) + } + if refreshCalls.Load() != 2 { + t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load()) + } + + // The fallback consumed the mirror's refresh_token: both persisted slots + // must now carry the rotated credential instead of the consumed one. + orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1") + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" { + t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot) + } + identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1") + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err) + } + if identitySlot.RefreshToken != "new-refresh" { + t.Fatalf("identity slot = %#v, want new-refresh", identitySlot) + } +} + func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) { t.Run("transient failure does not fall back", func(t *testing.T) { f := newOrgSlotFallbackFixture(t) diff --git a/internal/auth/token.go b/internal/auth/token.go index c511b45b..90b4e090 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -107,6 +107,13 @@ type TokenData struct { // transient marker to reject ambiguous UID-less logins without breaking // legitimate refreshes of unresolved accounts. FreshAuthorization bool `json:"-"` + // RepairOrganizationMirror marks a fallback refresh that consumed the + // organization mirror's refresh_token. The regular write plan can skip the + // organization slot under an explicit runtime selector (for example when an + // unresolved sibling profile still owns it), which would strand a + // refresh_token the server has already rotated; the marker forces the + // rotated credential back into that slot. + RepairOrganizationMirror bool `json:"-"` } // tokenPersistenceWritePlan is the single source of truth for deciding which @@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct { ExistingIdentity bool UpgradesLegacyProfile bool PreserveUnresolvedOrganization bool + RepairOrganizationMirror bool WriteIdentity bool WriteOrganization bool WriteGlobal bool @@ -167,6 +175,11 @@ func planTokenPersistenceWrites( plan.PreserveUnresolvedOrganization = plan.UserID != "" && unresolvedProfileForCorp(cfg, plan.CorpID) != nil && !plan.UpgradesLegacyProfile + // A fallback refresh consumed the organization mirror's refresh_token; + // the rotated credential must go back into that slot even when the + // selector-driven plan would skip it (for example an explicit --profile + // that preserves an unresolved sibling profile's slot). + plan.RepairOrganizationMirror = data.RepairOrganizationMirror plan.WriteIdentity = plan.UserID != "" orgCurrentSelector := "" if cfg != nil { @@ -177,7 +190,8 @@ func planTokenPersistenceWrites( // reauthorization. Its organization slot must move with the newly exact // identity even when an explicit runtime selector keeps it from becoming // process-global current. - plan.WriteOrganization = plan.UserID == "" || + plan.WriteOrganization = plan.RepairOrganizationMirror || + plan.UserID == "" || plan.UpgradesLegacyProfile || (!plan.PreserveUnresolvedOrganization && (plan.MakeCurrent || @@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { "persistence_profile", plan.PersistenceSelector, "write_identity_slot", plan.WriteIdentity, "write_org_mirror", plan.WriteOrganization, + "repair_org_mirror", plan.RepairOrganizationMirror, "write_global_mirror", plan.WriteGlobal, "publish_incoming_global", plan.MakeCurrent, ) diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 81870ac1..558ffc34 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -80,6 +80,7 @@ "工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.", "已取消操作": "Operation cancelled", "当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s", + "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.", "待办": "todo", "待办任务 ID (必填)": "Todo task ID (required)", "待办任务管理": "Todo task management", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 06b04ac5..29cc29d2 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -80,6 +80,7 @@ "工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。", "已取消操作": "已取消操作", "当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制", + "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态", "待办": "待办", "待办任务 ID (必填)": "待办任务 ID (必填)", "待办任务管理": "待办任务管理", From 422dc0fde31cd5e4d7de039f0470e143d97be822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Thu, 20 Aug 2026 21:44:05 +0800 Subject: [PATCH 250/267] fix ci fail to #666 --- internal/helpers/oa.go | 37 ++++ internal/helpers/oa_attachment_test.go | 190 ++++++++++++++++++++ skills/mono/references/products/oa.md | 5 +- skills/multi/dingtalk-misc/references/oa.md | 5 +- 4 files changed, 233 insertions(+), 4 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 89f883cf..39e51238 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -223,6 +223,43 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { } } + // --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成, + // 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。 + // 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果, + // 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。 + // 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。 + if deps.Caller.DryRun() { + return output.StoreResult(cmd.Context(), output.Success(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "attachment_upload", + "source": "oa", + "file": filePath, + "file_name": fileName, + "file_size": fileSize, + "md5": md5Hex, + "steps": []map[string]any{ + { + "tool": "oa/init_attachment_upload_info", + "args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex}, + "status": "planned", + }, + { + "tool": "HTTP PUT", + "args": map[string]any{"file": filePath, "fileSize": fileSize}, + "status": "planned", + }, + { + "tool": "oa/commit_attachment_upload_info", + "args": map[string]any{"fileName": fileName, "fileSize": fileSize}, + "requires": []string{"uploadKey from oa/init_attachment_upload_info"}, + "status": "planned", + }, + }, + }, output.WithDryRun())) + } + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 5f58206f..0a678025 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strconv" "strings" "testing" @@ -32,6 +33,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo cmd := newOaCommand() cmd.PersistentFlags().Bool("yes", false, "跳过确认") + cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行") cmd.PersistentFlags().String("format", caller.Format(), "输出格式") ctx, _ := output.WithResultStore(context.Background()) cmd.SetContext(ctx) @@ -513,6 +515,11 @@ func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, wantErr: "缺少 resourceUrls", }, + { + name: "empty resourceUrls element", + response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "首个 resourceUrls 为空", + }, { name: "missing headers", response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`, @@ -586,3 +593,186 @@ func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testin }) } } + +// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证 +// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、 +// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。 +func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) { + const content = "dry-run-preview-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content) + wantMD5, err := fileMD5Hex(filePath) + if err != nil { + t.Fatalf("compute expected md5: %v", err) + } + put := mockOAAttachmentPut(t) + + // caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。 + caller := &scriptedToolCaller{format: "json", dry: true} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--dry-run", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.calls != 0 { + t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls) + } + if put.calls != 0 { + t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v\noutput: %s", err, stdout) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified data missing/invalid: %#v", envelope["data"]) + } + if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" { + t.Fatalf("plan flags = %#v", data) + } + if data["operation"] != "attachment_upload" || data["source"] != "oa" { + t.Fatalf("plan operation/source = %#v", data) + } + if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 { + t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5) + } + steps, ok := data["steps"].([]any) + if !ok || len(steps) != 3 { + t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"]) + } + wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"} + for i, raw := range steps { + step, ok := raw.(map[string]any) + if !ok { + t.Fatalf("step %d not an object: %#v", i, raw) + } + if step["tool"] != wantTools[i] { + t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i]) + } + if step["status"] != "planned" { + t.Fatalf("step %d status = %#v, want planned", i, step["status"]) + } + } + + // The commit step (index 2) must NOT contain uploadKey in args (remote-only field) + // and must declare the dependency via a "requires" entry. + commitStep := steps[2].(map[string]any) + commitArgs, _ := commitStep["args"].(map[string]any) + if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey { + t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs) + } + requires, ok := commitStep["requires"].([]any) + if !ok || len(requires) == 0 { + t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"]) + } + found := false + for _, r := range requires { + if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) { + found = true + break + } + } + if !found { + t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure 触发 stat 成功但读取失败的文件, +// 覆盖 runOAAttachmentUpload 中 fileMD5Hex 失败分支:命令报错且无任何远程/PUT 调用。 +func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 0000 不能在 windows 上可靠地阻止读取") + } + filePath, _ := writeOAAttachmentTempFile(t, "unreadable.bin", "secret") + if err := os.Chmod(filePath, 0o000); err != nil { + t.Fatalf("chmod 0000: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(filePath, 0o600) }) + // 若环境(如 root)仍可读取 0000 文件,该分支无法触发,跳过以避免偽成功。 + if f, err := os.Open(filePath); err == nil { + _ = f.Close() + t.Skip("当前环境仍可读取 0000 文件,fileMD5Hex 失败分支无法触发") + } + + put := mockOAAttachmentPut(t) + caller := &scriptedToolCaller{format: "json"} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + ) + if err == nil { + t.Fatal("expected md5 failure error, got nil") + } + if !strings.Contains(err.Error(), "MD5") { + t.Fatalf("error = %v, want md5 failure", err) + } + if caller.calls != 0 { + t.Fatalf("md5 failure made %d MCP call(s), want 0", caller.calls) + } + if put.calls != 0 { + t.Fatalf("md5 failure made %d PUT call(s), want 0", put.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT, +// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。 +func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data") + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + return errors.New("oss put failed") + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + // commit 不应被达到 + {text: `{"result":{},"success":true}`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "put-fail.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "oss put failed") { + t.Fatalf("error = %v, want oss put failed", err) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、 +// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支: +// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。 +func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {err: errors.New("commit upload failed")}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-fail.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "commit upload failed") { + t.Fatalf("error = %v, want commit upload failed", err) + } + if put.calls != 1 { + t.Fatalf("PUT calls = %d, want 1", put.calls) + } + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls) + } + if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" { + t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog) + } +} diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index d94d9c93..97f32e3d 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -449,9 +449,10 @@ Flags: > ```bash > # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType > dws oa approval attachment upload --file ./a.pdf -> # 2) 组装 value 并发起(value 为 JSON 数组转义字符串) +> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes > dws oa approval create-instance --process-code PROC-xxx \ -> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' --yes +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' +> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes > ``` > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index e498c6ee..ee145ce1 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -452,9 +452,10 @@ Flags: > ```bash > # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType > dws oa approval attachment upload --file ./a.pdf -> # 2) 组装 value 并发起(value 为 JSON 数组转义字符串) +> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes > dws oa approval create-instance --process-code PROC-xxx \ -> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' --yes +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' +> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes > ``` > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 From 2d38beb7befd00727614af79e1c86134e61230ce Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 21:51:21 +0800 Subject: [PATCH 251/267] fix(shortcuts): separate compatibility visibility from availability --- .../builtin/ding_semantic_catalog_test.go | 11 ++++-- internal/shortcut/ding/common.go | 5 ++- internal/shortcut/ding/ding.go | 8 ++-- internal/shortcut/semantic_catalog.go | 11 +++--- .../semantic_catalog_coverage_test.go | 37 +++++++++++-------- internal/shortcut/semantic_catalog_ding.json | 6 ++- 6 files changed, 48 insertions(+), 30 deletions(-) diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go index faf2408c..af96a75d 100644 --- a/internal/shortcut/builtin/ding_semantic_catalog_test.go +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -37,6 +37,11 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( "+recall-personal": true, "+send-personal": true, } + wantCompatibilityAvailability := map[string]shortcut.Availability{ + "+list": shortcut.AvailabilityUnavailable, + "+recall-personal": shortcut.AvailabilityAvailable, + "+send-personal": shortcut.AvailabilityAvailable, + } public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { @@ -66,7 +71,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( } else { if record.CompatibilityVisible { compatibilityVisible++ - if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("ding", command) { + if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) { t.Errorf("%s compatibility-visible boundary drift", command) } } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { @@ -90,7 +95,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 4 || compatibilityVisible != len(wantCompatibilityVisible) { - t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/4/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go index bb45a46c..13408567 100644 --- a/internal/shortcut/ding/common.go +++ b/internal/shortcut/ding/common.go @@ -18,8 +18,9 @@ import ( ) const ( - dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." - dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." ) type dingPageEvidence struct { diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 8c923b92..ba2a19a5 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -101,7 +101,7 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = compatibilityDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", - "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", + "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, @@ -152,7 +152,7 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = compatibilityDingWrite( "+recall-personal", "撤回本人发起的 DING", - "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", + "仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。", shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "id"}}, @@ -163,11 +163,13 @@ var RecallPersonal = compatibilityDingWrite( ) func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + declaration := dingContract(command, description, intent, true, nil, nil, params, example) + declaration.Interface.Reason = dingCompatibilityWriteReason return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, true, nil, nil, params, example), + Contract: declaration, Flags: flags, Tips: []string{example}, Execute: execute, } } diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 2542142f..60aa42c6 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -142,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { command, record.Availability)) } // A command that was already part of the visible CLI contract cannot be - // hidden in the same feature change merely because Agent publication is - // withdrawn. This narrow fact preserves historical discovery/argv while - // the unavailable Interface and public=false keep it out of Agent routes. - if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { - panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + // hidden merely because Agent publication is withdrawn. Compatibility + // visibility owns only historical CLI discovery; availability independently + // records whether that compatibility path still executes. public=false keeps + // both available and unavailable compatibility leaves out of Agent routes. + if record.CompatibilityVisible && record.Public { + panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command)) } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index d555aced..df822179 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, - "compatibility-visible available": `{ - "version": 1, - "service": "chat", - "default_availability": "available", - "shortcuts": { - "+messages": { - "disposition": "semantic_adapter", - "semantic_delta": "reviewed", - "risk": "read", - "public": false, - "compatibility_visible": true, - "reviewed": true - } - } - }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { if InPublicCatalog(item.Service, item.Command) { t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") } + + available := map[string]semanticCatalogRecord{} + loadSemanticCatalog([]byte(`{ + "version":1, + "service":"compatibility-test", + "default_availability":"unavailable", + "shortcuts":{ + "+legacy":{ + "disposition":"semantic_adapter", + "semantic_delta":"historical CLI path remains executable but is not Agent-public", + "risk":"write", + "availability":"available", + "public":false, + "compatibility_visible":true, + "reviewed":true + } + } + }`), available) + record := available[publicCatalogKey("compatibility-test", "+legacy")] + if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable { + t.Fatalf("available compatibility record = %#v", record) + } } func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index d2abf1a8..7c1565d5 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -21,8 +21,9 @@ }, "+send-personal": { "disposition": "semantic_adapter", - "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。", "risk": "write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true @@ -36,8 +37,9 @@ }, "+recall-personal": { "disposition": "semantic_adapter", - "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。", "risk": "high-risk-write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true From 57cca7ef71e69ac59b04b24c63df36c79a821cdc Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 22:02:26 +0800 Subject: [PATCH 252/267] fix(sheet): validate revision result contracts --- internal/cli/schema_validate.go | 8 ++- internal/helpers/sheet_revision.go | 87 +++++++++++++++++++++++++ internal/helpers/sheet_revision_test.go | 82 ++++++++++++++++++++++- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/internal/cli/schema_validate.go b/internal/cli/schema_validate.go index 21d7920f..90a01ead 100644 --- a/internal/cli/schema_validate.go +++ b/internal/cli/schema_validate.go @@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error { if params == nil { params = map[string]any{} } - if err := validateSchemaValue("$", params, schema); err != nil { + if err := ValidateJSONSchemaValue(params, schema); err != nil { return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err)) } return nil } +// ValidateJSONSchemaValue validates one decoded JSON value against the +// required/type/enum/properties/items subset used by reviewed CLI contracts. +func ValidateJSONSchemaValue(value any, schema map[string]any) error { + return validateSchemaValue("$", value, schema) +} + func validateSchemaValue(path string, value any, schema map[string]any) error { if len(schema) == 0 { return nil diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go index f6f9f9d9..703f4f43 100644 --- a/internal/helpers/sheet_revision.go +++ b/internal/helpers/sheet_revision.go @@ -19,9 +19,11 @@ import ( "io" "strconv" "strings" + "sync" "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "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" @@ -215,6 +217,17 @@ var sheetChangesetResult = &contract.ResultSpec{ }`), } +type sheetPublishedResultSchemaCache struct { + once sync.Once + schema map[string]any + err error +} + +var ( + sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache + sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache +) + func newSheetRevisionCmds() []*cobra.Command { return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()} } @@ -385,6 +398,14 @@ func decodeSheetRevisionResult(tool, raw string) (any, error) { return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v%s", tool, err, sheetResultLogIDSuffix(object))) } } + if err := validateSheetPublishedResult(tool, object); err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) + } return object, nil } @@ -459,6 +480,72 @@ func normalizeSheetChangesetTransport(object map[string]any) error { return fmt.Errorf("成功响应缺少 changesetsJson") } +func validateSheetPublishedResult(tool string, object map[string]any) error { + var rawSchema json.RawMessage + var cache *sheetPublishedResultSchemaCache + switch tool { + case sheetRevisionGetRemoteTool: + rawSchema = sheetRevisionResult.DataSchema + cache = &sheetRevisionPublishedSchemaCache + case sheetChangesetGetRemoteTool: + rawSchema = sheetChangesetResult.DataSchema + cache = &sheetChangesetPublishedSchemaCache + default: + return fmt.Errorf("未知工具 %q", tool) + } + + cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) }) + if cache.err != nil { + return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err) + } + schema := cache.schema + if err := cli.ValidateJSONSchemaValue(object, schema); err != nil { + return err + } + if strings.TrimSpace(object["logId"].(string)) == "" { + return fmt.Errorf("$.logId 不能为空") + } + + switch tool { + case sheetRevisionGetRemoteTool: + if _, err := sheetNonNegativeInteger(object["revision"]); err != nil { + return fmt.Errorf("$.revision %v", err) + } + case sheetChangesetGetRemoteTool: + latest, err := sheetNonNegativeInteger(object["latestRevision"]) + if err != nil { + return fmt.Errorf("$.latestRevision %v", err) + } + start, err := sheetNonNegativeInteger(object["startRevision"]) + if err != nil { + return fmt.Errorf("$.startRevision %v", err) + } + end, err := sheetNonNegativeInteger(object["endRevision"]) + if err != nil { + return fmt.Errorf("$.endRevision %v", err) + } + if start > end { + return fmt.Errorf("$.startRevision 不能大于 $.endRevision") + } + if end > latest { + return fmt.Errorf("$.endRevision 不能大于 $.latestRevision") + } + } + return nil +} + +func sheetNonNegativeInteger(value any) (int64, error) { + number, ok := value.(json.Number) + if !ok { + return 0, fmt.Errorf("必须是非负整数") + } + parsed, err := strconv.ParseInt(number.String(), 10, 64) + if err != nil || parsed < 0 { + return 0, fmt.Errorf("必须是非负整数") + } + return parsed, nil +} + func sheetRevisionNumberArg(raw string) (any, error) { return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) } diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go index 29ea3593..56d18807 100644 --- a/internal/helpers/sheet_revision_test.go +++ b/internal/helpers/sheet_revision_test.go @@ -172,7 +172,7 @@ func TestSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { "changes":[{ "type":"RANGE_CONTENT_SET", "targets":[{"scope":"RANGE","sheetId":"sheet-id","sheetName":"项目","sheetNameSource":"AT_CHANGE","a1Range":"C2","role":"AFFECTED"}], - "details":{"cell":{"value":"完成"},"futureSafeField":{"x":1}}, + "details":{"cell":{"value":{"kind":"STRING","stringValue":"完成"}},"futureSafeField":{"x":1}}, "detailsStatus":"COMPLETE", "omissions":[] }] @@ -224,7 +224,7 @@ func TestSheetChangesetGetSendsNumericEndRevision(t *testing.T) { } func TestDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { - const response = `{"success":true,"changesets":[{"revision":9007199254740993}]}` + const response = `{"success":true,"logId":"trace-legacy","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":9007199254740993,"startRevision":9007199254740992,"endRevision":9007199254740993,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":9007199254740993,"createTime":"2026-08-20T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}` decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) if err != nil { t.Fatalf("decode legacy array: %v", err) @@ -331,6 +331,84 @@ func TestSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *te } } +func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { + validSummary := `"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]}` + validChangesetPrefix := `"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,` + validChangesets := `"changesets":[]` + + tests := []struct { + name string + tool string + response string + build func() *cobra.Command + args []string + }{ + { + name: "revision missing required field", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "revision wrong field type", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract","revision":"1"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "revision empty log id", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":" ","revision":1}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset missing summary", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong revision type", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":"1","startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong schema version", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":3,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong semantics", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"BIDIRECTIONAL","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset malformed summary", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+`"summary":{"changeCount":0},`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset item missing required field", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validSummary+`,"changesets":[{"revision":1,"isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}} + got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...) + if err == nil { + t.Fatalf("command unexpectedly accepted contract drift with output %q", got) + } + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+test.tool || apiErr.FailureStage != "response_validation" { + t.Fatalf("contract drift error = %#v", err) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("contract drift emitted a success envelope: %s", got) + } + }) + } +} + func TestDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, " \n ") var apiErr *apperrors.Error From bbaf0336183efa5239f6bced8e73e8c8ee3701ad Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 22:14:35 +0800 Subject: [PATCH 253/267] ci: stream app coverage progress --- .github/workflows/ci.yml | 8 +++++++- test/scripts/coverage_workflow_contract_test.go | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fb7c86..4348595e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1173,7 +1173,13 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - go test -count=1 -p 1 \ + progress_flags=() + if [ "$COVERAGE_SHARD" = app ]; then + # app is one long package; keep per-test progress visible while its + # authoritative serial coverage profile is being generated. + progress_flags=(-v) + fi + go test "${progress_flags[@]}" -count=1 -p 1 \ -coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \ -covermode=atomic \ "${packages[@]}" diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index 5ffb1298..226b984d 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -165,7 +165,9 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { " - helpers", " - remaining", `./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD"`, - "go test -count=1 -p 1", + `if [ "$COVERAGE_SHARD" = app ]; then`, + "progress_flags=(-v)", + `go test "${progress_flags[@]}" -count=1 -p 1`, `-coverprofile="coverage-shard-$COVERAGE_SHARD.txt"`, "-covermode=atomic", "name: coverage-current-shard-${{ matrix.shard }}", From 8cb0f644776206ccae3c68399532b153956b9f21 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 22:15:27 +0800 Subject: [PATCH 254/267] fix(shortcut): reject backward OA cursors --- internal/shortcut/oa/common.go | 8 ++++++-- .../shortcut/oa/oa_strict_cross_platform_coverage_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index f51c11bd..a1c77752 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -340,8 +340,12 @@ func oaCursorPage(result map[string]any, operation string, current int) (oaPageE if next == "" { return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") } - if next == strconv.Itoa(current) { - return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 与当前 cursor 相同") + nextValue, err := strconv.Atoi(next) + if err != nil { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数") + } + if nextValue <= current { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进") } page.Next = next return page, nil diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index adcaca63..f19cdc13 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -154,6 +154,12 @@ func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { t.Fatal("stalled cursor was accepted") } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil { + t.Fatal("backward cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil { + t.Fatal("non-integer cursor was accepted") + } page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) if err != nil || page.Next != "2" { t.Fatalf("cursor continuation=%+v err=%v", page, err) From 23940e4752e60d1b2d2ad5bc379e1097384c059f Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 22:24:17 +0800 Subject: [PATCH 255/267] revert: keep coverage shard output compact --- .github/workflows/ci.yml | 8 +------- test/scripts/coverage_workflow_contract_test.go | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4348595e..08fb7c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1173,13 +1173,7 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - progress_flags=() - if [ "$COVERAGE_SHARD" = app ]; then - # app is one long package; keep per-test progress visible while its - # authoritative serial coverage profile is being generated. - progress_flags=(-v) - fi - go test "${progress_flags[@]}" -count=1 -p 1 \ + go test -count=1 -p 1 \ -coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \ -covermode=atomic \ "${packages[@]}" diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index 226b984d..5ffb1298 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -165,9 +165,7 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { " - helpers", " - remaining", `./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD"`, - `if [ "$COVERAGE_SHARD" = app ]; then`, - "progress_flags=(-v)", - `go test "${progress_flags[@]}" -count=1 -p 1`, + "go test -count=1 -p 1", `-coverprofile="coverage-shard-$COVERAGE_SHARD.txt"`, "-covermode=atomic", "name: coverage-current-shard-${{ matrix.shard }}", From 11a9ad5d491a6e140454539f2cbb06bc4a7a3332 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 23:03:35 +0800 Subject: [PATCH 256/267] fix(shortcut): align OA execution availability --- internal/app/oa_shortcut_contract_test.go | 35 +++++++++++++++++++ .../builtin/oa_semantic_catalog_test.go | 9 +++-- internal/shortcut/oa/common.go | 10 ++++-- internal/shortcut/oa/oa.go | 7 ++-- .../oa_strict_cross_platform_coverage_test.go | 4 +++ internal/shortcut/oa/smart.go | 8 +++-- internal/shortcut/semantic_catalog_oa.json | 24 ++++++++----- internal/shortcut/types.go | 3 +- 8 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 internal/app/oa_shortcut_contract_test.go diff --git a/internal/app/oa_shortcut_contract_test.go b/internal/app/oa_shortcut_contract_test.go new file mode 100644 index 00000000..55b9f703 --- /dev/null +++ b/internal/app/oa_shortcut_contract_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + for _, canonical := range []string{ + "oa.shortcut_approve_by", + "oa.shortcut_done_approvals", + "oa.shortcut_list_cc", + "oa.shortcut_list_executed", + "oa.shortcut_list_forms", + "oa.shortcut_list_pending", + "oa.shortcut_list_submitted", + "oa.shortcut_my_initiated", + "oa.shortcut_pending", + "oa.shortcut_search_forms", + } { + tool, ok := snapshot.Tools[canonical] + if !ok { + t.Errorf("final Schema lacks OA tool %s", canonical) + continue + } + if got := tool["availability"]; got != contract.InterfaceAvailable { + t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable) + } + } +} diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index b6e63d4b..9efc0bef 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -58,6 +58,9 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if item.Risk != record.Risk || item.Availability != availability { t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) { + t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability) + } if record.Public { public++ if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { @@ -69,7 +72,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t } else { if record.CompatibilityVisible { compatibilityVisible++ - if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("oa", command) { + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) { t.Errorf("%s compatibility-visible boundary drift", command) } } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { @@ -93,7 +96,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 8 || compatibilityVisible != len(wantCompatibilityVisible) { - t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/8/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index a1c77752..6e09854c 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -65,9 +65,15 @@ func oaWriteSafety() contract.SafetySpec { return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} } -func oaContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { +func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") cliPath := "oa " + command + availability := contract.InterfaceUnavailable + interfaceReason := strings.TrimSpace(intent) + if available { + availability = contract.InterfaceAvailable + interfaceReason = oaCompositeReason + } return corecmd.ContractDecl{ Description: description, Result: result, @@ -77,7 +83,7 @@ func oaContract(command, description, intent string, result *contract.ResultSpec ProductID: "oa", Name: name, CanonicalPath: "oa." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath, }, - Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: oaCompositeReason}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason}, Selection: contract.SelectionSpec{ AgentSummary: description, UseWhen: []string{intent}, diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 7e062b92..b5ab751c 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -56,6 +56,7 @@ var ListPending = shortcut.Shortcut{ "+list-pending", "查询待我处理的审批(时间范围为 epoch 毫秒)", "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + true, oaCollectionResult("instances", "严格验证的待处理审批实例页"), oaPagePagination("page"), []contract.ParamDecl{ @@ -115,7 +116,8 @@ var ListForms = shortcut.Shortcut{ OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+list-forms", "获取当前用户可见的审批表单列表", - "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。", + true, oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, "dws oa +list-forms --cursor 0 --limit 100", @@ -164,6 +166,7 @@ var SearchForms = shortcut.Shortcut{ Contract: oaContract( "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + true, oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", @@ -196,7 +199,7 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short Service: "oa", Command: command, Product: "oa", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, - Contract: oaContract(command, description, intent, + Contract: oaContract(command, description, intent, true, oaCollectionResult("instances", description), oaPagePagination("page"), []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index f19cdc13..7df9f056 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -94,6 +94,10 @@ func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { t.Fatal("+approve-by must require explicit high-risk confirmation") } + unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil) + if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" { + t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface) + } for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { if declaration.Contract.Pagination == nil { t.Errorf("%s lacks Pagination", declaration.Command) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 655b44bc..0bacde28 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -39,13 +39,14 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param var PendingApprovals = shortcut.Shortcut{ Service: "oa", Command: "+pending", Product: "oa", Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", - Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时保持 unavailable。", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。", Risk: shortcut.RiskRead, Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + true, oaCollectionResult("pending", "严格验证的待审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", ), @@ -78,6 +79,7 @@ var DoneApprovals = shortcut.Shortcut{ Contract: oaContract( "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + true, oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", ), @@ -109,6 +111,7 @@ var MyInitiated = shortcut.Shortcut{ Contract: oaContract( "+my-initiated", "列出我发起(提交)的审批单据", "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + true, oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", @@ -169,13 +172,14 @@ func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch var Approve = shortcut.Shortcut{ Service: "oa", Command: "+approve-by", Product: "oa", Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前保持 unavailable。", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。", Risk: shortcut.RiskHighWrite, Safety: oaWriteSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + true, oaWriteResult("同意审批并通过精确任务读回验证"), nil, []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, "dws oa +approve-by --keyword 报销", diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 7ebe91a6..0aee7c46 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -5,24 +5,27 @@ "shortcuts": { "+list-executed": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+list-submitted": { "disposition": "semantic_adapter", - "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+my-initiated": { "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持不可用。", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。", "risk": "read", + "availability": "available", "primary": "+list-submitted", "public": false, "compatibility_visible": true, @@ -47,40 +50,45 @@ }, "+list-forms": { "disposition": "semantic_adapter", - "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+list-pending": { "disposition": "semantic_adapter", - "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+pending": { "disposition": "alias_internal", - "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均不可用。", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。", "risk": "read", + "availability": "available", "primary": "+list-pending", "public": false, "reviewed": true }, "+list-cc": { "disposition": "semantic_adapter", - "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+approve-by": { "disposition": "primary_smart", - "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,保持不可用。", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。", "risk": "high-risk-write", + "availability": "available", "public": false, "reviewed": true } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 9d9f5836..602baf39 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -197,7 +197,8 @@ type Shortcut struct { Hidden bool // CompatibilityVisible preserves a historically visible CLI command while // keeping it out of the Agent/public Shortcut catalog. Such a command is - // shown only by `dws shortcut list --all` and must remain unavailable. + // shown only by `dws shortcut list --all`; Availability independently says + // whether the historical execution path remains callable. CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live From e8ecff586a19fff65a2487744fd0656963ecdabb Mon Sep 17 00:00:00 2001 From: xiatian Date: Thu, 20 Aug 2026 23:06:56 +0800 Subject: [PATCH 257/267] fix(sheet): classify malformed revision responses --- internal/helpers/sheet_revision.go | 18 +++++- internal/helpers/sheet_revision_test.go | 82 +++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go index 703f4f43..434aec83 100644 --- a/internal/helpers/sheet_revision.go +++ b/internal/helpers/sheet_revision.go @@ -367,7 +367,11 @@ func decodeSheetRevisionResult(tool, raw string) (any, error) { } data, err := decodeSheetSingleJSON(raw) if err != nil { - return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err), + "invalid_tool_response", + false, + ) } object, ok := data.(map[string]any) if !ok { @@ -395,7 +399,12 @@ func decodeSheetRevisionResult(tool, raw string) (any, error) { } if tool == sheetChangesetGetRemoteTool { if err := normalizeSheetChangesetTransport(object); err != nil { - return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v%s", tool, err, sheetResultLogIDSuffix(object))) + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) } } if err := validateSheetPublishedResult(tool, object); err != nil { @@ -493,7 +502,12 @@ func validateSheetPublishedResult(tool string, object map[string]any) error { default: return fmt.Errorf("未知工具 %q", tool) } + return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache) +} +func validateSheetPublishedResultWithSchema(tool string, object map[string]any, + rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache, +) error { cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) }) if cache.err != nil { return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err) diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go index 56d18807..93d36127 100644 --- a/internal/helpers/sheet_revision_test.go +++ b/internal/helpers/sheet_revision_test.go @@ -260,11 +260,37 @@ func TestDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { } } -func TestDecodeSheetChangesetTransportPreservesLogIDInParseError(t *testing.T) { - const response = `{"success":true,"logId":"trace-malformed-changeset","changesetsJson":"not-json"}` - _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) - if err == nil || !strings.Contains(err.Error(), "logId=trace-malformed-changeset") { - t.Fatalf("parse error = %v, want backend logId", err) +func TestDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T) { + tests := []struct { + name string + tool string + response string + wantLogID string + }{ + { + name: "malformed outer json", tool: sheetRevisionGetRemoteTool, + response: `{"success":true`, + }, + { + name: "malformed changesets json", tool: sheetChangesetGetRemoteTool, + response: `{"success":true,"logId":"trace-malformed-changeset","changesetsJson":"not-json"}`, + wantLogID: "trace-malformed-changeset", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := decodeSheetRevisionResult(test.tool, test.response) + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Origin != "mcp" || + apiErr.FailureStage != "response_validation" || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+test.tool || !apiErr.RetryableSet || apiErr.Retryable { + t.Fatalf("protocol failure error = %#v", err) + } + if test.wantLogID != "" && !strings.Contains(err.Error(), "logId="+test.wantLogID) { + t.Fatalf("protocol failure error = %v, want backend logId", err) + } + }) } } @@ -358,6 +384,11 @@ func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { response: `{"success":true,"logId":" ","revision":1}`, build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, }, + { + name: "revision negative integer", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract","revision":-1}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, { name: "changeset missing summary", tool: sheetChangesetGetRemoteTool, response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validChangesets+`}`), @@ -383,6 +414,31 @@ func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+`"summary":{"changeCount":0},`+validChangesets+`}`), build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, }, + { + name: "changeset negative latest revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":-1,"startRevision":0,"endRevision":0,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset negative start revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":-1,"endRevision":0,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset negative end revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":-1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset start after end", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":2,"startRevision":2,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset end after latest", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":2,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, { name: "changeset item missing required field", tool: sheetChangesetGetRemoteTool, response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validSummary+`,"changesets":[{"revision":1,"isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`), @@ -409,6 +465,22 @@ func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { } } +func TestValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) { + if err := validateSheetPublishedResult("unknown-tool", map[string]any{}); err == nil { + t.Fatal("unknown tool unexpectedly accepted") + } + + cache := &sheetPublishedResultSchemaCache{} + if err := validateSheetPublishedResultWithSchema(sheetRevisionGetRemoteTool, map[string]any{}, + json.RawMessage(`{`), cache); err == nil { + t.Fatal("invalid published schema unexpectedly accepted") + } + + if _, err := sheetNonNegativeInteger(int64(1)); err == nil { + t.Fatal("non-json number unexpectedly accepted") + } +} + func TestDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, " \n ") var apiErr *apperrors.Error From bb6f470df5ec0dc334b76973386de47ec0f2f91a Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 23:55:37 +0800 Subject: [PATCH 258/267] test: address shortcut regression review --- .changes/1074-shortcut-regression-fixes.md | 7 +++++++ .../todo/todo_additional_cross_platform_coverage_test.go | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changes/1074-shortcut-regression-fixes.md diff --git a/.changes/1074-shortcut-regression-fixes.md b/.changes/1074-shortcut-regression-fixes.md new file mode 100644 index 00000000..55390ec8 --- /dev/null +++ b/.changes/1074-shortcut-regression-fixes.md @@ -0,0 +1,7 @@ +--- +category: Fixed +--- + +- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel. +- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures. +- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC. diff --git a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go index 7bbfc282..caae2acc 100644 --- a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go +++ b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "io" + "math" "strconv" "strings" "testing" @@ -331,7 +332,14 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) { {value: float64(40), want: 40, ok: true}, {value: json.Number("40"), want: 40, ok: true}, {value: " 40 ", want: 40, ok: true}, + {value: float64(math.MinInt64), want: math.MinInt64, ok: true}, {value: 1786932000000.5}, + {value: math.NaN()}, + {value: math.Inf(1)}, + {value: math.Inf(-1)}, + {value: float64(math.MaxInt64)}, + {value: 1e300}, + {value: -1e300}, {value: json.Number("not-a-number")}, {value: "not-a-number"}, {value: true}, From 14c5bed4fc7c7511792dd92957d3bd1686f7e565 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 21 Aug 2026 00:50:46 +0800 Subject: [PATCH 259/267] ci: split app-c race partition for runner headroom --- .github/workflows/ci.yml | 16 ++++++++++++---- scripts/ci/run-app-race-tests.sh | 28 ++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fb7c86..49a76f09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -534,7 +534,8 @@ jobs: # where the Schema partition alone owned most of the wall clock. The # app- names are pinned to the helper's partition set by # TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose - # its job silently. + # its job silently. The CrossPlatformCoverage-heavy C range is split again + # to retain headroom on runners reclaimed near the five-minute mark. timeout-minutes: 20 strategy: fail-fast: false @@ -542,7 +543,10 @@ jobs: shard: - app-schema - app-a-b - - app-c + - app-c-a-l + - app-c-m-r + - app-c-s-z + - app-c-other - app-d-r - app-s-z-example-fuzz - generators @@ -678,7 +682,8 @@ jobs: # partitions run concurrently and each releases its framework registries # when the process exits; cli/smoke need headroom beyond go test -timeout for # setup + assembly. The app- names are pinned to the helper's - # partition set by TestCIAppRacePartitionMatrixMatchesHelper. + # partition set by TestCIAppRacePartitionMatrixMatchesHelper. The + # CrossPlatformCoverage-heavy C range is split again for runner headroom. timeout-minutes: 20 strategy: fail-fast: false @@ -686,7 +691,10 @@ jobs: shard: - app-schema - app-a-b - - app-c + - app-c-a-l + - app-c-m-r + - app-c-s-z + - app-c-other - app-d-r - app-s-z-example-fuzz - generators diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh index 3f586dd0..0183762c 100755 --- a/scripts/ci/run-app-race-tests.sh +++ b/scripts/ci/run-app-race-tests.sh @@ -14,7 +14,7 @@ usage() { # Single source of truth for the partition set. CI runs one job per partition and # pins its shard names to this list, so a name that appears here without a # dispatch entry below fails closed rather than silently skipping tests. -APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz' +APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz' mode="${1:-}" partition="" @@ -66,7 +66,10 @@ fi schema_count=0 ab_count=0 -c_count=0 +cal_count=0 +cmr_count=0 +csz_count=0 +cother_count=0 dr_count=0 sz_count=0 unmatched_count=0 @@ -75,7 +78,10 @@ while IFS= read -r test_name; do case "$test_name" in Test*Schema*) schema_count=$((schema_count + 1)) ;; Test[A-B]*) ab_count=$((ab_count + 1)) ;; - TestC*) c_count=$((c_count + 1)) ;; + TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;; + TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;; + TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;; + TestC*) cother_count=$((cother_count + 1)) ;; Test[D-R]*) dr_count=$((dr_count + 1)) ;; Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;; *) @@ -96,7 +102,10 @@ classified='' for spec in \ "schema:$schema_count" \ "a-b:$ab_count" \ - "c:$c_count" \ + "c-a-l:$cal_count" \ + "c-m-r:$cmr_count" \ + "c-s-z:$csz_count" \ + "c-other:$cother_count" \ "d-r:$dr_count" \ "s-z-example-fuzz:$sz_count" do @@ -133,14 +142,14 @@ for name in $classified; do done total_count="$(wc -l < "$tests" | tr -d ' ')" -assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) +assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count)) if [ "$assigned_count" -ne "$total_count" ]; then printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2 exit 1 fi -printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \ - "$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count" +printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \ + "$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count" if [ "$mode" = "verify" ]; then exit 0 @@ -198,7 +207,10 @@ run_named_partition() { case "$1" in schema) run_partition schema no-race "$schema_pattern" ;; a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;; - c) run_partition c race '^TestC' "$schema_pattern" ;; + c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;; + c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;; + c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;; + c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;; d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;; s-z-example-fuzz) run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" From bf89acb3d203fdc6236a7a26a266da64041c5fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 21 Aug 2026 08:50:13 +0800 Subject: [PATCH 260/267] fix ci fail to #666 --- internal/helpers/oa.go | 5 +++- internal/helpers/oa_attachment_test.go | 35 +++++++++++--------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 39e51238..a0fa78f1 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -21,6 +21,9 @@ import ( "github.com/spf13/cobra" ) +// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment. +var computeFileMD5 = fileMD5Hex + func decodeOARequest(raw string) (map[string]any, error) { dec := json.NewDecoder(bytes.NewBufferString(raw)) dec.UseNumber() @@ -217,7 +220,7 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5")) if md5Hex == "" { - md5Hex, err = fileMD5Hex(filePath) + md5Hex, err = computeFileMD5(filePath) if err != nil { return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err)) } diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 0a678025..52d6a38c 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "reflect" - "runtime" "strconv" "strings" "testing" @@ -681,40 +680,34 @@ func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t * } } -// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure 触发 stat 成功但读取失败的文件, -// 覆盖 runOAAttachmentUpload 中 fileMD5Hex 失败分支:命令报错且无任何远程/PUT 调用。 +// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to +// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows). func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("chmod 0000 不能在 windows 上可靠地阻止读取") - } - filePath, _ := writeOAAttachmentTempFile(t, "unreadable.bin", "secret") - if err := os.Chmod(filePath, 0o000); err != nil { - t.Fatalf("chmod 0000: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(filePath, 0o600) }) - // 若环境(如 root)仍可读取 0000 文件,该分支无法触发,跳过以避免偽成功。 - if f, err := os.Open(filePath); err == nil { - _ = f.Close() - t.Skip("当前环境仍可读取 0000 文件,fileMD5Hex 失败分支无法触发") - } - + filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello") put := mockOAAttachmentPut(t) + + original := computeFileMD5 + computeFileMD5 = func(string) (string, error) { + return "", errors.New("permission denied") + } + t.Cleanup(func() { computeFileMD5 = original }) + caller := &scriptedToolCaller{format: "json"} _, err := executeOAAttachmentCommandCapturingOutput(t, caller, "approval", "attachment", "upload", "--file", filePath, ) if err == nil { - t.Fatal("expected md5 failure error, got nil") + t.Fatal("expected error from MD5 failure, got nil") } if !strings.Contains(err.Error(), "MD5") { - t.Fatalf("error = %v, want md5 failure", err) + t.Fatalf("error should mention MD5, got: %v", err) } if caller.calls != 0 { - t.Fatalf("md5 failure made %d MCP call(s), want 0", caller.calls) + t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls) } if put.calls != 0 { - t.Fatalf("md5 failure made %d PUT call(s), want 0", put.calls) + t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls) } } From dc37dd2c34d49cb340ea7fae0dd5d3fac031d924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 21 Aug 2026 09:56:48 +0800 Subject: [PATCH 261/267] fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持 - computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定 --- internal/helpers/oa_attachment_test.go | 6 ++---- skills/mono/references/products/oa/oa-form-components.md | 1 - .../multi/dingtalk-misc/references/oa/oa-form-components.md | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 52d6a38c..66f1d83b 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -686,11 +686,9 @@ func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) { filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello") put := mockOAAttachmentPut(t) - original := computeFileMD5 - computeFileMD5 = func(string) (string, error) { + testseam.Swap(t, &computeFileMD5, func(string) (string, error) { return "", errors.New("permission denied") - } - t.Cleanup(func() { computeFileMD5 = original }) + }) caller := &scriptedToolCaller{format: "json"} _, err := executeOAAttachmentCommandCapturingOutput(t, caller, diff --git a/skills/mono/references/products/oa/oa-form-components.md b/skills/mono/references/products/oa/oa-form-components.md index b54408e3..7da45277 100644 --- a/skills/mono/references/products/oa/oa-form-components.md +++ b/skills/mono/references/products/oa/oa-form-components.md @@ -328,7 +328,6 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | 已支持:value 需要 spaceId、fileName、fileSize、fileType、fileId,先用 `dws oa approval attachment upload --file ` 上传获取再组装提交 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 diff --git a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md index 2b319dcc..ab985fd9 100644 --- a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md +++ b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md @@ -328,7 +328,6 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | 已支持:value 需要 spaceId、fileName、fileSize、fileType、fileId,先用 `dws oa approval attachment upload --file ` 上传获取再组装提交 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 From 4aef07ccd320400559e28ed40acccffd80fe88df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 21 Aug 2026 10:40:46 +0800 Subject: [PATCH 262/267] fix(oa): validate commit response required fields before reporting success to #666 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段 - commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果 - 补充 malformed commit 响应回归测试,覆盖率 100% --- internal/helpers/oa.go | 64 +++++- internal/helpers/oa_attachment_test.go | 273 +++++++++++++++++++++++++ 2 files changed, 334 insertions(+), 3 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index a0fa78f1..044a8fcf 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -153,6 +153,56 @@ func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string return output.Success(result), nil } +// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result +// 包含构造 DDAttachment 所需的全部必需字段。当字段缺失或类型错误时返回 Validation 错误, +// 避免 Agent 拿到空 fileId 后组装无效的审批表单。 +func validateOAAttachmentCommitResult(result any) error { + resultMap, ok := result.(map[string]any) + if !ok || resultMap == nil { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象") + } + + // spaceId — 接受 string 或 number + switch v := resultMap["spaceId"].(type) { + case string: + if strings.TrimSpace(v) == "" { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + } + case float64: + // numeric spaceId is acceptable + case json.Number: + // numeric spaceId is acceptable + default: + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + } + + // fileName — must be non-empty string + if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName") + } + + // fileSize — must be > 0 (number) + switch v := resultMap["fileSize"].(type) { + case float64: + if v <= 0 { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + } + case json.Number: + if f, err := v.Float64(); err != nil || f <= 0 { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + } + default: + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize") + } + + // fileId — must be non-empty string + if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" { + return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId") + } + + return nil +} + // parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 // 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、 // result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。 @@ -285,8 +335,8 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { return err } - // Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。 - result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{ + // Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。 + commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{ "fileName": fileName, "uploadKey": uploadKey, "fileSize": float64(fileSize), @@ -294,7 +344,15 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { if err != nil { return err } - return output.StoreResult(cmd.Context(), result) + commitResp, ok := commitData.(map[string]any) + if !ok { + return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象") + } + commitResult, _ := commitResp["result"] + if err := validateOAAttachmentCommitResult(commitResult); err != nil { + return err + } + return output.StoreResult(cmd.Context(), output.Success(commitResult)) } func newOAAttachmentCommand() *cobra.Command { diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 66f1d83b..d4ec2588 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -737,6 +737,279 @@ func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) { } } +// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回 +// 缺少必需字段时命令报错(而不是包装为成功)。 +func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) { + tests := []struct { + name string + commitResponse string + wantErr string + }{ + { + name: "result is null", + commitResponse: `{"success":true,"result":null}`, + wantErr: "不是有效的 JSON 对象", + }, + { + name: "result is empty object", + commitResponse: `{"success":true,"result":{}}`, + wantErr: "spaceId", + }, + { + name: "missing fileId", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`, + wantErr: "fileId", + }, + { + name: "empty fileId", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`, + wantErr: "fileId", + }, + { + name: "missing spaceId", + commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`, + wantErr: "spaceId", + }, + { + name: "fileSize is zero", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`, + wantErr: "fileSize", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: test.commitResponse}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-validate.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + // init + commit = 2 MCP calls + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls) + } + // PUT should have been called (init succeeded) + if put.calls != 1 { + t.Fatalf("httpPutFile called %d times, want 1", put.calls) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用 +// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的 +// json.Number 类型分支。 +func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) { + tests := []struct { + name string + input any + wantErr string // empty means expect nil error + }{ + // result 不是 map + {name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"}, + {name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"}, + {name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"}, + {name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"}, + + // spaceId — 空字符串 + {name: "spaceId empty string", input: map[string]any{ + "spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + {name: "spaceId whitespace only", input: map[string]any{ + "spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + + // spaceId — json.Number(通过 UseNumber 解码的数字) + {name: "spaceId json.Number", input: map[string]any{ + "spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: ""}, + + // spaceId — float64(正常路径) + {name: "spaceId float64", input: map[string]any{ + "spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: ""}, + + // spaceId — 非法类型 + {name: "spaceId bool", input: map[string]any{ + "spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + + // fileName — 非 string 类型 + {name: "fileName is number", input: map[string]any{ + "spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + {name: "fileName is nil", input: map[string]any{ + "spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + {name: "fileName empty", input: map[string]any{ + "spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + + // fileSize — json.Number 有效 + {name: "fileSize json.Number valid", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1", + }, wantErr: ""}, + + // fileSize — json.Number 无效(<= 0) + {name: "fileSize json.Number zero", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize json.Number negative", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize json.Number invalid", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1", + }, wantErr: "fileSize"}, + + // fileSize — 非法类型(default 分支) + {name: "fileSize is string", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize is nil", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1", + }, wantErr: "fileSize"}, + + // fileId — 非 string 类型 + {name: "fileId is number", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99), + }, wantErr: "fileId"}, + {name: "fileId whitespace", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ", + }, wantErr: "fileId"}, + + // 全部通过 + {name: "all valid string types", input: map[string]any{ + "spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc", + }, wantErr: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateOAAttachmentCommitResult(test.input) + if test.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload +// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。 +func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) { + mockOAAttachmentPut(t) + caller := &scriptedToolCaller{format: "json"} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", "", + ) + if err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("error = %v, want --file empty error", err) + } + if caller.calls != 0 { + t.Fatalf("MCP calls = %d, want 0", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload +// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。 +func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {err: errors.New("injected init failure")}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "init-err.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatal("expected error from init call failure, got nil") + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("PUT calls = %d, want 0", put.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload +// 中 commitData 不是 map[string]any 的分支(line 348)。 +func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data") + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + // commit 返回一个 JSON 字符串而非对象 + {text: `"not an object"`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-nonobj.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") { + t.Fatalf("error = %v, want commit non-object error", err) + } + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖 +// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。 +func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: `{not valid json`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "bad-init.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") { + t.Fatalf("error = %v, want parse failure", err) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("PUT calls = %d, want 0", put.calls) + } +} + // TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、 // commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支: // PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。 From 7640ba7614947b7603b7059e4421fddd8c5515dd Mon Sep 17 00:00:00 2001 From: xiatian Date: Fri, 21 Aug 2026 10:59:14 +0800 Subject: [PATCH 263/267] fix(sheet): validate changeset audit integrity --- internal/helpers/sheet_revision.go | 181 ++++++++++++++++- internal/helpers/sheet_revision_test.go | 250 +++++++++++++++++++++--- 2 files changed, 407 insertions(+), 24 deletions(-) diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go index 434aec83..04f1cb16 100644 --- a/internal/helpers/sheet_revision.go +++ b/internal/helpers/sheet_revision.go @@ -17,6 +17,8 @@ import ( "encoding/json" "fmt" "io" + "reflect" + "sort" "strconv" "strings" "sync" @@ -350,14 +352,14 @@ func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]an if err != nil { return nil, err } - data, err := decodeSheetRevisionResult(tool, raw) + data, err := decodeSheetRevisionResult(tool, args, raw) if err != nil { return nil, err } return output.Success(data), nil } -func decodeSheetRevisionResult(tool, raw string) (any, error) { +func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) { if strings.TrimSpace(raw) == "" { return nil, invalidSheetRevisionResponse(tool, "MCP sheet read tool returned no non-empty text content", @@ -415,6 +417,16 @@ func decodeSheetRevisionResult(tool, raw string) (any, error) { false, ) } + if tool == sheetChangesetGetRemoteTool { + if err := validateSheetChangesetAudit(request, object); err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) + } + } return object, nil } @@ -548,6 +560,171 @@ func validateSheetPublishedResultWithSchema(tool string, object map[string]any, return nil } +type sheetChangesetSummaryAudit struct { + changeCount int64 + completeChangeCount int64 + partialChangeCount int64 + unsupportedChangeCount int64 + containsStateReset bool + containsIncompleteChanges bool + affectedSheets []sheetChangesetAffectedSheetAudit +} + +type sheetChangesetAffectedSheetAudit struct { + sheetID string + sheetName string + ranges []string +} + +type sheetChangesetAffectedSheetAccumulator struct { + sheetName string + ranges map[string]struct{} +} + +func validateSheetChangesetAudit(request, object map[string]any) error { + requestStart := request["startRevision"].(int64) + responseStart, _ := sheetNonNegativeInteger(object["startRevision"]) + responseEnd, _ := sheetNonNegativeInteger(object["endRevision"]) + latest, _ := sheetNonNegativeInteger(object["latestRevision"]) + if responseStart != requestStart { + return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart) + } + if requestEnd, exists := request["endRevision"]; exists { + if responseEnd != requestEnd.(int64) { + return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd) + } + } else if responseEnd != latest { + return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest) + } + if responseEnd-responseStart > sheetChangesetMaxSpan { + return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan) + } + + changesets := object["changesets"].([]any) + wantCount := responseEnd - responseStart + if int64(len(changesets)) != wantCount { + return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd) + } + for index, rawChangeset := range changesets { + changeset := rawChangeset.(map[string]any) + revision, err := sheetNonNegativeInteger(changeset["revision"]) + if err != nil { + return fmt.Errorf("$.changesets[%d].revision %v", index, err) + } + expected := responseStart + int64(index) + 1 + if revision != expected { + return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected) + } + } + + actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any)) + if err != nil { + return err + } + expectedSummary := summarizeSheetChangesetsForAudit(changesets) + if !reflect.DeepEqual(actualSummary, expectedSummary) { + return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致") + } + return nil +} + +func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) { + fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"} + counts := make([]int64, len(fields)) + for index, field := range fields { + value, err := sheetNonNegativeInteger(raw[field]) + if err != nil { + return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err) + } + counts[index] = value + } + + result := sheetChangesetSummaryAudit{ + changeCount: counts[0], + completeChangeCount: counts[1], + partialChangeCount: counts[2], + unsupportedChangeCount: counts[3], + containsStateReset: raw["containsStateReset"].(bool), + containsIncompleteChanges: raw["containsIncompleteChanges"].(bool), + } + for _, rawSheet := range raw["affectedSheets"].([]any) { + sheet := rawSheet.(map[string]any) + rawRanges := sheet["ranges"].([]any) + affected := sheetChangesetAffectedSheetAudit{ + sheetID: sheet["sheetId"].(string), + ranges: make([]string, 0, len(rawRanges)), + } + affected.sheetName, _ = sheet["sheetName"].(string) + for _, rawRange := range rawRanges { + affected.ranges = append(affected.ranges, rawRange.(string)) + } + result.affectedSheets = append(result.affectedSheets, affected) + } + return result, nil +} + +func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit { + result := sheetChangesetSummaryAudit{} + affected := map[string]*sheetChangesetAffectedSheetAccumulator{} + for _, rawChangeset := range changesets { + changeset := rawChangeset.(map[string]any) + result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET" + result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE" + for _, rawChange := range changeset["changes"].([]any) { + change := rawChange.(map[string]any) + result.changeCount++ + switch change["detailsStatus"] { + case "COMPLETE": + result.completeChangeCount++ + case "PARTIAL": + result.partialChangeCount++ + } + if change["type"] == "UNSUPPORTED_CHANGE" { + result.unsupportedChangeCount++ + } + for _, rawTarget := range change["targets"].([]any) { + target := rawTarget.(map[string]any) + if target["role"] == "SOURCE" { + continue + } + sheetID, _ := target["sheetId"].(string) + if strings.TrimSpace(sheetID) == "" { + continue + } + accumulator := affected[sheetID] + if accumulator == nil { + accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}} + affected[sheetID] = accumulator + } + if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" { + accumulator.sheetName = sheetName + } + if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" { + accumulator.ranges[a1Range] = struct{}{} + } + } + } + } + + sheetIDs := make([]string, 0, len(affected)) + for sheetID := range affected { + sheetIDs = append(sheetIDs, sheetID) + } + sort.Strings(sheetIDs) + for _, sheetID := range sheetIDs { + accumulator := affected[sheetID] + ranges := make([]string, 0, len(accumulator.ranges)) + for a1Range := range accumulator.ranges { + ranges = append(ranges, a1Range) + } + sort.Strings(ranges) + result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{ + sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges, + }) + } + return result +} + func sheetNonNegativeInteger(value any) (int64, error) { number, ok := value.(json.Number) if !ok { diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go index 93d36127..6be2ba14 100644 --- a/internal/helpers/sheet_revision_test.go +++ b/internal/helpers/sheet_revision_test.go @@ -103,6 +103,11 @@ func encodeSheetChangesetTransport(t *testing.T, logical string) string { if err := decoder.Decode(&object); err != nil { t.Fatalf("decode logical changeset fixture: %v", err) } + return encodeSheetChangesetTransportObject(t, object) +} + +func encodeSheetChangesetTransportObject(t *testing.T, object map[string]any) string { + t.Helper() changesets, ok := object["changesets"] if !ok { t.Fatal("logical changeset fixture missing changesets") @@ -120,7 +125,85 @@ func encodeSheetChangesetTransport(t *testing.T, logical string) string { return string(encoded) } -func TestSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) { +func decodeSheetChangesetLogicalObject(t *testing.T, logical string) map[string]any { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(logical)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + t.Fatalf("decode logical changeset fixture: %v", err) + } + return object +} + +const sheetChangesetAuditLogicalResponse = `{ + "success":true, + "logId":"trace-audit", + "schemaVersion":2, + "changeSemantics":"FORWARD_ONLY", + "latestRevision":12, + "startRevision":10, + "endRevision":12, + "summary":{ + "changeCount":3, + "completeChangeCount":1, + "partialChangeCount":1, + "unsupportedChangeCount":1, + "containsStateReset":true, + "containsIncompleteChanges":true, + "affectedSheets":[ + {"sheetId":"sheet-a","sheetName":"Alpha","ranges":["C3"]}, + {"sheetId":"sheet-b","sheetName":"Beta","ranges":["A1","B2"]} + ] + }, + "changesets":[ + { + "revision":11, + "createTime":"2026-08-21T10:00:00+08:00", + "isSelfEdit":true, + "eventType":"EDIT", + "detailsStatus":"UNAVAILABLE", + "changes":[ + { + "type":"RANGE_CONTENT_SET", + "targets":[ + {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"B2","role":"AFFECTED"}, + {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"A1","role":"DESTINATION"}, + {"scope":"WORKBOOK","role":"AFFECTED"} + ], + "details":{}, + "detailsStatus":"COMPLETE", + "omissions":[] + }, + { + "type":"RANGE_STYLE_SET", + "targets":[{"scope":"RANGE","sheetId":"sheet-a","sheetName":"Alpha","a1Range":"C3","role":"AFFECTED"}], + "details":{}, + "detailsStatus":"PARTIAL", + "omissions":[{"code":"DETAILS_NOT_FULLY_INTERPRETED"}] + }, + { + "type":"UNSUPPORTED_CHANGE", + "targets":[{"scope":"RANGE","sheetId":"sheet-source","a1Range":"D4","role":"SOURCE"}], + "details":{}, + "detailsStatus":"UNAVAILABLE", + "omissions":[{"code":"UNKNOWN_ACTION"}] + } + ] + }, + { + "revision":12, + "createTime":"2026-08-21T10:01:00+08:00", + "isSelfEdit":false, + "eventType":"STATE_RESET", + "detailsStatus":"COMPLETE", + "reset":{"type":"OVERWRITE","targetStatus":"NOT_APPLICABLE"}, + "changes":[] + } + ] +}` + +func TestCrossPlatformCoverageSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) { const response = `{"success":true,"logId":"trace-revision","revision":142,"futureField":{"kept":true,"largeInteger":9007199254740993}}` caller := &sheetRevisionTestCaller{responses: map[string]string{sheetRevisionGetRemoteTool: response}} nodeURL := "https://alidocs.dingtalk.com/spreadsheetv2/key/path?sheet=one" @@ -145,7 +228,7 @@ func TestSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) { } } -func TestSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { +func TestCrossPlatformCoverageSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { const logicalResponse = `{ "success":true, "logId":"trace-changeset", @@ -208,9 +291,9 @@ func TestSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { } } -func TestSheetChangesetGetSendsNumericEndRevision(t *testing.T) { +func TestCrossPlatformCoverageSheetChangesetGetSendsNumericEndRevision(t *testing.T) { caller := &sheetRevisionTestCaller{responses: map[string]string{ - sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-changeset","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":121,"startRevision":120,"endRevision":121,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[]}`), + sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-changeset","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":121,"startRevision":120,"endRevision":121,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":121,"createTime":"2026-08-21T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`), }} _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), "--node", "node-1", "--start-revision", "120", "--end-revision", "121") @@ -223,9 +306,130 @@ func TestSheetChangesetGetSendsNumericEndRevision(t *testing.T) { } } -func TestDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { +func TestCrossPlatformCoverageSheetChangesetGetAcceptsConsistentIntervalAndSummary(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, sheetChangesetAuditLogicalResponse), + }} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "10", "--end-revision", "12") + if err != nil { + t.Fatalf("consistent changeset response returned error: %v", err) + } + assertSheetRevisionJSONEqual(t, got, sheetChangesetAuditLogicalResponse) +} + +func TestCrossPlatformCoverageSheetChangesetGetRejectsIntervalAndSummaryDrift(t *testing.T) { + tests := []struct { + name string + args []string + mutate func(map[string]any) + want string + }{ + { + name: "wrong response start", want: "与请求值 10 不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { object["startRevision"] = json.Number("9") }, + }, + { + name: "wrong explicit response end", want: "与请求值 12 不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") }, + }, + { + name: "omitted end is not latest", want: "必须等于 $.latestRevision", + args: []string{"--node", "node-1", "--start-revision", "10"}, + mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") }, + }, + { + name: "response span too large", want: "响应区间超过 20 个 revision", + args: []string{"--node", "node-1", "--start-revision", "10"}, + mutate: func(object map[string]any) { + object["endRevision"] = json.Number("31") + object["latestRevision"] = json.Number("31") + }, + }, + { + name: "missing revision", want: "无法完整覆盖 (10,12]", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + changesets := object["changesets"].([]any) + object["changesets"] = changesets[1:] + }, + }, + { + name: "out of order revisions", want: "期望连续 revision 11", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + changesets := object["changesets"].([]any) + object["changesets"] = []any{changesets[1], changesets[0]} + }, + }, + { + name: "fractional changeset revision", want: "必须是非负整数", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["changesets"].([]any)[0].(map[string]any)["revision"] = json.Number("11.5") + }, + }, + { + name: "fractional summary count", want: "$.summary.changeCount 必须是非负整数", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["changeCount"] = json.Number("3.5") + }, + }, + { + name: "forged summary count", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["changeCount"] = json.Number("2") + }, + }, + { + name: "forged incomplete flag", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["containsIncompleteChanges"] = false + }, + }, + { + name: "forged affected sheets", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["affectedSheets"] = []any{} + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + object := decodeSheetChangesetLogicalObject(t, sheetChangesetAuditLogicalResponse) + test.mutate(object) + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransportObject(t, object), + }} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+sheetChangesetGetRemoteTool || apiErr.FailureStage != "response_validation" { + t.Fatalf("audit drift error = %#v", err) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("audit drift emitted a success envelope: %s", got) + } + }) + } +} + +func TestCrossPlatformCoverageDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { const response = `{"success":true,"logId":"trace-legacy","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":9007199254740993,"startRevision":9007199254740992,"endRevision":9007199254740993,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":9007199254740993,"createTime":"2026-08-20T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}` - decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, map[string]any{ + "startRevision": int64(9007199254740992), + "endRevision": int64(9007199254740993), + }, response) if err != nil { t.Fatalf("decode legacy array: %v", err) } @@ -237,7 +441,7 @@ func TestDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { } } -func TestDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { +func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { tests := []struct { name string raw string @@ -253,14 +457,15 @@ func TestDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - if _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, test.raw); err == nil { + if _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, + map[string]any{"startRevision": int64(0)}, test.raw); err == nil { t.Fatalf("decode %s unexpectedly succeeded", test.raw) } }) } } -func TestDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T) { +func TestCrossPlatformCoverageDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T) { tests := []struct { name string tool string @@ -280,7 +485,7 @@ func TestDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := decodeSheetRevisionResult(test.tool, test.response) + _, err := decodeSheetRevisionResult(test.tool, map[string]any{"startRevision": int64(0)}, test.response) var apiErr *apperrors.Error if !errors.As(err, &apiErr) || apiErr.Origin != "mcp" || apiErr.FailureStage != "response_validation" || apiErr.Reason != "invalid_tool_response" || @@ -294,9 +499,10 @@ func TestDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T } } -func TestDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) { +func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) { const response = `{"success":false,"logId":"trace","errorCode":"UPSTREAM","errorMsg":"failed"}` - _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, response) + _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, + map[string]any{"startRevision": int64(0)}, response) var cliErr *CLIError if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || cliErr.Operation != "sheet/"+sheetChangesetGetRemoteTool { @@ -307,7 +513,7 @@ func TestDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) { } } -func TestSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *testing.T) { +func TestCrossPlatformCoverageSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *testing.T) { tests := []struct { name string tool string @@ -357,7 +563,7 @@ func TestSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *te } } -func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { +func TestCrossPlatformCoverageSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { validSummary := `"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]}` validChangesetPrefix := `"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,` validChangesets := `"changesets":[]` @@ -465,7 +671,7 @@ func TestSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { } } -func TestValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) { +func TestCrossPlatformCoverageValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) { if err := validateSheetPublishedResult("unknown-tool", map[string]any{}); err == nil { t.Fatal("unknown tool unexpectedly accepted") } @@ -481,8 +687,8 @@ func TestValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) { } } -func TestDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { - _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, " \n ") +func TestCrossPlatformCoverageDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { + _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, nil, " \n ") var apiErr *apperrors.Error if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" || apiErr.Operation != "sheet/"+sheetRevisionGetRemoteTool || @@ -491,7 +697,7 @@ func TestDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing. } } -func TestNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) { +func TestCrossPlatformCoverageNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) { data := map[string]any{ "success": true, "changesetsJson": strings.Repeat("x", sheetChangesetJSONMaxBytes+1), @@ -501,7 +707,7 @@ func TestNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) { } } -func TestSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) { +func TestCrossPlatformCoverageSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) { caller := &sheetRevisionTestCaller{dryRun: true, responses: map[string]string{}} got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), "--node", "node-1", "--start-revision", "0", "--dry-run") @@ -528,7 +734,7 @@ func TestSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) { } } -func TestSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { +func TestCrossPlatformCoverageSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { tests := []struct { name string args []string @@ -558,7 +764,7 @@ func TestSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { } } -func TestSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { +func TestCrossPlatformCoverageSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { root := newSheetCommand() for _, name := range []string{"revision-get", "changeset-get"} { command, _, err := root.Find([]string{name}) @@ -611,7 +817,7 @@ func TestSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { } } -func TestSheetRevisionResultSchemasUseMCPNumberAndChangesetV2(t *testing.T) { +func TestCrossPlatformCoverageSheetRevisionResultSchemasUseMCPNumberAndChangesetV2(t *testing.T) { var revisionSchema map[string]any if err := json.Unmarshal(sheetRevisionResult.DataSchema, &revisionSchema); err != nil { t.Fatalf("decode revision Result schema: %v", err) From 3d9a469347641593d20a6269b627f94cb9ea6189 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 21 Aug 2026 10:57:01 +0800 Subject: [PATCH 264/267] docs: announce DWS CLI availability --- README_zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_zh.md b/README_zh.md index b2a5763f..29e55abc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,7 +19,7 @@

> [!IMPORTANT] -> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。 +> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。 > > dws 开源沟通群二维码 From 0ebd840ba913d60dc335ba55d81ed715c853d0ed Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 21 Aug 2026 12:35:38 +0800 Subject: [PATCH 265/267] chore: prepare v1.0.60-beta.1 changelog --- .../1074-shortcut-regression-fixes.md | 0 .../oa-ding-report-shortcuts.md | 0 .../refresh-org-slot-fallback.md | 0 .../stable-active-fragment-seal.md | 0 CHANGELOG.md | 23 +++++++++++++++++++ 5 files changed, 23 insertions(+) rename .changes/{ => released/1.0.60-beta.1}/1074-shortcut-regression-fixes.md (100%) rename .changes/{ => released/1.0.60-beta.1}/oa-ding-report-shortcuts.md (100%) rename .changes/{ => released/1.0.60-beta.1}/refresh-org-slot-fallback.md (100%) rename .changes/{ => released/1.0.60-beta.1}/stable-active-fragment-seal.md (100%) diff --git a/.changes/1074-shortcut-regression-fixes.md b/.changes/released/1.0.60-beta.1/1074-shortcut-regression-fixes.md similarity index 100% rename from .changes/1074-shortcut-regression-fixes.md rename to .changes/released/1.0.60-beta.1/1074-shortcut-regression-fixes.md diff --git a/.changes/oa-ding-report-shortcuts.md b/.changes/released/1.0.60-beta.1/oa-ding-report-shortcuts.md similarity index 100% rename from .changes/oa-ding-report-shortcuts.md rename to .changes/released/1.0.60-beta.1/oa-ding-report-shortcuts.md diff --git a/.changes/refresh-org-slot-fallback.md b/.changes/released/1.0.60-beta.1/refresh-org-slot-fallback.md similarity index 100% rename from .changes/refresh-org-slot-fallback.md rename to .changes/released/1.0.60-beta.1/refresh-org-slot-fallback.md diff --git a/.changes/stable-active-fragment-seal.md b/.changes/released/1.0.60-beta.1/stable-active-fragment-seal.md similarity index 100% rename from .changes/stable-active-fragment-seal.md rename to .changes/released/1.0.60-beta.1/stable-active-fragment-seal.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 76de4f26..16dddbec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.60-beta.1] - 2026-08-21 + +### Changed + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. + +- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes. + +### Fixed + +- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel. +- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures. +- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC. + +- **OAuth refresh falls back to the organization mirror** — when the server rejects the + current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound` + business code, `dws` now retries once with the still-valid token mirrored in the same + organization's slot (same corp, matching or backfilled user identity) before giving up, + and writes the rotated credential back to both the identity and the organization slots so + the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP + rejections without a reviewed business code do not trigger the fallback. + + ## [1.0.59] - 2026-08-20 This release promotes the sealed `v1.0.59-beta.5` contents to stable. From 9b521f0392c678e13909e43adc3a04dbdbfd43f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:17:50 +0000 Subject: [PATCH 266/267] chore: update beta formula for v1.0.60-beta.1 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 52036bdf..28413205 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.59-beta.5" + version "1.0.60-beta.1" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz" - sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz" + sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz" - sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz" + sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz" - sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz" + sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz" - sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz" + sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip" - sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip" + sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87" end def install From 2057fec3b09ebba825fec2e936e2c5708a948f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 21 Aug 2026 14:19:53 +0800 Subject: [PATCH 267/267] fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateOAAttachmentCommitResult 改为返回归一化后的 result map - string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错 - fileSize 的 json.Number 同样归一化为 int64 - 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666 --- internal/helpers/oa.go | 51 +++++---- internal/helpers/oa_attachment_test.go | 145 ++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 20 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 044a8fcf..c35c41cc 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -154,53 +154,65 @@ func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string } // validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result -// 包含构造 DDAttachment 所需的全部必需字段。当字段缺失或类型错误时返回 Validation 错误, -// 避免 Agent 拿到空 fileId 后组装无效的审批表单。 -func validateOAAttachmentCommitResult(result any) error { +// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer +// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation +// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。 +func validateOAAttachmentCommitResult(result any) (map[string]any, error) { resultMap, ok := result.(map[string]any) if !ok || resultMap == nil { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象") } - // spaceId — 接受 string 或 number + // spaceId — 接受 string 或 number,归一化为 int64 switch v := resultMap["spaceId"].(type) { case string: if strings.TrimSpace(v) == "" { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") } + parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数") + } + resultMap["spaceId"] = parsed case float64: - // numeric spaceId is acceptable + // numeric spaceId — keep as-is (already matches JSON number) case json.Number: - // numeric spaceId is acceptable + parsed, err := v.Int64() + if err != nil { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数") + } + resultMap["spaceId"] = parsed default: - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") } // fileName — must be non-empty string if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName") } - // fileSize — must be > 0 (number) + // fileSize — must be > 0 (number),归一化 json.Number → int64 switch v := resultMap["fileSize"].(type) { case float64: if v <= 0 { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") } case json.Number: - if f, err := v.Float64(); err != nil || f <= 0 { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + parsed, err := v.Int64() + if err != nil || parsed <= 0 { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") } + resultMap["fileSize"] = parsed default: - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize") } // fileId — must be non-empty string if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" { - return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId") + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId") } - return nil + return resultMap, nil } // parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 @@ -349,10 +361,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象") } commitResult, _ := commitResp["result"] - if err := validateOAAttachmentCommitResult(commitResult); err != nil { + normalized, err := validateOAAttachmentCommitResult(commitResult) + if err != nil { return err } - return output.StoreResult(cmd.Context(), output.Success(commitResult)) + return output.StoreResult(cmd.Context(), output.Success(normalized)) } func newOAAttachmentCommand() *cobra.Command { diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index d4ec2588..d4173d05 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -899,7 +899,7 @@ func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing. for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := validateOAAttachmentCommitResult(test.input) + _, err := validateOAAttachmentCommitResult(test.input) if test.wantErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1010,6 +1010,149 @@ func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) } } +// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证 +// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。 +func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) { + tests := []struct { + name string + input map[string]any + wantSpaceID any + wantSize any + wantErr string + }{ + { + name: "string spaceId normalized to int64", + input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: int64(27827223951), + wantSize: float64(100), + }, + { + name: "non-numeric string spaceId returns error", + input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "float string spaceId returns error", + input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "json.Number spaceId normalized to int64", + input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: int64(27827223951), + wantSize: float64(100), + }, + { + name: "json.Number spaceId non-integer returns error", + input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "json.Number fileSize normalized to int64", + input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"}, + wantSpaceID: float64(123), + wantSize: int64(2048), + }, + { + name: "float64 spaceId unchanged", + input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: float64(27827223951), + wantSize: float64(100), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized, err := validateOAAttachmentCommitResult(test.input) + if test.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if normalized["spaceId"] != test.wantSpaceID { + t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID) + } + if normalized["fileSize"] != test.wantSize { + t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令 +// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。 +func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) { + tests := []struct { + name string + commitResponse string + wantSpaceID float64 // JSON decode 后 number → float64 + }{ + { + name: "spaceId as number", + commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`, + wantSpaceID: float64(27827223951), + }, + { + name: "spaceId as string", + commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`, + wantSpaceID: float64(27827223951), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + const content = "output-contract-data" + filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content) + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: test.commitResponse}, + }} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "合同.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified data missing: %#v", envelope) + } + // spaceId must be a number in the JSON output + spaceID, ok := data["spaceId"].(float64) + if !ok { + t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"]) + } + if spaceID != test.wantSpaceID { + t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID) + } + // fileSize must also be a number + if _, ok := data["fileSize"].(float64); !ok { + t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"]) + } + }) + } +} + // TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、 // commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支: // PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。