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/174] =?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/174] =?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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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/174] 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 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 102/174] 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 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 103/174] 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 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 104/174] 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 105/174] 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 106/174] 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 107/174] 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 108/174] 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 109/174] 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 110/174] 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 111/174] 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 112/174] 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 113/174] 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 114/174] 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 115/174] 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 116/174] 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 117/174] 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 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 118/174] 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 119/174] 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 2dd724f1e108ab2b8e3821b4b88ed6785737e7f8 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Tue, 18 Aug 2026 21:10:23 +0800 Subject: [PATCH 120/174] 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 121/174] 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 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 122/174] 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 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 123/174] 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 124/174] 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 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 125/174] 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 126/174] 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 127/174] 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 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 128/174] 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 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 129/174] 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 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 130/174] 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 131/174] 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 132/174] 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 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 133/174] 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 134/174] 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 135/174] 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 136/174] 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 137/174] 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 138/174] 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 139/174] 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 140/174] 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 141/174] 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 142/174] 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 143/174] 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 144/174] 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 145/174] 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 146/174] 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 147/174] 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 148/174] 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 149/174] 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 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 150/174] chore: retrigger CI From e51ecc04f162e88ff5f4950424e9fd912f3a14e9 Mon Sep 17 00:00:00 2001 From: "mingyue.gmy" Date: Wed, 19 Aug 2026 17:09:14 +0800 Subject: [PATCH 151/174] 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 152/174] 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 153/174] 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 154/174] 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 155/174] 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 156/174] 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 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 157/174] 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 158/174] 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 159/174] 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 160/174] 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 161/174] 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 162/174] 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 163/174] 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 164/174] 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 165/174] 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 166/174] 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 167/174] 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 d8a3d5d6fd0fa0f6e8fd50bbd6eec60dadb87d3b Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 19 Aug 2026 22:57:11 +0800 Subject: [PATCH 168/174] 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 169/174] 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 170/174] 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 171/174] 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 2c0d6e4118fed362b19d8b5d20a5879901b8a046 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 10:25:13 +0800 Subject: [PATCH 172/174] 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 7a019c6fa37916f48ca70db9241f7c542cdaefb1 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 10:46:17 +0800 Subject: [PATCH 173/174] 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 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 174/174] 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