diff --git a/Makefile b/Makefile index 73913b28..98424658 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -20,6 +20,7 @@ help: @printf " make test - Run the Go test suite\n" @printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n" @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" + @printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @printf " make format-check - Check all repository Go source files with gofmt\n" @printf " make fmt - Format all repository Go source files\n" @@ -62,6 +63,9 @@ test-auth-legacy-compat: @mkdir -p "$(POLICY_GOTMPDIR)" @GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh +shortcut-public-e2e-proof: build + @GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh + lint: @./scripts/dev/lint.sh @@ -84,7 +88,7 @@ fmt: $(GO_SOURCE_LIST) > "$$go_files"; \ xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" -policy: test-auth-legacy-compat +policy: test-auth-legacy-compat shortcut-public-e2e-proof @mkdir -p "$(POLICY_GOTMPDIR)" @$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh @$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 3780d36d..36e1beca 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-21T10:07:48.251313", - "count": 418, + "generated_at": "2026-08-24T12:22:05.108140", + "count": 426, "results": [ { "suite": "semantic", @@ -282,6 +282,76 @@ "semantic_delta": "更新仪表盘配置的一对一入口。", "availability": "available" }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-get-config", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-get-fields", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-list-sources", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-sync", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-sync-status", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。", + "availability": "available" + }, { "suite": "semantic", "service": "aitable", @@ -3635,6 +3705,16 @@ "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", "availability": "available" }, + { + "suite": "semantic", + "service": "pat", + "command": "+browser-policy", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。", + "availability": "available" + }, { "suite": "semantic", "service": "report", @@ -4126,4 +4206,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 6e8e37f9..882a8113 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 = 425 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 468 + schemaPublishedShortcutCount = 482 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 424 + publiclyDeliveredShortcutCount = 425 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go index ce519e82..eff964db 100644 --- a/internal/pat/browser_policy.go +++ b/internal/pat/browser_policy.go @@ -71,6 +71,13 @@ func patConfigDir() string { return filepath.Join(homeDir, ".dws") } +// BrowserPolicyConfigDir returns the exact configuration directory used by +// the native PAT command. Shortcut adapters use this narrow seam so their +// write/readback cycle cannot drift to a different policy file. +func BrowserPolicyConfigDir() string { + return patConfigDir() +} + func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) { path := patPolicyPath(configDir) data, err := patPolicyReadFile(path) @@ -203,6 +210,32 @@ func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (Browse }, nil } +// ReadStoredBrowserPolicy reads the exact default or explicitly named agent +// entry without consulting the process agent-code environment. This is the +// verification companion to SetBrowserPolicy: callers can prove the same +// target was persisted rather than observing an environment-selected fallback. +func ReadStoredBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) { + agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode) + if err != nil { + return BrowserPolicySelection{}, err + } + policy, err := LoadBrowserPolicy(configDir) + if err != nil { + return BrowserPolicySelection{}, err + } + if agentCode != "" { + entry, ok := policy.Agents[agentCode] + if !ok { + return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy agent entry was not persisted") + } + return BrowserPolicySelection{Scope: "agent", AgentCode: agentCode, OpenBrowser: entry.OpenBrowser, Source: "agent"}, nil + } + if policy.Default == nil { + return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy default entry was not persisted") + } + return BrowserPolicySelection{Scope: "default", OpenBrowser: policy.Default.OpenBrowser, Source: "default"}, nil +} + func newBrowserPolicyCommand() *cobra.Command { cmd := &cobra.Command{ Use: "browser-policy", diff --git a/internal/pat/coverage_edges_test.go b/internal/pat/coverage_edges_test.go index 103c619a..9256adbb 100644 --- a/internal/pat/coverage_edges_test.go +++ b/internal/pat/coverage_edges_test.go @@ -105,6 +105,9 @@ func TestCrossPlatformCoverageBrowserPolicyInjectedEdges(t *testing.T) { if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) { t.Fatalf("read error = %v", err) } + if _, err := ReadStoredBrowserPolicy("x", ""); !errors.Is(err, failure) { + t.Fatalf("readback error = %v", err) + } patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil } if _, err := LoadBrowserPolicy("x"); err == nil { t.Fatal("invalid policy decoded") @@ -179,6 +182,53 @@ func TestCrossPlatformCoverageBrowserPolicyCommandSetError(t *testing.T) { } } +func TestCrossPlatformCoverageBrowserPolicyReadbackAndSelectionEdges(t *testing.T) { + configDir := t.TempDir() + if got := BrowserPolicyConfigDir(); strings.TrimSpace(got) == "" { + t.Fatal("browser policy config directory is empty") + } + if _, err := ReadStoredBrowserPolicy(configDir, "bad code"); err == nil { + t.Fatal("invalid readback agent code succeeded") + } + if _, err := ReadStoredBrowserPolicy(configDir, "missing"); err == nil { + t.Fatal("missing agent readback succeeded") + } + if _, err := ReadStoredBrowserPolicy(configDir, ""); err == nil { + t.Fatal("missing default readback succeeded") + } + if _, err := SetBrowserPolicy(configDir, "agent", false); err != nil { + t.Fatal(err) + } + agent, err := ReadStoredBrowserPolicy(configDir, "agent") + if err != nil || agent.Scope != "agent" || agent.AgentCode != "agent" || agent.OpenBrowser { + t.Fatalf("agent readback = %#v, %v", agent, err) + } + resolvedAgent, err := ResolveBrowserPolicy(configDir, "agent") + if err != nil || resolvedAgent.Source != "agent" || resolvedAgent.OpenBrowser { + t.Fatalf("agent selection = %#v, %v", resolvedAgent, err) + } + if _, err := SetBrowserPolicy(configDir, "", true); err != nil { + t.Fatal(err) + } + defaultPolicy, err := ReadStoredBrowserPolicy(configDir, "") + if err != nil || defaultPolicy.Scope != "default" || !defaultPolicy.OpenBrowser { + t.Fatalf("default readback = %#v, %v", defaultPolicy, err) + } + resolvedDefault, err := ResolveBrowserPolicy(configDir, "unknown") + if err != nil || resolvedDefault.Source != "default" || !resolvedDefault.OpenBrowser { + t.Fatalf("default selection = %#v, %v", resolvedDefault, err) + } + empty, err := ResolveBrowserPolicy(t.TempDir(), "") + if err != nil || empty.Source == "" || !empty.OpenBrowser { + t.Fatalf("built-in selection = %#v, %v", empty, err) + } + + cmd := newBrowserPolicyCommand() + if err := cmd.Execute(); err == nil { + t.Fatal("browser policy command accepted missing --enabled") + } +} + func TestCrossPlatformCoveragePATPureHelperEdges(t *testing.T) { if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" { t.Fatalf("product codes = %#v", got) diff --git a/internal/pat/pat.go b/internal/pat/pat.go index d4a17c36..f3684019 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -18,6 +18,7 @@ package pat import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -91,5 +92,17 @@ DWS_CHANNEL 只用于上游 channelCode。`, patCmd.AddCommand(newChmodCommand(c)) patCmd.AddCommand(newBrowserPolicyCommand()) + // Shortcuts are mounted before this open-source PAT tree in the app. Fold + // that pre-existing service group into the native PAT parent so Cobra and + // Schema both see exactly one top-level path while preserving PAT's group + // behavior and adding every +leaf alongside chmod/browser-policy. + for _, existing := range root.Commands() { + if existing.Name() != patCmd.Name() { + continue + } + cobracmd.MergeCommandTree(patCmd, existing) + root.RemoveCommand(existing) + break + } root.AddCommand(patCmd) } diff --git a/internal/pat/pat_register_test.go b/internal/pat/pat_register_test.go new file mode 100644 index 00000000..30396bdd --- /dev/null +++ b/internal/pat/pat_register_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pat + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) { + root := &cobra.Command{Use: "dws"} + shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"} + shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"}) + root.AddCommand(&cobra.Command{Use: "aaa-other"}) + root.AddCommand(shortcutGroup) + + RegisterCommands(root, &fakeToolCaller{}) + + var patGroups []*cobra.Command + for _, command := range root.Commands() { + if command.Name() == "pat" { + patGroups = append(patGroups, command) + } + } + if len(patGroups) != 1 { + t.Fatalf("top-level PAT groups = %d, want 1", len(patGroups)) + } + for _, child := range []string{"+fixture", "chmod", "browser-policy"} { + if found, _, err := patGroups[0].Find([]string{child}); err != nil || found == nil || found.Name() != child { + t.Errorf("merged PAT child %q missing: found=%v err=%v", child, found, err) + } + } + if patGroups[0].Short != "行为授权管理" { + t.Fatalf("native PAT group metadata was not preserved: %q", patGroups[0].Short) + } +} diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index cb6d31f7..b0ddb2f4 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -32,13 +32,16 @@ import ( _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devdoc" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/hrbrain" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/live" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/pat" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart" diff --git a/internal/shortcut/builtin/devdoc_semantic_catalog_test.go b/internal/shortcut/builtin/devdoc_semantic_catalog_test.go new file mode 100644 index 00000000..23cb16d3 --- /dev/null +++ b/internal/shortcut/builtin/devdoc_semantic_catalog_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDevdocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_devdoc.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "devdoc" || len(source.Shortcuts) != 1 { + t.Fatalf("service/count = %q/%d", source.Service, len(source.Shortcuts)) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "devdoc" { + registered[item.Command] = item + } + } + if len(registered) != 1 { + t.Fatalf("registered Devdoc shortcuts = %d, want 1", len(registered)) + } + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok { + t.Fatalf("catalog entry %s is not registered", command) + } + if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable { + t.Fatalf("catalog entry is not reviewed private unavailable: %#v", record) + } + if !item.Hidden || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || item.SemanticDelta != record.SemanticDelta { + t.Fatalf("runtime semantic facts drifted: %#v", item) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.OutputRollout != output.RolloutUnifiedActive || strings.TrimSpace(item.Safety.Effect) == "" { + t.Fatal("unavailable Devdoc Shortcut lacks Contract/Result/Safety/unified output") + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || !strings.Contains(string(item.Contract.Result.DataSchema), "zeroMatchProven") { + t.Fatal("unavailable Devdoc Shortcut does not publish the semantic zero-match boundary") + } + } +} diff --git a/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go b/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go new file mode 100644 index 00000000..3e822970 --- /dev/null +++ b/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go @@ -0,0 +1,75 @@ +// 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 TestCrossPlatformCoverageHRbrainSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_hrbrain.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "hrbrain" { + registered[item.Command] = item + } + } + if source.Service != "hrbrain" || len(source.Shortcuts) != 11 || len(registered) != 11 { + t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered)) + } + var missing, stale []string + blockerCounts := map[string]int{} + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if record.Public || record.Availability != "" || !record.Reviewed || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || !item.Hidden { + t.Errorf("%s availability/review drift", command) + } + if strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic delta drift", command) + } + if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Result/Safety/unified output", command) + } + if item.Contract.Interface == nil || strings.Contains(item.Contract.Interface.Reason, "shortcut_defect") { + t.Errorf("%s lacks a reviewed non-Shortcut blocker", command) + } else if strings.Contains(item.Contract.Interface.Reason, "classified=adapter_business_service") { + blockerCounts["adapter_business_service"]++ + } else if strings.Contains(item.Contract.Interface.Reason, "classified=tenant_fixture") { + blockerCounts["tenant_fixture"]++ + } else { + t.Errorf("%s has unknown blocker classification", 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 blockerCounts["adapter_business_service"] != 9 || blockerCounts["tenant_fixture"] != 2 { + t.Fatalf("blocker counts = %#v", blockerCounts) + } +} diff --git a/internal/shortcut/builtin/pat_semantic_catalog_test.go b/internal/shortcut/builtin/pat_semantic_catalog_test.go new file mode 100644 index 00000000..be27ac2e --- /dev/null +++ b/internal/shortcut/builtin/pat_semantic_catalog_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoveragePATSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_pat.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "pat" { + registered[item.Command] = item + } + } + if source.Service != "pat" || len(source.Shortcuts) != 2 || len(registered) != 2 { + t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered)) + } + public := 0 + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok || !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Fatalf("%s semantic review drift", command) + } + if record.Public { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" { + t.Fatalf("%s public contract drift", command) + } + } else if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable { + t.Fatalf("%s unavailable visibility drift", command) + } + if command == "+authorize" { + if record.Disposition != shortcut.DispositionAliasInternal || record.Primary != "pat chmod" || item.PrimaryCommand != "pat chmod" || !strings.Contains(item.SemanticDelta, "classified=routed") { + t.Fatalf("%s must route to the reviewed pat chmod execution chain", command) + } + if item.Contract.Interface == nil || !strings.Contains(item.Contract.Interface.Reason, "blocked_fixture") || !strings.Contains(item.Contract.Interface.Reason, "classified=routed") { + t.Fatalf("%s must separate routed implementation from unproved live write", command) + } + } + } + if public != 1 { + t.Fatalf("public PAT shortcuts = %d, want 1", public) + } +} diff --git a/internal/shortcut/devdoc/devdoc.go b/internal/shortcut/devdoc/devdoc.go new file mode 100644 index 00000000..d930926e --- /dev/null +++ b/internal/shortcut/devdoc/devdoc.go @@ -0,0 +1,240 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package devdoc registers strict Shortcut adapters for DingTalk Open Platform +// documentation discovery. +package devdoc + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const devdocCompositeReason = "Unavailable after exact Shortcut plus raw isolation: the downstream interface performs semantic recall and returned nonempty neighbors for runtime-random zero-match candidates, so no guaranteed zero-match query can satisfy the public list/search proof contract." + +type devdocPageEvidence struct { + CurrentPage int + PageSize int + TotalCount int + HasMore bool +} + +func devdocSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验但未满足公开 zero-match 证明的语义文档搜索页","properties":{"count":{"type":"integer","description":"当前页文档数量"},"documents":{"type":"array","description":"当前页开放平台文档","items":{"type":"object","description":"带稳定 URL 的开放平台文档","properties":{"title":{"type":"string","description":"文档标题"},"url":{"type":"string","description":"文档稳定 URL"},"description":{"type":"string","description":"文档摘要"}},"required":["title","url"],"additionalProperties":false}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的语义召回总数"},"complete":{"type":"boolean","description":"是否到达语义召回末页;不表示 query zero-match 已证明"},"matchSemantics":{"type":"string","description":"下游搜索匹配语义","enum":["semantic"]},"zeroMatchProven":{"type":"boolean","description":"是否通过保证零匹配 query 证明公开投影;当前固定为 false"}},"required":["count","documents","currentPage","pageSize","totalCount","complete","matchSemantics","zeroMatchProven"],"additionalProperties":false}`), + } +} + +func devdocPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "page", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +var SearchDocs = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "devdoc", + Command: "+search-docs", + Product: "devdoc", + Description: "审计钉钉开放平台语义文档搜索结果", + Intent: "需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。", + Risk: shortcut.RiskRead, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devdoc", Name: "shortcut_search_docs", CanonicalPath: "devdoc.shortcut_search_docs", + CLIPath: "devdoc +search-docs", PrimaryCLIPath: "devdoc +search-docs", + }, + Description: "审计钉钉开放平台语义文档搜索结果", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: devdocCompositeReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "审计钉钉开放平台语义文档搜索结果", + UseWhen: []string{"需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。"}, + AvoidWhen: []string{"需要精确匹配或保证零命中时不要使用;搜索用户业务文档时使用 drive/wiki/doc"}, + Examples: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "query", Property: "keyword"}, + {Name: "page", Property: "page", InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, + }, + Result: devdocSearchResult(), + Pagination: devdocPagination(), + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词去除空白后必须非空"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "size", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量必须在 1 到 50 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "搜索关键词去除空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "每页数量必须在 1 到 50 之间"}, + }, + Tips: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空") + } + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于 0") + } + if size := rt.Int("size"); size < 1 || size > 50 { + return apperrors.NewValidation("--size 必须在 1 到 50 之间") + } + return nil + }, + Execute: executeSearchDocs, +} + +func executeSearchDocs(rt *shortcut.RuntimeContext) error { + page, size := rt.Int("page"), rt.Int("size") + data, err := rt.CallMCPData("devdoc", "search_open_platform_docs", map[string]any{ + "keyword": strings.TrimSpace(rt.Str("query")), + "page": page, + "size": size, + }) + if err != nil { + return err + } + documents, evidence, err := devdocProjectSearch(data, page, size) + if err != nil { + return err + } + payload := map[string]any{ + "count": len(documents), + "documents": documents, + "currentPage": evidence.CurrentPage, + "pageSize": evidence.PageSize, + "totalCount": evidence.TotalCount, + "complete": !evidence.HasMore, + "matchSemantics": "semantic", + "zeroMatchProven": false, + } + next := "" + if evidence.HasMore { + next = strconv.Itoa(evidence.CurrentPage + 1) + } + // Strict page evidence guarantees the only two valid framework states: + // terminal+empty-next or continuing+positive next page. + pagination, _ := output.NewPagination(!evidence.HasMore, next) + meta := &output.Meta{Count: output.NewCount(len(documents)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func devdocProjectSearch(data map[string]any, requestedPage, requestedSize int) ([]map[string]any, devdocPageEvidence, error) { + const operation = "devdoc/search_open_platform_docs" + items, err := responsecheck.RequireObjectCollection(data, operation, "result.items") + if err != nil { + return nil, devdocPageEvidence{}, err + } + // RequireObjectCollection already proved that result is an object while + // resolving result.items, so a second fallible shape check is unreachable. + result, _ := data["result"].(map[string]any) + evidence, err := devdocParsePage(result, requestedPage, requestedSize, len(items)) + if err != nil { + return nil, devdocPageEvidence{}, err + } + documents := make([]map[string]any, 0, len(items)) + seenURLs := make(map[string]struct{}, len(items)) + for index, item := range items { + title, titleOK := devdocNonEmptyString(item["title"]) + url, urlOK := devdocNonEmptyString(item["url"]) + if !titleOK || !urlOK { + return nil, devdocPageEvidence{}, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("文档结果第 %d 项缺少非空 title 或 url", index)) + } + if _, duplicate := seenURLs[url]; duplicate { + return nil, devdocPageEvidence{}, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("文档结果第 %d 项 URL 重复", index)) + } + seenURLs[url] = struct{}{} + document := map[string]any{"title": title, "url": url} + if description, ok := devdocOptionalString(item["desc"]); ok { + document["description"] = description + } + documents = append(documents, document) + } + return documents, evidence, nil +} + +func devdocParsePage(result map[string]any, requestedPage, requestedSize, itemCount int) (devdocPageEvidence, error) { + const operation = "devdoc/search_open_platform_docs" + currentPage, ok := devdocInteger(result["currentPage"]) + if !ok || currentPage < 1 || currentPage != requestedPage { + return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_page_mismatch", "响应 currentPage 缺失、错型或与请求页码不一致") + } + pageSize, ok := devdocInteger(result["pageSize"]) + if !ok || pageSize < 1 || pageSize != requestedSize { + return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_size_mismatch", "响应 pageSize 缺失、错型或与请求分页大小不一致") + } + totalCount, ok := devdocInteger(result["totalCount"]) + if !ok || totalCount < 0 || totalCount < itemCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "invalid_total_count", "响应 totalCount 缺失、错型、为负数或小于当前页条数") + } + if itemCount > pageSize { + return devdocPageEvidence{}, responsecheck.Error(operation, "page_size_exceeded", "当前页结果数量超过服务端 pageSize") + } + hasMore, ok := result["hasMore"].(bool) + if !ok { + return devdocPageEvidence{}, responsecheck.Error(operation, "malformed_pagination", "响应 hasMore 缺失或不是布尔值") + } + pageEnd := currentPage * pageSize + if hasMore && pageEnd >= totalCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=true 但 currentPage/pageSize/totalCount 已到达末页") + } + if !hasMore && pageEnd < totalCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=false 但 totalCount 表明仍有下一页") + } + return devdocPageEvidence{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil +} + +func devdocInteger(value any) (int, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || typed != math.Trunc(typed) || typed > float64(math.MaxInt) { + return 0, false + } + return int(typed), true + case int: + return typed, true + default: + return 0, false + } +} + +func devdocNonEmptyString(value any) (string, bool) { + text, ok := value.(string) + text = strings.TrimSpace(text) + return text, ok && text != "" +} + +func devdocOptionalString(value any) (string, bool) { + if value == nil { + return "", false + } + return devdocNonEmptyString(value) +} + +func init() { + shortcut.Register(SearchDocs) +} diff --git a/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go b/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go new file mode 100644 index 00000000..6baaf0e3 --- /dev/null +++ b/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go @@ -0,0 +1,243 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devdoc + +import ( + "bytes" + "context" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type devdocCoverageCaller struct { + response string + err error + calls int +} + +func (caller *devdocCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil +} +func (*devdocCoverageCaller) Format() string { return "json" } +func (*devdocCoverageCaller) DryRun() bool { return false } +func (*devdocCoverageCaller) Fields() string { return "" } +func (*devdocCoverageCaller) JQ() string { return "" } + +func runDevdocCoverage(t *testing.T, caller *devdocCoverageCaller, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devdoc"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(SearchDocs))) + root.AddCommand(service) + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"devdoc", SearchDocs.Command}, args...)) + executed, err := root.ExecuteC() + if err == nil { + _, _, err = output.EmitStoredResult(executed) + } + return stdout.String(), err +} + +func TestCrossPlatformCoverageDevdocUnavailableContractIsUnifiedTypedAndTruthful(t *testing.T) { + if SearchDocs.OutputRollout != output.RolloutUnifiedActive { + t.Fatalf("rollout = %q", SearchDocs.OutputRollout) + } + if !SearchDocs.Hidden || SearchDocs.Availability != "unavailable" || SearchDocs.Contract.Interface == nil || SearchDocs.Contract.Interface.Availability != "unavailable" { + t.Fatal("Devdoc semantic search must remain hidden/unavailable until a guaranteed zero-match query is provable") + } + if SearchDocs.Contract.Empty() || SearchDocs.Contract.Result == nil || SearchDocs.Contract.Pagination == nil { + t.Fatal("Devdoc Shortcut must retain Contract, Result, and Pagination for audit") + } + resultSchema := string(SearchDocs.Contract.Result.DataSchema) + if !strings.Contains(resultSchema, "matchSemantics") || !strings.Contains(resultSchema, "zeroMatchProven") || strings.TrimSpace(SearchDocs.Safety.Effect) == "" { + t.Fatal("Devdoc Result must declare semantic matching and the unproven zero-match boundary") + } +} + +func TestCrossPlatformCoverageDevdocSearchRejectsBrokenResponses(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, "currentPage": float64(3), "pageSize": float64(10), + "totalCount": float64(2), "hasMore": false, + }, + } + documents, page, err := devdocProjectSearch(explicitEmpty, 3, 10) + if err != nil || len(documents) != 0 || page.HasMore { + t.Fatalf("explicit empty page: documents=%v page=%+v err=%v", documents, page, err) + } + // This proves only that an explicitly empty terminal page is parsed + // truthfully. It is deliberately not evidence for a zero-match query. + + broken := map[string]map[string]any{ + "empty response": {}, + "missing success": {"result": map[string]any{"items": []any{}}}, + "success false": {"success": false, "result": map[string]any{"items": []any{}}}, + "wrong success type": {"success": "true", "result": map[string]any{"items": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection type": {"success": true, "result": map[string]any{"items": map[string]any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}}, + "missing item identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}}, + "duplicate identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "one", "url": "https://example.invalid/doc"}, map[string]any{"title": "two", "url": "https://example.invalid/doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(2), "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"items": []any{}}}, + "mismatched page": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}}, + "wrong hasMore type": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": "false"}}, + "false completion": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(11), "hasMore": false}}, + "false continuation": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(10), "hasMore": true}}, + } + for name, payload := range broken { + t.Run(name, func(t *testing.T) { + if got, _, err := devdocProjectSearch(payload, 1, 10); err == nil { + t.Fatalf("broken response accepted: %#v", got) + } + }) + } +} + +func TestCrossPlatformCoverageDevdocSearchProjectsKnownNonemptyPage(t *testing.T) { + payload := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{ + map[string]any{"title": "Authentication", "url": "https://example.invalid/auth", "desc": "Guide"}, + map[string]any{"title": "Errors", "url": "https://example.invalid/errors", "desc": nil}, + }, + "currentPage": float64(1), "pageSize": float64(2), "totalCount": float64(3), "hasMore": true, + }, + } + documents, page, err := devdocProjectSearch(payload, 1, 2) + if err != nil || len(documents) != 2 || !page.HasMore { + t.Fatalf("documents=%#v page=%+v err=%v", documents, page, err) + } + if documents[0]["description"] != "Guide" { + t.Fatalf("first document=%#v", documents[0]) + } + if _, exists := documents[1]["description"]; exists { + t.Fatalf("nil description must be omitted: %#v", documents[1]) + } +} + +func TestCrossPlatformCoverageDevdocExecuteValidationAndPagination(t *testing.T) { + blankCommand := corecmd.New(shortcut.FromShortcut(SearchDocs)) + if err := blankCommand.Flags().Set("query", " "); err != nil { + t.Fatal(err) + } + if err := SearchDocs.Validate(shortcut.RuntimeContextForTest(blankCommand, SearchDocs)); err == nil { + t.Fatal("blank query validation succeeded") + } + for name, args := range map[string][]string{ + "blank query": {"--query", " "}, + "zero page": {"--query", "query", "--page", "0"}, + "zero size": {"--query", "query", "--size", "0"}, + "large size": {"--query", "query", "--size", "51"}, + } { + t.Run(name, func(t *testing.T) { + caller := &devdocCoverageCaller{} + if _, err := runDevdocCoverage(t, caller, args...); err == nil { + t.Fatal("invalid input succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before validation = %d", caller.calls) + } + }) + } + + for name, response := range map[string]string{ + "terminal": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":10,"totalCount":1,"hasMore":false}}`, + "continuing": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`, + } { + t.Run(name, func(t *testing.T) { + caller := &devdocCoverageCaller{response: response} + args := []string{"--query", " query "} + if name == "continuing" { + args = append(args, "--size", "1") + } + if _, err := runDevdocCoverage(t, caller, args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + } + + caller := &devdocCoverageCaller{err: errors.New("transport")} + if _, err := runDevdocCoverage(t, caller, "--query", "query"); err == nil { + t.Fatal("transport failure succeeded") + } + malformed := &devdocCoverageCaller{response: `{"success":true,"result":{"items":"bad"}}`} + if _, err := runDevdocCoverage(t, malformed, "--query", "query"); err == nil { + t.Fatal("malformed projection succeeded") + } +} + +func TestCrossPlatformCoverageDevdocPageAndScalarEdges(t *testing.T) { + base := func() map[string]any { + return map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, "currentPage": float64(1), "pageSize": float64(2), + "totalCount": float64(0), "hasMore": false, + }, + } + } + for name, mutate := range map[string]func(map[string]any){ + "page size mismatch": func(result map[string]any) { result["pageSize"] = float64(3) }, + "negative total": func(result map[string]any) { result["totalCount"] = float64(-1) }, + "total below items": func(result map[string]any) { + result["items"] = []any{map[string]any{"title": "one", "url": "https://example.invalid/one"}} + result["totalCount"] = float64(0) + }, + "items exceed size": func(result map[string]any) { + result["items"] = []any{ + map[string]any{"title": "one", "url": "https://example.invalid/one"}, + map[string]any{"title": "two", "url": "https://example.invalid/two"}, + map[string]any{"title": "three", "url": "https://example.invalid/three"}, + } + result["totalCount"] = float64(3) + }, + } { + t.Run(name, func(t *testing.T) { + payload := base() + mutate(payload["result"].(map[string]any)) + if _, _, err := devdocProjectSearch(payload, 1, 2); err == nil { + t.Fatal("invalid page succeeded") + } + }) + } + + for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "1"} { + if _, ok := devdocInteger(value); ok { + t.Fatalf("invalid integer accepted: %#v", value) + } + } + if got, ok := devdocInteger(7); !ok || got != 7 { + t.Fatalf("int projection = %d, %v", got, ok) + } + if _, ok := devdocOptionalString(7); ok { + t.Fatal("non-string optional value accepted") + } +} diff --git a/internal/shortcut/hrbrain/hrbrain.go b/internal/shortcut/hrbrain/hrbrain.go new file mode 100644 index 00000000..f056bbca --- /dev/null +++ b/internal/shortcut/hrbrain/hrbrain.go @@ -0,0 +1,632 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package hrbrain registers fail-closed Shortcut adapters for DingTalk +// Organization Brain reads. Every declaration remains unavailable until the +// lower service supplies a verifiable non-null result and safe live fixtures. +package hrbrain + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + hrbrainBlockerAdapterBusiness = "adapter_business_service" + hrbrainBlockerTenantFixture = "tenant_fixture" +) + +func hrbrainUnavailableReason(command string) string { + switch command { + case "+get-pool", "+list-pool-employees": + return "classified=" + hrbrainBlockerTenantFixture + "; exact Shortcut and raw atomic calls reach the same remote operation and upstream error, but no safe nonempty pool fixture exists to prove identity and pagination." + case "+list-pools", "+profile-labels": + return "classified=" + hrbrainBlockerAdapterBusiness + "; raw atomic calls return success=true with result=null and the exact Shortcut rejects the response, so the business response contract cannot prove a collection or legitimate empty result." + case "+profile-metadata", "+query-profile", "+profile-career", "+profile-performance", "+search-employees", "+search-employees-structured", "+search-fields": + return "classified=" + hrbrainBlockerAdapterBusiness + "; exact Shortcut and raw atomic calls reach the same remote server, operation, and upstream error, so no Shortcut-layer defect can be repaired without downstream capability or business-service recovery." + default: + panic("missing reviewed HRbrain blocker classification for " + command) + } +} + +type hrbrainPage struct { + CurrentPage int + PageSize int + TotalCount int + HasMore bool +} + +func hrbrainReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func hrbrainObjectResult(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":"严格校验且身份匹配的 HRbrain 业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.name", "value.mobile", "value.email", "value.workNo", "value.userId"}, + } +} + +func hrbrainCollectionResult(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":"当前响应中的有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}}},"required":["count","items"],"additionalProperties":false}`, + description, description, + )), + SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"}, + } +} + +func hrbrainPageResult(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":"当前页有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"complete":{"type":"boolean","description":"服务端证据是否证明到达末页"}},"required":["count","items","currentPage","pageSize","totalCount","complete"],"additionalProperties":false}`, + description, description, + )), + SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"}, + } +} + +func hrbrainPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, CursorParameter: "page", + MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hrbrainContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + path := "hrbrain " + command + return corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "hrbrain", Name: name, CanonicalPath: "hrbrain." + name, + CLIPath: path, PrimaryCLIPath: path, + }, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: hrbrainUnavailableReason(command), + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"该 HRbrain Shortcut 当前 unavailable;不要把 null、业务错误或缺少安全 fixture 当作成功,基础通讯录查询改用 contact"}, + Examples: examples, + }, + Parameters: params, + Result: result, + Pagination: pagination, + } +} + +func hrbrainBase(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, flags []shortcut.Flag, params []contract.ParamDecl, examples ...string) shortcut.Shortcut { + return shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "hrbrain", + Command: command, + Product: "hrbrain", + Description: description, + Intent: intent, + Risk: shortcut.RiskRead, + Safety: hrbrainReadSafety(), + Contract: hrbrainContract(command, description, intent, result, pagination, params, examples...), + Flags: flags, + Tips: examples, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + } +} + +var ListPools = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+list-pools", "查询组织大脑人才池列表", "需要按名称、类型、创建人或标签发现人才池时使用;当前必须保持 unavailable,直到下游返回可验证分页数组。", + hrbrainPageResult("严格校验的人才池搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "人才池名称关键词"}, + {Name: "pool-type", Type: shortcut.FlagString, Desc: "人才池类型"}, + {Name: "creator", Type: shortcut.FlagString, Desc: "创建人稳定身份"}, + {Name: "labels", Type: shortcut.FlagStringSlice, Desc: "标签列表"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "keyword", Property: "keyword"}, {Name: "pool-type", Property: "poolType"}, + {Name: "creator", Property: "creator"}, {Name: "labels", Property: "labels"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +list-pools --page 1 --page-size 20 --format json`, + ) + declaration.Constraints = hrbrainPageConstraints() + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")} + hrbrainAddOptional(params, rt, "keyword", "keyword") + hrbrainAddOptional(params, rt, "pool-type", "poolType") + hrbrainAddOptional(params, rt, "creator", "creator") + if rt.Changed("labels") { + params["labels"] = hrbrainCleanValues(rt.StrSlice("labels")) + } + return hrbrainCallPage(rt, "list_talent_pools", params, "poolCode") + } + return declaration +}() + +var GetPool = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+get-pool", "根据 poolCode 查询人才池详情", "已知稳定 poolCode 并需要读取同一人才池详情时使用;没有安全非空 fixture 前保持 unavailable。", + hrbrainObjectResult("身份匹配的人才池详情"), nil, + []shortcut.Flag{{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}}, + []contract.ParamDecl{{Name: "pool-code", Property: "poolCode"}}, + `dws hrbrain +get-pool --pool-code --format json`, + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallObject(rt, "get_talent_pool_detail", map[string]any{"poolCode": rt.Str("pool-code")}, rt.Str("pool-code"), "poolCode") + } + return declaration +}() + +var ListPoolEmployees = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+list-pool-employees", "查询指定人才池内的员工列表", "已知稳定 poolCode 并需要分页读取池内员工时使用;缺少安全人才池与员工 fixture 前保持 unavailable。", + hrbrainPageResult("严格校验的人才池员工页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "pool-code", Property: "poolCode"}, {Name: "page", Property: "currentPage", InterfaceType: "number"}, + {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +list-pool-employees --pool-code --page 1 --page-size 20 --format json`, + ) + declaration.Constraints = hrbrainPageConstraints() + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallPage(rt, "list_pool_employees", map[string]any{ + "poolCode": rt.Str("pool-code"), "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"), + }, "workNo", "staffId", "userId") + } + return declaration +}() + +var ProfileMetadata = hrbrainCollectionShortcut( + "+profile-metadata", "查询员工档案元数据结构", "已知工号并需要发现可查询档案模块与字段时使用;下游工具当前不可验证,保持 unavailable。", + "get_profile_metadata", "work-no", "workNo", "档案元数据", []string{"modelCode", "fieldCode", "code"}, +) + +var QueryProfile = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+query-profile", "按模块批量查询员工档案数据", "已知工号、模块与字段编码并需要批量读取档案时使用;下游可用性和结果合同未通过 live 验证。", + hrbrainCollectionResult("严格校验的员工档案模块结果"), nil, + []shortcut.Flag{ + {Name: "work-no", Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}, + {Name: "data-queries", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的模块查询条件"}, + }, + []contract.ParamDecl{{Name: "work-no", Property: "workNo"}, {Name: "data-queries", Property: "dataQueries"}}, + `dws hrbrain +query-profile --work-no --data-queries '[{"modelCode":"basic","fields":["name"]}]' --format json`, + ) + declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"data-queries"}, Description: "必须是非空 JSON 数组的模块查询条件"}} + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + _, err := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries") + return err + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + // Validate already established the exact non-empty object-array shape. + queries, _ := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries") + return hrbrainCallCollection(rt, "query_profile_data", map[string]any{"workNo": rt.Str("work-no"), "dataQueries": queries}, "modelCode", "moduleCode") + } + return declaration +}() + +var ProfileLabels = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+profile-labels", "批量查询员工档案标签", "已知一组稳定工号并需要读取标签时使用;下游当前 success=true 但 result=null,保持 unavailable。", + hrbrainCollectionResult("严格校验的员工标签结果"), nil, + []shortcut.Flag{ + {Name: "staff-ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "一个或多个非空员工工号"}, + {Name: "all-label", Type: shortcut.FlagBool, Desc: "是否返回全部标签"}, + }, + []contract.ParamDecl{{Name: "staff-ids", Property: "staffIds"}, {Name: "all-label", Property: "allLabel"}}, + `dws hrbrain +profile-labels --staff-ids --format json`, + ) + declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "必须包含一个或多个非空员工工号"}} + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + if len(hrbrainCleanValues(rt.StrSlice("staff-ids"))) == 0 { + return apperrors.NewValidation("--staff-ids 必须包含一个或多个非空员工工号") + } + return nil + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"staffIds": hrbrainCleanValues(rt.StrSlice("staff-ids"))} + if rt.Changed("all-label") { + params["allLabel"] = rt.Bool("all-label") + } + return hrbrainCallCollection(rt, "get_profile_label", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var ProfileCareer = hrbrainCollectionShortcut( + "+profile-career", "查询员工公司内职业历程", "已知稳定工号并需要读取岗位或职级变动历史时使用;下游未通过 live 可用性验证。", + "get_employee_career", "work-no", "workNo", "职业历程", []string{"careerId", "id", "workNo"}, +) + +var ProfilePerformance = hrbrainCollectionShortcut( + "+profile-performance", "查询员工历史绩效记录", "已知稳定工号并需要读取历史绩效时使用;下游未通过 live 可用性验证。", + "get_employee_performance", "work-no", "workNo", "绩效记录", []string{"performanceId", "id", "workNo"}, +) + +var SearchEmployees = func() shortcut.Shortcut { + declaration := hrbrainEmployeeSearchBase( + "+search-employees", "按关键词、部门、职务、职级或人才池搜索员工", "需要按至少一个基础过滤条件搜索员工时使用;当前下游返回业务错误且缺少安全非空 fixture。", + "search_employees", + ) + declaration.Constraints = append(hrbrainPageConstraints(), shortcut.Constraint{ + Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "dept-name", "position-name", "job-level", "pool-code"}, + Description: "至少提供一个员工搜索过滤条件,禁止无条件枚举组织人员", + }) + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")} + for _, pair := range [][2]string{{"keyword", "keyword"}, {"dept-name", "deptName"}, {"position-name", "positionName"}, {"job-level", "jobLevel"}, {"pool-code", "poolCode"}} { + hrbrainAddOptional(params, rt, pair[0], pair[1]) + } + return hrbrainCallPage(rt, "search_employees", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var SearchEmployeesStructured = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+search-employees-structured", "使用高级条件表达式搜索员工", "已先取得有权限字段,并需要组合过滤表达式搜索员工时使用;下游工具当前不可用。", + hrbrainPageResult("严格校验的高级员工搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "origin-json", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 对象的搜索表达式"}, + {Name: "fields", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的返回字段"}, + {Name: "order-by", Type: shortcut.FlagStringSlice, Desc: "排序字段列表"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "origin-json", Property: "originJson"}, {Name: "fields", Property: "fields"}, {Name: "order-by", Property: "orderByClauses"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +search-employees-structured --origin-json '{"rules":[],"combinator":"and"}' --fields '[{"label":"name","value":"name"}]' --format json`, + ) + declaration.Constraints = append(hrbrainPageConstraints(), + shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"origin-json"}, Description: "必须是非空 JSON 对象的搜索表达式"}, + shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"fields"}, Description: "必须是非空 JSON 数组的返回字段"}, + ) + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + if err := hrbrainValidatePage(rt); err != nil { + return err + } + if _, err := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json"); err != nil { + return err + } + _, err := hrbrainJSONArray(rt.Str("fields"), "--fields") + return err + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + // Validate already established both structured input shapes. + origin, _ := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json") + fields, _ := hrbrainJSONArray(rt.Str("fields"), "--fields") + params := map[string]any{ + "originJson": string(mustJSON(origin)), "fields": fields, + "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"), + } + if rt.Changed("order-by") { + params["orderByClauses"] = hrbrainCleanValues(rt.StrSlice("order-by")) + } + return hrbrainCallPage(rt, "search_employees_structured", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var SearchFields = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+search-fields", "获取当前身份可用的高级人才搜索字段", "构造高级员工搜索表达式前发现字段与操作符时使用;下游工具当前返回业务错误,保持 unavailable。", + hrbrainCollectionResult("严格校验的高级搜索字段"), nil, nil, nil, + `dws hrbrain +search-fields --format json`, + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallCollection(rt, "get_search_fields", nil, "fieldCode", "code", "value") + } + return declaration +}() + +func hrbrainCollectionShortcut(command, description, intent, tool, flag, property, resultDescription string, identityKeys []string) shortcut.Shortcut { + declaration := hrbrainBase( + command, description, intent, hrbrainCollectionResult("严格校验的"+resultDescription), nil, + []shortcut.Flag{{Name: flag, Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}}, + []contract.ParamDecl{{Name: flag, Property: property}}, + fmt.Sprintf("dws hrbrain %s --%s --format json", command, flag), + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallCollection(rt, tool, map[string]any{property: rt.Str(flag)}, identityKeys...) + } + return declaration +} + +func hrbrainEmployeeSearchBase(command, description, intent, _ string) shortcut.Shortcut { + return hrbrainBase( + command, description, intent, hrbrainPageResult("严格校验的员工搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "姓名或工号关键词"}, + {Name: "dept-name", Type: shortcut.FlagString, Desc: "部门名称"}, + {Name: "position-name", Type: shortcut.FlagString, Desc: "职务名称"}, + {Name: "job-level", Type: shortcut.FlagString, Desc: "职级"}, + {Name: "pool-code", Type: shortcut.FlagString, Desc: "人才池稳定 poolCode"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "keyword", Property: "keyword"}, {Name: "dept-name", Property: "deptName"}, {Name: "position-name", Property: "positionName"}, + {Name: "job-level", Property: "jobLevel"}, {Name: "pool-code", Property: "poolCode"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +search-employees --keyword --page 1 --page-size 20 --format json`, + ) +} + +func hrbrainPageConstraints() []shortcut.Constraint { + return []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量必须在 1 到 100 之间"}, + } +} + +func hrbrainValidatePage(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于 0") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + return nil +} + +func hrbrainCallObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected string, identityKeys ...string) error { + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, "hrbrain/"+tool) + if err != nil { + return err + } + identity := hrbrainIdentity(value, identityKeys...) + if identity == "" { + return responsecheck.Error("hrbrain/"+tool, "missing_stable_id", "HRbrain 详情对象缺少稳定身份") + } + if expected != "" && identity != strings.TrimSpace(expected) { + return responsecheck.Error("hrbrain/"+tool, "identity_mismatch", "HRbrain 详情对象身份与请求目标不一致") + } + return rt.Output(map[string]any{"value": value}) +} + +func hrbrainCallCollection(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error { + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + items, err := hrbrainRequireCollectionResult(data, "hrbrain/"+tool, identityKeys...) + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "items": items}) +} + +func hrbrainCallPage(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error { + page, size := rt.Int("page"), rt.Int("page-size") + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + items, evidence, err := hrbrainProjectPage(data, "hrbrain/"+tool, page, size, identityKeys...) + if err != nil { + return err + } + payload := map[string]any{ + "count": len(items), "items": items, "currentPage": evidence.CurrentPage, + "pageSize": evidence.PageSize, "totalCount": evidence.TotalCount, "complete": !evidence.HasMore, + } + next := "" + if evidence.HasMore { + next = strconv.Itoa(evidence.CurrentPage + 1) + } + // The strict page evidence guarantees either terminal+empty-next or + // continuing+positive next page, the two valid framework states. + pagination, _ := output.NewPagination(!evidence.HasMore, next) + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(&output.Meta{ + Count: output.NewCount(len(items)), Pagination: pagination, + }))) +} + +func hrbrainRequireCollectionResult(data map[string]any, operation string, identityKeys ...string) ([]map[string]any, error) { + result, err := responsecheck.RequireResult(data, operation) + if err != nil { + return nil, err + } + raw, ok := result.([]any) + if !ok { + return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", result)) + } + return hrbrainValidateItems(raw, operation, identityKeys...) +} + +func hrbrainProjectPage(data map[string]any, operation string, requestedPage, requestedSize int, identityKeys ...string) ([]map[string]any, hrbrainPage, error) { + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return nil, hrbrainPage{}, err + } + raw, ok := result["items"].([]any) + if !ok { + if _, present := result["items"]; !present { + return nil, hrbrainPage{}, responsecheck.Error(operation, "missing_collection", "成功响应缺少 result.items 数组") + } + return nil, hrbrainPage{}, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.items 应为数组,实际为 %T", result["items"])) + } + items, err := hrbrainValidateItems(raw, operation, identityKeys...) + if err != nil { + return nil, hrbrainPage{}, err + } + evidence, err := hrbrainParsePage(result, operation, requestedPage, requestedSize, len(items)) + if err != nil { + return nil, hrbrainPage{}, err + } + return items, evidence, nil +} + +func hrbrainValidateItems(raw []any, operation string, identityKeys ...string) ([]map[string]any, error) { + items := make([]map[string]any, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("结果第 %d 项不是非空对象", index)) + } + identity := hrbrainIdentity(object, identityKeys...) + if identity == "" { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定身份", index)) + } + if _, duplicate := seen[identity]; duplicate { + return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("结果第 %d 项稳定身份重复", index)) + } + seen[identity] = struct{}{} + items = append(items, object) + } + return items, nil +} + +func hrbrainParsePage(result map[string]any, operation string, requestedPage, requestedSize, itemCount int) (hrbrainPage, error) { + currentPage, ok := hrbrainInteger(result["currentPage"]) + if !ok || currentPage != requestedPage || currentPage < 1 { + return hrbrainPage{}, responsecheck.Error(operation, "pagination_page_mismatch", "currentPage 缺失、错型或与请求页码不一致") + } + pageSize, ok := hrbrainInteger(result["pageSize"]) + if !ok || pageSize != requestedSize || pageSize < 1 || itemCount > pageSize { + return hrbrainPage{}, responsecheck.Error(operation, "pagination_size_mismatch", "pageSize 缺失、错型、与请求不一致或小于结果条数") + } + totalCount, ok := hrbrainInteger(result["totalCount"]) + if !ok || totalCount < 0 || totalCount < itemCount { + return hrbrainPage{}, responsecheck.Error(operation, "invalid_total_count", "totalCount 缺失、错型、为负数或小于当前页条数") + } + hasMore, ok := result["hasMore"].(bool) + if !ok { + return hrbrainPage{}, responsecheck.Error(operation, "malformed_pagination", "hasMore 缺失或不是布尔值") + } + pageEnd := currentPage * pageSize + if hasMore && pageEnd >= totalCount || !hasMore && pageEnd < totalCount { + return hrbrainPage{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore 与 currentPage/pageSize/totalCount 相互矛盾") + } + return hrbrainPage{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil +} + +func hrbrainIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + value := object[key] + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return text + } + case float64: + if typed == math.Trunc(typed) && !math.IsNaN(typed) && !math.IsInf(typed, 0) { + return strconv.FormatInt(int64(typed), 10) + } + } + } + return "" +} + +func hrbrainInteger(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) || typed < float64(math.MinInt) { + return 0, false + } + return int(typed), true + default: + return 0, false + } +} + +func hrbrainAddOptional(params map[string]any, rt *shortcut.RuntimeContext, flag, property string) { + if value := strings.TrimSpace(rt.Str(flag)); value != "" { + params[property] = value + } +} + +func hrbrainCleanValues(values []string) []string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + cleaned = append(cleaned, value) + } + } + return cleaned +} + +func hrbrainJSONArray(raw, flag string) ([]any, error) { + var value []any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 数组: %v", flag, err)) + } + if len(value) == 0 { + return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 数组") + } + for index, item := range value { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 第 %d 项必须是非空对象", flag, index)) + } + } + return value, nil +} + +func hrbrainJSONObject(raw, flag string) (map[string]any, error) { + var value map[string]any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 对象: %v", flag, err)) + } + if len(value) == 0 { + return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 对象") + } + return value, nil +} + +func mustJSON(value any) []byte { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func init() { + shortcut.Register( + ListPools, GetPool, ListPoolEmployees, + ProfileMetadata, QueryProfile, ProfileLabels, ProfileCareer, ProfilePerformance, + SearchEmployees, SearchEmployeesStructured, SearchFields, + ) +} diff --git a/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go b/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go new file mode 100644 index 00000000..62049917 --- /dev/null +++ b/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go @@ -0,0 +1,385 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package hrbrain + +import ( + "context" + "errors" + "io" + "math" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type hrbrainCoverageCaller struct{ calls int } + +func (caller *hrbrainCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":[]}`}}}, nil +} +func (*hrbrainCoverageCaller) Format() string { return "json" } +func (*hrbrainCoverageCaller) DryRun() bool { return false } +func (*hrbrainCoverageCaller) Fields() string { return "" } +func (*hrbrainCoverageCaller) JQ() string { return "" } + +type hrbrainParityCaller struct { + calls int + server string + tool string + params map[string]any + response string + err error +} + +func (caller *hrbrainParityCaller) CallTool(_ context.Context, server, tool string, params map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.server = server + caller.tool = tool + caller.params = params + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil +} +func (*hrbrainParityCaller) Format() string { return "json" } +func (*hrbrainParityCaller) DryRun() bool { return false } +func (*hrbrainParityCaller) Fields() string { return "" } +func (*hrbrainParityCaller) JQ() string { return "" } + +func runHRbrainCoverage(t *testing.T, declaration shortcut.Shortcut, caller edition.ToolCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "hrbrain"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetArgs(append([]string{"hrbrain", declaration.Command}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageHRbrainDeclarationsStayUnavailableAndTyped(t *testing.T) { + declarations := []shortcut.Shortcut{ + ListPools, GetPool, ListPoolEmployees, ProfileMetadata, QueryProfile, ProfileLabels, + ProfileCareer, ProfilePerformance, SearchEmployees, SearchEmployeesStructured, SearchFields, + } + if len(declarations) != 11 { + t.Fatalf("declarations = %d", len(declarations)) + } + for _, declaration := range declarations { + if !declaration.Hidden || declaration.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s must remain hidden/unavailable", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive || declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks unified Contract/Result", declaration.Command) + } + if strings.TrimSpace(declaration.Safety.Effect) == "" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s lacks unavailable interface and Safety", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageHRbrainBlockersAreClassifiedWithoutShortcutDefects(t *testing.T) { + commands := []string{ + ListPools.Command, GetPool.Command, ListPoolEmployees.Command, ProfileMetadata.Command, + QueryProfile.Command, ProfileLabels.Command, ProfileCareer.Command, ProfilePerformance.Command, + SearchEmployees.Command, SearchEmployeesStructured.Command, SearchFields.Command, + } + counts := map[string]int{} + for _, command := range commands { + reason := hrbrainUnavailableReason(command) + if strings.Contains(reason, "shortcut_defect") || !strings.Contains(reason, "classified=") { + t.Fatalf("%s has unreviewed blocker reason %q", command, reason) + } + switch { + case strings.Contains(reason, "classified="+hrbrainBlockerAdapterBusiness): + counts[hrbrainBlockerAdapterBusiness]++ + case strings.Contains(reason, "classified="+hrbrainBlockerTenantFixture): + counts[hrbrainBlockerTenantFixture]++ + default: + t.Fatalf("%s has unknown blocker classification %q", command, reason) + } + } + if counts[hrbrainBlockerAdapterBusiness] != 9 || counts[hrbrainBlockerTenantFixture] != 2 { + t.Fatalf("blocker counts = %#v", counts) + } +} + +func TestCrossPlatformCoverageHRbrainExactParameterProjectionForAllElevenShortcuts(t *testing.T) { + pageResponse := `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}` + employeePageResponse := `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}` + queryItems := []any{map[string]any{"modelCode": "model", "fields": []any{"field"}}} + structuredFields := []any{map[string]any{"label": "field", "value": "field"}} + cases := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + params map[string]any + response string + }{ + {name: "list pools", declaration: ListPools, tool: "list_talent_pools", params: map[string]any{"currentPage": 1, "pageSize": 20}, response: pageResponse}, + {name: "get pool", declaration: GetPool, args: []string{"--pool-code", "pool"}, tool: "get_talent_pool_detail", params: map[string]any{"poolCode": "pool"}, response: `{"success":true,"result":{"poolCode":"pool"}}`}, + {name: "list pool employees", declaration: ListPoolEmployees, args: []string{"--pool-code", "pool"}, tool: "list_pool_employees", params: map[string]any{"poolCode": "pool", "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "profile metadata", declaration: ProfileMetadata, args: []string{"--work-no", "worker"}, tool: "get_profile_metadata", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"modelCode":"model"}]}`}, + {name: "query profile", declaration: QueryProfile, args: []string{"--work-no", "worker", "--data-queries", `[{"modelCode":"model","fields":["field"]}]`}, tool: "query_profile_data", params: map[string]any{"workNo": "worker", "dataQueries": queryItems}, response: `{"success":true,"result":[{"modelCode":"model"}]}`}, + {name: "profile labels", declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, tool: "get_profile_label", params: map[string]any{"staffIds": []string{"worker"}}, response: `{"success":true,"result":[{"workNo":"worker"}]}`}, + {name: "profile career", declaration: ProfileCareer, args: []string{"--work-no", "worker"}, tool: "get_employee_career", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"careerId":"career"}]}`}, + {name: "profile performance", declaration: ProfilePerformance, args: []string{"--work-no", "worker"}, tool: "get_employee_performance", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"performanceId":"performance"}]}`}, + {name: "search employees", declaration: SearchEmployees, args: []string{"--keyword", "worker"}, tool: "search_employees", params: map[string]any{"keyword": "worker", "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "search employees structured", declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`}, tool: "search_employees_structured", params: map[string]any{"originJson": `{"rules":[{"field":"field"}]}`, "fields": structuredFields, "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "search fields", declaration: SearchFields, tool: "get_search_fields", params: map[string]any{}, response: `{"success":true,"result":[{"fieldCode":"field"}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &hrbrainParityCaller{response: tc.response} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("exact Shortcut failed against valid synthetic response: %v", err) + } + if caller.calls != 1 || caller.server != "hrbrain" || caller.tool != tc.tool { + t.Fatalf("remote route = calls:%d server:%q tool:%q, want 1/hrbrain/%s", caller.calls, caller.server, caller.tool, tc.tool) + } + if !reflect.DeepEqual(caller.params, tc.params) { + t.Fatalf("params = %#v, want %#v", caller.params, tc.params) + } + }) + } +} + +func TestCrossPlatformCoverageHRbrainRejectsNullWrongCollectionsAndBadItems(t *testing.T) { + brokenCollections := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"name": "missing-id"}}}, + {"success": true, "result": []any{map[string]any{"poolCode": "same"}, map[string]any{"poolCode": "same"}}}, + } + for index, payload := range brokenCollections { + if got, err := hrbrainRequireCollectionResult(payload, "hrbrain/test", "poolCode"); err == nil { + t.Errorf("broken collection %d accepted: %#v", index, got) + } + } + explicitEmpty := map[string]any{"success": true, "result": []any{}} + if got, err := hrbrainRequireCollectionResult(explicitEmpty, "hrbrain/test", "poolCode"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty collection: got=%#v err=%v", got, err) + } +} + +func TestCrossPlatformCoverageHRbrainPaginationFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"poolCode": "pool-1"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true, + }} + items, page, err := hrbrainProjectPage(valid, "hrbrain/test", 1, 1, "poolCode") + if err != nil || len(items) != 1 || !page.HasMore { + t.Fatalf("valid page: items=%#v page=%+v err=%v", items, page, err) + } + broken := []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{}}}, + {"success": true, "result": map[string]any{"items": "bad", "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(21), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(20), "hasMore": true}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": "false"}}, + } + for index, payload := range broken { + if got, evidence, err := hrbrainProjectPage(payload, "hrbrain/test", 1, 20, "poolCode"); err == nil { + t.Errorf("broken page %d accepted: items=%#v evidence=%+v", index, got, evidence) + } + } +} + +func TestCrossPlatformCoverageHRbrainStructuredInputsRejectBadShapes(t *testing.T) { + for _, raw := range []string{"", "{}", "[]", "[1]", "[{}]"} { + if _, err := hrbrainJSONArray(raw, "--items"); err == nil { + t.Errorf("bad array accepted: %q", raw) + } + } + for _, raw := range []string{"", "[]", "{}"} { + if _, err := hrbrainJSONObject(raw, "--object"); err == nil { + t.Errorf("bad object accepted: %q", raw) + } + } +} + +func TestCrossPlatformCoverageHRbrainInvalidInputMakesZeroRemoteCalls(t *testing.T) { + for name, tc := range map[string]struct { + declaration shortcut.Shortcut + args []string + }{ + "unfiltered employee search": {declaration: SearchEmployees}, + "invalid page": {declaration: ListPools, args: []string{"--page", "0"}}, + "invalid page size": {declaration: ListPoolEmployees, args: []string{"--pool-code", "fixture", "--page-size", "101"}}, + "bad query json": {declaration: QueryProfile, args: []string{"--work-no", "fixture", "--data-queries", `{}`}}, + "bad structured json": {declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{}`, "--fields", `[]`}}, + } { + t.Run(name, func(t *testing.T) { + caller := &hrbrainCoverageCaller{} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before validation = %d, want 0", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageHRbrainExecutionFailuresAndContinuation(t *testing.T) { + for name, tc := range map[string]struct { + declaration shortcut.Shortcut + args []string + response string + err error + }{ + "object transport": {declaration: GetPool, args: []string{"--pool-code", "pool"}, err: errors.New("transport")}, + "object malformed": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":null}`}, + "object missing id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"name":"pool"}}`}, + "object wrong id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"poolCode":"other"}}`}, + "collection transport": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, err: errors.New("transport")}, + "collection malformed": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, response: `{"success":true,"result":null}`}, + "page transport": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, err: errors.New("transport")}, + "page malformed": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, response: `{"success":true,"result":null}`}, + } { + t.Run(name, func(t *testing.T) { + caller := &hrbrainParityCaller{response: tc.response, err: tc.err} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid remote result succeeded") + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + } + + continuing := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`} + if err := runHRbrainCoverage(t, SearchEmployees, continuing, "--keyword", "worker", "--page-size", "1"); err != nil { + t.Fatal(err) + } + if continuing.calls != 1 { + t.Fatalf("continuing calls = %d", continuing.calls) + } + + optionalPool := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`} + if err := runHRbrainCoverage(t, ListPools, optionalPool, + "--keyword", "pool", "--pool-type", "type", "--creator", "creator", "--labels", "one,two"); err != nil { + t.Fatal(err) + } + if labels, ok := optionalPool.params["labels"].([]string); !ok || len(labels) != 2 { + t.Fatalf("labels = %#v", optionalPool.params["labels"]) + } + + labelsCaller := &hrbrainParityCaller{response: `{"success":true,"result":[{"workNo":"worker"}]}`} + if err := runHRbrainCoverage(t, ProfileLabels, labelsCaller, "--staff-ids", "worker", "--all-label"); err != nil { + t.Fatal(err) + } + if labelsCaller.params["allLabel"] != true { + t.Fatalf("allLabel = %#v", labelsCaller.params["allLabel"]) + } + + structured := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`} + if err := runHRbrainCoverage(t, SearchEmployeesStructured, structured, + "--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`, "--order-by", "field"); err != nil { + t.Fatal(err) + } + if order, ok := structured.params["orderByClauses"].([]string); !ok || len(order) != 1 { + t.Fatalf("orderByClauses = %#v", structured.params["orderByClauses"]) + } +} + +func TestCrossPlatformCoverageHRbrainRemainingValidationAndScalarEdges(t *testing.T) { + t.Run("unknown blocker", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown blocker classification did not panic") + } + }() + _ = hrbrainUnavailableReason("+unknown") + }) + + profileLabelsCommand := corecmd.New(shortcut.FromShortcut(ProfileLabels)) + if err := profileLabelsCommand.Flags().Set("staff-ids", ","); err != nil { + t.Fatal(err) + } + if err := ProfileLabels.Validate(shortcut.RuntimeContextForTest(profileLabelsCommand, ProfileLabels)); err == nil { + t.Fatal("blank staff IDs succeeded") + } + badPage := &hrbrainCoverageCaller{} + if err := runHRbrainCoverage(t, SearchEmployeesStructured, badPage, + "--origin-json", `{"rule":"value"}`, "--fields", `[{"field":"value"}]`, "--page", "0"); err == nil { + t.Fatal("structured search invalid page succeeded") + } + if badPage.calls != 0 { + t.Fatalf("structured invalid page remote calls = %d", badPage.calls) + } + + if got := hrbrainIdentity(map[string]any{"id": float64(7)}, "id"); got != "7" { + t.Fatalf("numeric identity = %q", got) + } + for _, value := range []any{math.NaN(), math.Inf(1), 1.5} { + if got := hrbrainIdentity(map[string]any{"id": value}, "id"); got != "" { + t.Fatalf("invalid identity = %q", got) + } + } + if got, ok := hrbrainInteger(7); !ok || got != 7 { + t.Fatalf("integer = %d, %v", got, ok) + } + for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "7"} { + if _, ok := hrbrainInteger(value); ok { + t.Fatalf("invalid integer accepted: %#v", value) + } + } + + t.Run("marshal panic", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("mustJSON did not panic for unsupported value") + } + }() + _ = mustJSON(make(chan int)) + }) +} + +func TestCrossPlatformCoverageHRbrainPageSizeAndTotalContradictions(t *testing.T) { + missing := map[string]any{"success": true, "result": map[string]any{}} + if _, _, err := hrbrainProjectPage(missing, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("missing page collection succeeded") + } + badItem := map[string]any{"success": true, "result": map[string]any{ + "items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(1), "hasMore": false, + }} + if _, _, err := hrbrainProjectPage(badItem, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("bad page item succeeded") + } + tooMany := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"workNo": "one"}, map[string]any{"workNo": "two"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true, + }} + if _, _, err := hrbrainProjectPage(tooMany, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("page larger than pageSize succeeded") + } + badTotal := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"workNo": "one"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(0), "hasMore": false, + }} + if _, _, err := hrbrainProjectPage(badTotal, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("total below item count succeeded") + } +} diff --git a/internal/shortcut/pat/pat.go b/internal/shortcut/pat/pat.go new file mode 100644 index 00000000..16d1ee42 --- /dev/null +++ b/internal/shortcut/pat/pat.go @@ -0,0 +1,154 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package pat registers safe Shortcut routing for PAT authorization tasks. +package pat + +import ( + "encoding/json" + "regexp" + "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" + patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var safeAgentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +var ( + patBrowserPolicyConfigDir = patcore.BrowserPolicyConfigDir + patSetBrowserPolicy = patcore.SetBrowserPolicy + patReadBrowserPolicy = patcore.ReadStoredBrowserPolicy +) + +func browserPolicyResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"本地 PAT 浏览器策略预览或已验证写入结果","properties":{"scope":{"type":"string","description":"写入范围:default 或 agent","enum":["default","agent"]},"openBrowser":{"type":"boolean","description":"策略是否允许打开浏览器"},"executed":{"type":"boolean","description":"本次是否实际写入本地策略"},"verified":{"type":"boolean","description":"是否对同一目标完成磁盘读回验证"}},"required":["scope","openBrowser","executed","verified"],"additionalProperties":false}`), + } +} + +func authorizeResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"由 routed 原子命令 pat chmod 持有的 PAT 授权计划或逐 scope 终态账本","properties":{"selected":{"type":"integer","description":"计划选择的 scope 数量"},"skipped":{"type":"integer","description":"计划跳过的 scope 数量"},"pending":{"type":"integer","description":"仍待授权的 scope 数量"},"verified":{"type":"boolean","description":"授权身份与逐 scope 终态是否已验证"}},"required":["selected","skipped","pending","verified"],"additionalProperties":false}`), + } +} + +var BrowserPolicy = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "pat", + Command: "+browser-policy", + Product: "pat", + Description: "安全配置 PAT 授权时是否允许打开本地浏览器", + Intent: "需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "pat", Name: "shortcut_browser_policy", CanonicalPath: "pat.shortcut_browser_policy", + CLIPath: "pat +browser-policy", PrimaryCLIPath: "pat +browser-policy", + }, + Description: "安全配置 PAT 授权时是否允许打开本地浏览器", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "The Shortcut writes only the local PAT policy file, requires confirmation, supports a no-write request preview, omits agent identity from output, and verifies the exact persisted target.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "安全配置 PAT 授权时是否允许打开本地浏览器", + UseWhen: []string{"需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。"}, + AvoidWhen: []string{"需要预览或授予行为 scope 时使用原子命令 pat chmod;普通 OAuth 登录使用 auth"}, + Examples: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "enabled"}, {Name: "agent-code"}, + }, + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest}, + Result: browserPolicyResult(), + }, + Flags: []shortcut.Flag{ + {Name: "enabled", Type: shortcut.FlagBool, Required: true, Desc: "是否允许打开本地浏览器;必须显式传 true 或 false"}, + {Name: "agent-code", Type: shortcut.FlagString, Desc: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符;只用于定位本地策略项且不会出现在结果中"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"agent-code"}, Description: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符"}, + }, + Tips: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`}, + Validate: func(rt *shortcut.RuntimeContext) error { + value := strings.TrimSpace(rt.Str("agent-code")) + if value != "" && !safeAgentCodePattern.MatchString(value) { + return apperrors.NewValidation("--agent-code 格式无效;只允许 1 到 64 位字母、数字、下划线或连字符") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + scope := "default" + agentCode := strings.TrimSpace(rt.Str("agent-code")) + if agentCode != "" { + scope = "agent" + } + enabled := rt.Bool("enabled") + if rt.DryRun() { + return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": false, "verified": false}) + } + configDir := patBrowserPolicyConfigDir() + written, err := patSetBrowserPolicy(configDir, agentCode, enabled) + if err != nil { + return err + } + stored, err := patReadBrowserPolicy(configDir, agentCode) + if err != nil { + return err + } + if written.Scope != scope || stored.Scope != scope || written.OpenBrowser != enabled || stored.OpenBrowser != enabled || written.AgentCode != stored.AgentCode { + return apperrors.NewInternal("PAT 浏览器策略写后读回不一致;结果未验证") + } + return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": true, "verified": true}) + }, +} + +var Authorize = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "pat", + Command: "+authorize", + Product: "pat", + Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + Intent: "命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。", + Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "pat", Name: "shortcut_authorize", CanonicalPath: "pat.shortcut_authorize", + CLIPath: "pat +authorize", PrimaryCLIPath: "pat +authorize", + }, + Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, + Reason: "classified=routed; pat chmod already owns the reviewed plan, fallback, pending, per-scope ledger, identity, and confirmation chain. The Shortcut runtime cannot reuse that Cobra/ToolCaller closure without duplicating safety semantics, and a live write remains blocked_fixture, so +authorize stays unavailable while routing to pat chmod.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + UseWhen: []string{"命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。"}, + AvoidWhen: []string{"不要向此 unavailable Shortcut 传入任何真实身份、会话、scope 或授权材料;本地浏览器策略使用 pat +browser-policy"}, + Examples: []string{`dws pat +authorize --format json`}, + }, + Result: authorizeResult(), + }, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Execute: func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("PAT 授权任务 classified=routed;+authorize 当前 unavailable,请使用审核后的原子命令 pat chmod,并先执行 --dry-run") + }, +} + +func init() { + shortcut.Register(BrowserPolicy, Authorize) +} diff --git a/internal/shortcut/pat/pat_cross_platform_coverage_test.go b/internal/shortcut/pat/pat_cross_platform_coverage_test.go new file mode 100644 index 00000000..e7c1d620 --- /dev/null +++ b/internal/shortcut/pat/pat_cross_platform_coverage_test.go @@ -0,0 +1,353 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "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" + patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type patShortcutCaller struct{ calls int } + +func (caller *patShortcutCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return nil, nil +} +func (*patShortcutCaller) Format() string { return "json" } +func (*patShortcutCaller) DryRun() bool { return false } +func (*patShortcutCaller) Fields() string { return "" } +func (*patShortcutCaller) JQ() string { return "" } + +type patAtomicCall struct { + tool string + args map[string]any +} + +type patAtomicFixtureCaller struct { + dryRun bool + responses []string + calls []patAtomicCall +} + +func (caller *patAtomicFixtureCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + copied := make(map[string]any, len(args)) + for key, value := range args { + copied[key] = value + } + caller.calls = append(caller.calls, patAtomicCall{tool: tool, args: copied}) + response := `{"success":true,"code":"OK","data":{}}` + if index := len(caller.calls) - 1; index < len(caller.responses) { + response = caller.responses[index] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil +} +func (*patAtomicFixtureCaller) Format() string { return "json" } +func (caller *patAtomicFixtureCaller) DryRun() bool { return caller.dryRun } +func (*patAtomicFixtureCaller) Fields() string { return "" } +func (*patAtomicFixtureCaller) JQ() string { return "" } + +func runPATShortcut(t *testing.T, declaration shortcut.Shortcut, caller *patShortcutCaller, input string, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + service := &cobra.Command{Use: "pat"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader(input)) + root.SetArgs(append([]string{"pat", declaration.Command}, args...)) + executed, err := root.ExecuteC() + if err == nil { + _, _, err = output.EmitStoredResult(executed) + } + return stdout.String(), err +} + +func runPATAtomicRoute(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Bool("verbose", false, "") + patcore.RegisterCommands(root, caller) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"pat", "chmod"}, args...)) + + oldStdout := os.Stdout + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writePipe + _, runErr := root.ExecuteC() + _ = writePipe.Close() + os.Stdout = oldStdout + outputBytes, readErr := io.ReadAll(readPipe) + _ = readPipe.Close() + if readErr != nil { + t.Fatal(readErr) + } + return string(outputBytes), runErr +} + +func runPATNativeBrowserPolicy(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Bool("verbose", false, "") + patcore.RegisterCommands(root, caller) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"pat", "browser-policy"}, args...)) + _, err := root.ExecuteC() + return stdout.String(), err +} + +func TestCrossPlatformCoveragePATAuthorizeRouteUsesAtomicPlanFallbackPendingAndLedger(t *testing.T) { + plan := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","disposition":"selected"}],"selectedScopes":["synthetic.item:read"],"skippedScopes":[],"pendingScopes":[]}}` + pending := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"pending"}],"selectedScopes":["synthetic.item:read"],"grantedScopes":[],"skippedScopes":[],"pendingScopes":["synthetic.item:read"]}}` + + preconfirm := &patAtomicFixtureCaller{responses: []string{plan, pending}} + if _, err := runPATAtomicRoute(t, preconfirm, "--product", "synthetic", "--grant-type", "once", "--format", "json"); err == nil { + t.Fatal("unconfirmed atomic authorization unexpectedly succeeded") + } + if len(preconfirm.calls) != 0 { + t.Fatalf("atomic calls before confirmation = %d, want 0", len(preconfirm.calls)) + } + + dryRun := &patAtomicFixtureCaller{dryRun: true, responses: []string{plan}} + dryOutput, err := runPATAtomicRoute(t, dryRun, "--product", "synthetic", "--grant-type", "once", "--dry-run", "--format", "json") + if err != nil { + t.Fatalf("atomic plan dry-run: %v", err) + } + if len(dryRun.calls) != 1 || dryRun.calls[0].tool != "pat.batch_plan" || dryRun.calls[0].args["dryRun"] != true { + t.Fatalf("dry-run route = %#v", dryRun.calls) + } + if !strings.Contains(dryOutput, `"items"`) || !strings.Contains(dryOutput, `"selectedScopes"`) || !strings.Contains(dryOutput, `"pendingScopes"`) { + t.Fatal("dry-run did not preserve the synthetic per-scope plan ledger") + } + + confirmed := &patAtomicFixtureCaller{responses: []string{plan, pending}} + confirmedOutput, err := runPATAtomicRoute(t, confirmed, "--product", "synthetic", "--grant-type", "once", "--yes", "--format", "json") + if err != nil { + t.Fatalf("confirmed atomic route: %v", err) + } + if len(confirmed.calls) != 2 || confirmed.calls[0].tool != "pat.batch_plan" || confirmed.calls[1].tool != "pat.batch_grant" { + t.Fatalf("confirmed route = %#v", confirmed.calls) + } + if scopes, ok := confirmed.calls[1].args["scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "synthetic.item:read" { + t.Fatalf("grant scopes = %#v", confirmed.calls[1].args["scopes"]) + } + for _, key := range []string{"agentCode", "sessionId", "orgId", "uid"} { + if _, present := confirmed.calls[1].args[key]; present { + t.Fatalf("synthetic fixture unexpectedly carried identity key %s", key) + } + } + if !strings.Contains(confirmedOutput, `"items"`) || !strings.Contains(confirmedOutput, `"pendingScopes"`) || !strings.Contains(confirmedOutput, `"status":"pending"`) { + t.Fatal("confirmed route did not preserve the synthetic pending per-scope ledger") + } + + fallback := &patAtomicFixtureCaller{responses: []string{ + `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{}}`, + `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"granted"}],"grantedScopes":["synthetic.item:read"],"pendingScopes":[]}}`, + }} + if _, err := runPATAtomicRoute(t, fallback, "synthetic.item:read", "--grant-type", "once", "--yes", "--format", "json"); err != nil { + t.Fatalf("atomic fallback route: %v", err) + } + if len(fallback.calls) != 2 || fallback.calls[0].tool != "pat.batch_grant" || fallback.calls[1].tool != "pat.grant" { + t.Fatalf("fallback route = %#v", fallback.calls) + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup(t *testing.T) { + proofRoot := t.TempDir() + exactDir := filepath.Join(proofRoot, "exact") + if err := os.Mkdir(exactDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", exactDir) + policyPath := filepath.Join(exactDir, "pat_policy.json") + caller := &patShortcutCaller{} + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true"); err == nil { + t.Fatal("unconfirmed write unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before confirmation = %d", caller.calls) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("policy file exists before confirmation: %v", err) + } + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--dry-run"); err != nil { + t.Fatalf("dry-run: %v", err) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("dry-run created policy file: %v", err) + } + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "yes\n", "--enabled=true"); err != nil { + t.Fatalf("confirmed write: %v", err) + } + if caller.calls != 0 { + t.Fatalf("local policy Shortcut made %d remote calls", caller.calls) + } + stored, err := os.ReadFile(policyPath) + if err != nil { + t.Fatalf("read policy: %v", err) + } + if bytes.Contains(stored, []byte("agentCode")) || !bytes.Contains(stored, []byte(`"openBrowser": true`)) { + t.Fatal("default policy did not persist the expected redacted setting") + } + if err := os.Remove(policyPath); err != nil { + t.Fatalf("cleanup policy: %v", err) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("policy cleanup left residue: %v", err) + } + + rawDir := filepath.Join(proofRoot, "raw") + if err := os.Mkdir(rawDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", rawDir) + rawPolicyPath := filepath.Join(rawDir, "pat_policy.json") + rawCaller := &patAtomicFixtureCaller{} + rawOutput, err := runPATNativeBrowserPolicy(t, rawCaller, "--enabled=false", "--format", "json") + if err != nil { + t.Fatalf("raw browser policy write: %v", err) + } + if len(rawCaller.calls) != 0 { + t.Fatalf("raw local policy command made %d remote calls", len(rawCaller.calls)) + } + var rawReceipt patcore.BrowserPolicySelection + if err := json.Unmarshal([]byte(rawOutput), &rawReceipt); err != nil { + t.Fatalf("parse raw browser policy receipt: %v", err) + } + if rawReceipt.Scope != "default" || rawReceipt.Source != "default" || rawReceipt.OpenBrowser || rawReceipt.AgentCode != "" { + t.Fatal("raw browser policy did not return a terminal receipt for the requested target") + } + rawStored, err := os.ReadFile(rawPolicyPath) + if err != nil { + t.Fatalf("read raw policy: %v", err) + } + if bytes.Contains(rawStored, []byte("agentCode")) || !bytes.Contains(rawStored, []byte(`"openBrowser": false`)) { + t.Fatal("raw default policy did not persist the expected redacted setting") + } + if err := os.Remove(rawPolicyPath); err != nil { + t.Fatalf("cleanup raw policy: %v", err) + } + if _, err := os.Stat(rawPolicyPath); !os.IsNotExist(err) { + t.Fatalf("raw policy cleanup left residue: %v", err) + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyOutputOmitsAgentIdentity(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + caller := &patShortcutCaller{} + outputText, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=false", "--agent-code", "DWS_TEST_AGENT", "--yes") + if err != nil { + t.Fatal(err) + } + if strings.Contains(outputText, "DWS_TEST_AGENT") || strings.Contains(outputText, "agentCode") || strings.Contains(outputText, "agent-code") { + t.Fatal("public PAT Shortcut output exposed agent identity") + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyWriteReadbackFailures(t *testing.T) { + failure := errors.New("fixture failure") + t.Run("write failure", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{}, failure + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) { + t.Fatalf("write error = %v", err) + } + }) + + t.Run("read failure", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil + }) + testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{}, failure + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) { + t.Fatalf("read error = %v", err) + } + }) + + t.Run("mismatched readback", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil + }) + testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: false}, nil + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); err == nil { + t.Fatal("mismatched readback succeeded") + } + }) +} + +func TestCrossPlatformCoveragePATInvalidAndUnavailablePathsMakeZeroRemoteCalls(t *testing.T) { + caller := &patShortcutCaller{} + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--agent-code", "invalid value", "--yes"); err == nil { + t.Fatal("invalid agent code unexpectedly succeeded") + } + if _, err := runPATShortcut(t, Authorize, caller, "", "--yes"); err == nil { + t.Fatal("unavailable authorization Shortcut unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("invalid/unavailable PAT paths made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoveragePATShortcutContracts(t *testing.T) { + if BrowserPolicy.Hidden || BrowserPolicy.Availability == shortcut.AvailabilityUnavailable { + t.Fatal("browser policy must be public/available before semantic decoration") + } + if BrowserPolicy.OutputRollout != output.RolloutUnifiedActive || BrowserPolicy.Contract.Result == nil || BrowserPolicy.Contract.DryRun == nil || BrowserPolicy.Contract.DryRun.PreviewKind != "request" || strings.TrimSpace(BrowserPolicy.Safety.Effect) == "" { + t.Fatal("public browser policy lacks Result/Safety/DryRun/unified output") + } + if !Authorize.Hidden || Authorize.Availability != shortcut.AvailabilityUnavailable || Authorize.Contract.Interface.Availability != "unavailable" { + t.Fatal("PAT authorization Shortcut must stay hidden/unavailable") + } + if !strings.Contains(Authorize.Contract.Interface.Reason, "classified=routed") || !strings.Contains(Authorize.Contract.Interface.Reason, "blocked_fixture") { + t.Fatal("PAT authorization must separate the routed implementation from unproved live write evidence") + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 19762dae..081b94a9 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -34,6 +34,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+dashboard-share-get": {}, "aitable\u0000+dashboard-share-update": {}, "aitable\u0000+dashboard-update": {}, + "aitable\u0000+datasource-create": {}, + "aitable\u0000+datasource-get-config": {}, + "aitable\u0000+datasource-get-fields": {}, + "aitable\u0000+datasource-list-sources": {}, + "aitable\u0000+datasource-sync": {}, + "aitable\u0000+datasource-sync-status": {}, + "aitable\u0000+datasource-update": {}, "aitable\u0000+export-data": {}, "aitable\u0000+field-delete": {}, "aitable\u0000+field-get": {}, @@ -375,6 +382,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, "oa\u0000+search-forms": {}, + "pat\u0000+browser-policy": {}, "report\u0000+inbox-list": {}, "report\u0000+outbox-list": {}, "report\u0000+report-latest": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index c2c7607a..b7584c43 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -64,6 +64,15 @@ var sheetSemanticCatalogJSON []byte //go:embed semantic_catalog_whiteboard.json var whiteboardSemanticCatalogJSON []byte +//go:embed semantic_catalog_devdoc.json +var devdocSemanticCatalogJSON []byte + +//go:embed semantic_catalog_hrbrain.json +var hrbrainSemanticCatalogJSON []byte + +//go:embed semantic_catalog_pat.json +var patSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -101,6 +110,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( reportSemanticCatalogJSON, sheetSemanticCatalogJSON, whiteboardSemanticCatalogJSON, + devdocSemanticCatalogJSON, + hrbrainSemanticCatalogJSON, + patSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_devdoc.json b/internal/shortcut/semantic_catalog_devdoc.json new file mode 100644 index 00000000..35765f80 --- /dev/null +++ b/internal/shortcut/semantic_catalog_devdoc.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "service": "devdoc", + "default_availability": "unavailable", + "shortcuts": { + "+search-docs": { + "disposition": "schema_leaf", + "semantic_delta": "严格验证 success、result.items、文档 URL 身份和分页一致性;但 exact 与 raw 对运行时随机 zero-match 候选都返回语义近邻,无法满足 public list/search 的保证零命中证明,因此保持 unavailable。", + "risk": "read", + "availability": "unavailable", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_hrbrain.json b/internal/shortcut/semantic_catalog_hrbrain.json new file mode 100644 index 00000000..64fc52a2 --- /dev/null +++ b/internal/shortcut/semantic_catalog_hrbrain.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "service": "hrbrain", + "default_availability": "unavailable", + "shortcuts": { + "+list-pools": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,无法证明列表成功或合法空。","risk":"read","public":false,"reviewed":true}, + "+get-pool": {"disposition":"schema_leaf","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空 pool fixture,不能验证同 ID 详情。","risk":"read","public":false,"reviewed":true}, + "+list-pool-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空人才池与员工 fixture,分页合同未获 live 证明。","risk":"read","public":false,"reviewed":true}, + "+profile-metadata": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案元数据能力仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+query-profile": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案查询业务链仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+profile-labels": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,不能把未知结果投成空标签。","risk":"read","public":false,"reviewed":true}, + "+profile-career": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,职业历程读取仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+profile-performance": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,且敏感绩效数据缺少受控 live 证明。","risk":"read","public":false,"reviewed":true}, + "+search-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,无法完成已知非空与保证零命中证明。","risk":"read","public":false,"reviewed":true}, + "+search-employees-structured": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,字段发现与结构化搜索链仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+search-fields": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;无资源输入的 exact/raw 仍返回同一远端错误,字段发现能力未解锁。","risk":"read","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_pat.json b/internal/shortcut/semantic_catalog_pat.json new file mode 100644 index 00000000..8cab68d5 --- /dev/null +++ b/internal/shortcut/semantic_catalog_pat.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "pat", + "default_availability": "unavailable", + "shortcuts": { + "+browser-policy": { + "disposition": "primary_smart", + "semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。", + "risk": "write", + "availability": "available", + "public": true, + "reviewed": true + }, + "+authorize": { + "disposition": "alias_internal", + "semantic_delta": "classified=routed;pat chmod 已持有 plan、fallback、pending、逐 scope ledger、身份校验与确认链,+authorize 不复制执行语义;真实授权写仍因 blocked_fixture 未获证明。", + "risk": "high-risk-write", + "primary": "pat chmod", + "public": false, + "reviewed": true + } + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index d7f41236..429d32bb 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -37,6 +37,9 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json", ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json", ] diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index 64b5cbf5..f5fe0fb0 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -41,6 +41,7 @@ SERVICE_TO_SKILL = { "mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md", "minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md", "oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md", + "pat": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "pat.md", "report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md", "sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md", "todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md", diff --git a/scripts/policy/check-shortcut-public-e2e-proof.sh b/scripts/policy/check-shortcut-public-e2e-proof.sh new file mode 100755 index 00000000..807030b1 --- /dev/null +++ b/scripts/policy/check-shortcut-public-e2e-proof.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +MANIFEST="$ROOT/scripts/policy/shortcut-public-e2e-proof.json" +BINARY="${DWS_SHORTCUT_PROOF_BINARY:-$ROOT/dws}" +GO_BINARY="${GO:-go}" +PROOF_ROOT="" + +fail() { + printf 'shortcut public E2E proof failed: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$PROOF_ROOT" ] && [ -d "$PROOF_ROOT" ]; then + find "$PROOF_ROOT" -type f -delete 2>/dev/null || true + find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; 2>/dev/null || true + fi +} + +trap cleanup EXIT HUP INT TERM + +command -v jq >/dev/null 2>&1 || fail "jq is required" +command -v "$GO_BINARY" >/dev/null 2>&1 || fail "Go is required" +[ -x "$BINARY" ] || fail "current source binary is missing; run make build" +[ -f "$MANIFEST" ] || fail "reviewed proof manifest is missing" + +if [ "${DWS_SHORTCUT_PROOF_ALLOW_DIRTY:-0}" != "1" ] && + [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]; then + fail "worktree must be clean so evidence is bound to current HEAD" +fi + +HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)" +[ -n "$HEAD_SHA" ] || fail "cannot resolve current HEAD" + +jq -e ' + .version == 1 and + (.products | type == "array" and length > 0) and + (.public_shortcuts | type == "array") and + (.products | group_by(.service) | all(.[]; length == 1)) and + (.public_shortcuts | group_by([.service, .command]) | all(.[]; length == 1)) and + (.products | all(.[]; + (.service | type == "string" and length > 0) and + (.source_total | type == "number") and + (.public | type == "number") and + (.unavailable | type == "number") and + (.source_total == (.public + .unavailable)))) and + (.public_shortcuts | all(.[]; + (.service | type == "string" and length > 0) and + (.command | type == "string" and startswith("+")) and + (.owning_atomic | type == "string" and length > 0) and + (.proof_class | IN("list", "search", "read", "write")) and + (.proof_case | type == "string" and length > 0) and + (.stable_target | type == "string" and length > 0) and + (.pass_labels | type == "array" and length > 0 and all(.[]; type == "string" and endswith("-PASS"))))) +' "$MANIFEST" >/dev/null || fail "reviewed proof manifest is malformed" + +SOURCE_TOTAL=0 +PUBLIC_TOTAL=0 +UNAVAILABLE_TOTAL=0 +SEMANTIC_PUBLIC="" +RUNTIME_PUBLIC="" + +for service in $(jq -r '.products[].service' "$MANIFEST"); do + catalog="$ROOT/internal/shortcut/semantic_catalog_${service}.json" + [ -f "$catalog" ] || fail "semantic catalog is missing for $service" + + jq -e --arg service "$service" ' + . as $catalog | + .service == $service and + .default_availability == "unavailable" and + (.shortcuts | type == "object") and + (.shortcuts | to_entries | all(.[]; + (.key | type == "string" and startswith("+")) and + (.value | type == "object") and + (.value.reviewed == true) and + ((.value.semantic_delta | type) == "string" and (.value.semantic_delta | length) > 0) and + (if (.value.public // false) + then ((.value.availability // $catalog.default_availability) == "available") + else ((.value.availability // $catalog.default_availability) == "unavailable") + end))) + ' "$catalog" >/dev/null || fail "$service semantic catalog is not truthfully reviewed" + + source_count="$(jq '.shortcuts | length' "$catalog")" + public_count="$(jq '[. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available"))] | length' "$catalog")" + unavailable_count=$((source_count - public_count)) + expected_source="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .source_total' "$MANIFEST")" + expected_public="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .public' "$MANIFEST")" + expected_unavailable="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .unavailable' "$MANIFEST")" + [ "$source_count" -eq "$expected_source" ] || fail "$service source count drifted" + [ "$public_count" -eq "$expected_public" ] || fail "$service public count drifted" + [ "$unavailable_count" -eq "$expected_unavailable" ] || fail "$service unavailable count drifted" + + semantic_lines="$(jq -r --arg service "$service" '. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available")) | [$service, .key] | @tsv' "$catalog")" + if [ -n "$semantic_lines" ]; then + SEMANTIC_PUBLIC="${SEMANTIC_PUBLIC}${semantic_lines}"$'\n' + fi + + runtime_output="$($BINARY shortcut list --service "$service" --format json)" + printf '%s' "$runtime_output" | jq -e --arg service "$service" ' + type == "object" and + (.count | type == "number") and + (.shortcuts | type == "array") and + (.count == (.shortcuts | length)) and + (.shortcuts | all(.[]; + type == "object" and + .service == $service and + (.command | type == "string" and startswith("+")) and + .public == true and + .reviewed == true and + .availability == "available")) + ' >/dev/null || fail "$service runtime public inventory is malformed" + runtime_count="$(printf '%s' "$runtime_output" | jq '.shortcuts | length')" + [ "$runtime_count" -eq "$public_count" ] || fail "$service runtime public count does not match its semantic catalog" + runtime_lines="$(printf '%s' "$runtime_output" | jq -r '.shortcuts[] | [.service, .command] | @tsv')" + if [ -n "$runtime_lines" ]; then + RUNTIME_PUBLIC="${RUNTIME_PUBLIC}${runtime_lines}"$'\n' + fi + + SOURCE_TOTAL=$((SOURCE_TOTAL + source_count)) + PUBLIC_TOTAL=$((PUBLIC_TOTAL + public_count)) + UNAVAILABLE_TOTAL=$((UNAVAILABLE_TOTAL + unavailable_count)) +done + +SEMANTIC_PUBLIC="$(printf '%s' "$SEMANTIC_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)" +RUNTIME_PUBLIC="$(printf '%s' "$RUNTIME_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)" +EXPECTED_PUBLIC="$(jq -r '.public_shortcuts[] | [.service, .command] | @tsv' "$MANIFEST" | LC_ALL=C sort)" +[ "$SEMANTIC_PUBLIC" = "$RUNTIME_PUBLIC" ] || fail "semantic and runtime public surfaces differ" +[ "$RUNTIME_PUBLIC" = "$EXPECTED_PUBLIC" ] || fail "a public shortcut lacks a reviewed exact plus raw proof case" + +printf 'CURRENT-HEAD-PUBLIC-SURFACE-PASS head=%s source=%d public=%d unavailable=%d\n' "$HEAD_SHA" "$SOURCE_TOTAL" "$PUBLIC_TOTAL" "$UNAVAILABLE_TOTAL" + +(cd "$ROOT" && DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" "$GO_BINARY" test -count=1 ./internal/shortcut/pat -run '^TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup$' >/dev/null) || + fail "PAT exact/raw zero-call structural proof failed" + +run_pat_browser_policy_default() { + schema_output="$($BINARY schema --cli-path 'pat +browser-policy' --compact --format json)" + printf '%s' "$schema_output" | jq -e ' + type == "object" and + .cli_path == "pat +browser-policy" and + .effect == "write" and + .risk == "medium" and + .confirmation == "user_required" and + .idempotency == "idempotent" and + .availability == "available" and + .interface_mode == "composite" and + (.result | type == "object") and + (.result.outcomes | type == "array" and index("success") != null and index("failure") != null) and + (.result.data_schema.required | sort == ["executed", "openBrowser", "scope", "verified"]) + ' >/dev/null || fail "pat +browser-policy compact contract is incomplete" + + PROOF_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-pat-public-proof.XXXXXX")" + exact_dir="$PROOF_ROOT/exact" + raw_dir="$PROOF_ROOT/raw" + mkdir -p "$exact_dir" "$raw_dir" + exact_path="$exact_dir/pat_policy.json" + raw_path="$raw_dir/pat_policy.json" + + if env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --format json /dev/null 2>&1; then + fail "pat +browser-policy wrote without confirmation" + fi + [ ! -e "$exact_path" ] || fail "pat +browser-policy left state before confirmation" + printf '%s\n' 'PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS' + + exact_dry_run="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --dry-run --format json)" + printf '%s' "$exact_dry_run" | jq -e ' + type == "object" and .ok == true and .outcome == "success" and + (.data | type == "object") and .data.scope == "default" and + .data.openBrowser == false and .data.executed == false and .data.verified == false + ' >/dev/null || fail "pat +browser-policy dry-run result is malformed" + [ ! -e "$exact_path" ] || fail "pat +browser-policy dry-run wrote policy state" + printf '%s\n' 'PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS' + + exact_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --yes --format json)" + printf '%s' "$exact_output" | jq -e ' + type == "object" and .ok == true and .outcome == "success" and + (.data | type == "object") and .data.scope == "default" and + .data.openBrowser == false and .data.executed == true and .data.verified == true + ' >/dev/null || fail "pat +browser-policy terminal receipt is malformed" + printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS' + + jq -e ' + type == "object" and (.default | type == "object") and + .default.openBrowser == false and ((has("agents") | not) or .agents == {}) + ' "$exact_path" >/dev/null || fail "pat +browser-policy exact target readback failed" + printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-READBACK-PASS' + + raw_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$raw_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat browser-policy --enabled=false --format json)" + printf '%s' "$raw_output" | jq -e ' + type == "object" and .scope == "default" and .source == "default" and + .openBrowser == false and (has("agentCode") | not) + ' >/dev/null || fail "pat browser-policy terminal receipt is malformed" + printf '%s\n' 'PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS' + + jq -e ' + type == "object" and (.default | type == "object") and + .default.openBrowser == false and ((has("agents") | not) or .agents == {}) + ' "$raw_path" >/dev/null || fail "pat browser-policy raw target readback failed" + printf '%s\n' 'PAT-BROWSER-POLICY-RAW-READBACK-PASS' + + unlink "$exact_path" + unlink "$raw_path" + [ ! -e "$exact_path" ] || fail "pat +browser-policy cleanup left exact residue" + [ ! -e "$raw_path" ] || fail "pat browser-policy cleanup left raw residue" + find "$exact_dir" "$raw_dir" -type f -delete + [ -z "$(find "$PROOF_ROOT" -type f -print -quit)" ] || fail "PAT browser policy proof left file residue" + find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; + [ ! -e "$PROOF_ROOT" ] || fail "PAT browser policy proof left directory residue" + PROOF_ROOT="" + printf '%s\n' 'PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS' +} + +while IFS=$'\t' read -r service command proof_case; do + [ -n "$service" ] || continue + case "$service $command $proof_case" in + 'pat +browser-policy pat_browser_policy_default') + proof_output="$(run_pat_browser_policy_default)" + ;; + *) + fail "$service $command has no executable proof implementation" + ;; + esac + actual_labels="$(printf '%s\n' "$proof_output" | sed '/^$/d' | LC_ALL=C sort)" + expected_labels="$(jq -r --arg service "$service" --arg command "$command" '.public_shortcuts[] | select(.service == $service and .command == $command) | .pass_labels[]' "$MANIFEST" | LC_ALL=C sort)" + [ "$actual_labels" = "$expected_labels" ] || fail "$service $command did not emit every reviewed PASS label" + printf '%s\n' "$proof_output" +done < <(jq -r '.public_shortcuts[] | [.service, .command, .proof_case] | @tsv' "$MANIFEST") + +printf 'EVERY-PUBLIC-SHORTCUT-DOUBLE-LAYER-E2E-PASS total=%d\n' "$PUBLIC_TOTAL" diff --git a/scripts/policy/shortcut-public-e2e-proof.json b/scripts/policy/shortcut-public-e2e-proof.json new file mode 100644 index 00000000..9f142732 --- /dev/null +++ b/scripts/policy/shortcut-public-e2e-proof.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "products": [ + { + "service": "devdoc", + "source_total": 1, + "public": 0, + "unavailable": 1 + }, + { + "service": "hrbrain", + "source_total": 11, + "public": 0, + "unavailable": 11 + }, + { + "service": "pat", + "source_total": 2, + "public": 1, + "unavailable": 1 + } + ], + "public_shortcuts": [ + { + "service": "pat", + "command": "+browser-policy", + "owning_atomic": "pat browser-policy", + "proof_class": "write", + "proof_case": "pat_browser_policy_default", + "stable_target": "default-policy", + "pass_labels": [ + "PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS", + "PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS", + "PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS", + "PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS", + "PAT-BROWSER-POLICY-EXACT-READBACK-PASS", + "PAT-BROWSER-POLICY-RAW-READBACK-PASS", + "PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS" + ] + } + ] +} diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 0ad95f22..69e50e21 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -44,7 +44,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aisearch` | 1 | `—` | -| `aitable` | 93 | `dingtalk-aitable` | +| `aitable` | 100 | `dingtalk-aitable` | | `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | @@ -56,6 +56,7 @@ cli_version: ">=1.0.15" | `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | | `oa` | 1 | `dingtalk-misc` | +| `pat` | 1 | `dingtalk-misc` | | `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index 24fa4652..50fc24c4 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -28,7 +28,7 @@ metadata: ## Shortcut 发现(按需) -`aitable` 当前有 93 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`aitable` 当前有 100 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 diff --git a/skills/multi/dingtalk-misc/references/pat.md b/skills/multi/dingtalk-misc/references/pat.md index 41de9718..a4d4ec23 100644 --- a/skills/multi/dingtalk-misc/references/pat.md +++ b/skills/multi/dingtalk-misc/references/pat.md @@ -4,6 +4,16 @@ `dws pat` 管理 Agent 的行为授权。它不管理开放平台应用权限;应用权限使用 `dws dev app permission`(见 [devapp.md](./devapp.md))。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "pat +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws pat --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service pat --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws pat +browser-policy` | write | 安全配置 PAT 授权时是否允许打开本地浏览器 | + + ## 命令总览 ### 配置浏览器策略