From 56a5edecef9411910e41292989ef6b3ee1bfdbc8 Mon Sep 17 00:00:00 2001 From: xuan Date: Tue, 2 Jun 2026 20:54:55 +0800 Subject: [PATCH] feat(pat): support batch chmod authorization flows (#389) * feat(pat): support batch chmod flows * feat: use server default agentCode for chmod * feat: surface tool auth metadata * feat: infer product grant auth metadata * chore: update coverage badge [skip ci] * feat(pat): summarize chmod output by default * chore: update coverage badge [skip ci] * fix(pat): preserve batch session metadata * fix(pat): align dry-run session and schema docs * chore(config): use public example MCP override URL * fix(pat): align chmod session env handling --------- Co-authored-by: shangguanxuan.sgx Co-authored-by: github-actions[bot] --- .github/badges/coverage.svg | 2 +- CHANGELOG.md | 4 + README.md | 4 + docs/reference.md | 2 + internal/app/doctor_command.go | 7 +- internal/app/legacy.go | 8 +- internal/app/runner.go | 12 +- internal/app/runner_test.go | 24 ++ internal/auth/endpoints.go | 10 +- internal/cli/canonical.go | 8 +- internal/cli/canonical_test.go | 13 + internal/cli/loader.go | 4 +- internal/ir/catalog.go | 94 ++++++- internal/ir/catalog_test.go | 134 +++++++++ internal/pat/chmod.go | 474 ++++++++++++++++++++++++++++--- internal/pat/chmod_test.go | 500 +++++++++++++++++++++++++++++++-- internal/pat/pat.go | 4 +- pkg/config/constants.go | 17 ++ pkg/config/constants_test.go | 13 + 19 files changed, 1236 insertions(+), 98 deletions(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index b052c53a..ed40a879 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1 +1 @@ -coverage: 54.2%coverage54.2% \ No newline at end of file +coverage: 54.9%coverage54.9% \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bd0b0d..824eba49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`dws auth export` / `dws auth import`** — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output. +### Changed + +- **Breaking: `dws pat chmod` now prints a compact authorization summary by default** — scripts that parse the raw MCP JSON response from stdout must pass `--format json` or `--verbose` to preserve the previous machine-readable payload. The summary keeps the grant status, agentCode, grantType, scope counts, and next-action hint without dumping full scope detail. + ## [1.0.32] - 2026-05-25 Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`. diff --git a/README.md b/README.md index 282b8bc1..2e9e5904 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,9 @@ dws schema --jq '.products[] | {id, tool_count: (.tools | length)}' # Step 2: Inspect target tool's parameter schema dws schema aitable.query_records --jq '.tool.parameters' +# Optional: inspect DingTalk authorization metadata for PAT planning +dws schema aitable.query_records --jq '.tool.auth' + # Step 3: Construct the correct call dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10 ``` @@ -443,6 +446,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati dws schema # list all products and tools dws schema aitable.query_records # view parameter schema dws schema aitable.query_records --jq '.tool.required' # view required fields +dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata dws schema --jq '.products[].id' # extract all product IDs ``` diff --git a/docs/reference.md b/docs/reference.md index 3ee3fdfc..5b3cdce8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -76,6 +76,7 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl | `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required | | `output_schema` | MCP 输出 Schema(上游下发时才有) | | `sensitive` | 敏感写操作,需 `--yes` 确认 | +| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` | | `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 | | `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` | @@ -85,6 +86,7 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl ```bash dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay +dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据 dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数 dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示 ``` diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index 0d966e24..4b3d56fd 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -22,7 +22,6 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" @@ -190,7 +189,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout fmt.Fprint(w, "检查网络连通性... ") } - baseURL := cli.DefaultMarketBaseURL + baseURL := config.GetMCPBaseURL() httpClient := &http.Client{Timeout: timeout} client := market.NewClient(baseURL, httpClient) @@ -205,7 +204,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout r := checkResult{ Name: "network", Status: statusFail, - Message: fmt.Sprintf("mcp.dingtalk.com 不可达: %v", err), + Message: fmt.Sprintf("%s 不可达: %v", baseURL, err), Hint: "请检查网络连接或代理设置", } if !jsonOut { @@ -217,7 +216,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout r := checkResult{ Name: "network", Status: statusPass, - Message: fmt.Sprintf("mcp.dingtalk.com 可达 (延迟 %dms)", latency.Milliseconds()), + Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()), } if !jsonOut { printCheckResult(w, r) diff --git a/internal/app/legacy.go b/internal/app/legacy.go index 1ca1d25c..89886d17 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -211,11 +211,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" { slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL) } else { - baseURL := cli.DefaultMarketBaseURL - if discoveryBaseURLOverride != "" { - baseURL = discoveryBaseURLOverride - } - slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL) + slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL()) } } fetchStart := time.Now() @@ -453,7 +449,7 @@ func DiscoveryBaseURL() string { if discoveryBaseURLOverride != "" { return discoveryBaseURLOverride } - return cli.DefaultMarketBaseURL + return config.GetMCPBaseURL() } // ipv4HTTPClient returns an HTTP client that forces IPv4 connections with diff --git a/internal/app/runner.go b/internal/app/runner.go index 65c0b5cb..eb026b15 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -88,6 +88,8 @@ const ( envDingtalkTraceID = "DINGTALK_TRACE_ID" envDingtalkSessionID = "DINGTALK_SESSION_ID" envDingtalkMessageID = "DINGTALK_MESSAGE_ID" + envDWSSessionID = "DWS_SESSION_ID" + envRewindSessionID = "REWIND_SESSION_ID" // Environment variables for third-party channel integration envDWSChannel = "DWS_CHANNEL" @@ -162,6 +164,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) if r.loader == nil || r.transport == nil { return r.fallback.Run(ctx, invocation) } + r.transport.ExtraHeaders = resolveIdentityHeaders() // Mock mode: skip catalog validation, use a placeholder endpoint. if r.globalFlags != nil && r.globalFlags.Mock { @@ -677,11 +680,18 @@ func resolveIdentityHeaders() map[string]string { // open-source edition pins to edition.DefaultOSSClawType via the // MergeHeaders hook below) and it does NOT influence the host-owned // PAT decision (driven solely by DINGTALK_DWS_AGENTCODE). + sessionID := os.Getenv(envDingtalkSessionID) + if sessionID == "" { + sessionID = os.Getenv(envDWSSessionID) + } + if sessionID == "" { + sessionID = os.Getenv(envRewindSessionID) + } envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)), "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), - "x-dingtalk-session-id": os.Getenv(envDingtalkSessionID), + "x-dingtalk-session-id": sessionID, "x-dingtalk-message-id": os.Getenv(envDingtalkMessageID), } for k, v := range envHeaders { diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index ee274e82..fd3fd673 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -328,6 +328,30 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } +func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) { + setupRuntimeCommandTest(t) + t.Setenv(envDingtalkSessionID, "ding-session") + t.Setenv(envDWSSessionID, "dws-session") + t.Setenv(envRewindSessionID, "rewind-session") + + headers := resolveIdentityHeaders() + if got := headers["x-dingtalk-session-id"]; got != "ding-session" { + t.Fatalf("x-dingtalk-session-id = %q, want DINGTALK_SESSION_ID", got) + } + + t.Setenv(envDingtalkSessionID, "") + headers = resolveIdentityHeaders() + if got := headers["x-dingtalk-session-id"]; got != "dws-session" { + t.Fatalf("x-dingtalk-session-id = %q, want DWS_SESSION_ID", got) + } + + t.Setenv(envDWSSessionID, "") + headers = resolveIdentityHeaders() + if got := headers["x-dingtalk-session-id"]; got != "rewind-session" { + t.Fatalf("x-dingtalk-session-id = %q, want REWIND_SESSION_ID", got) + } +} + func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index bc617dc7..3f3b3303 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -96,7 +96,7 @@ const ( LogoutContinueURL = "https://login.dingtalk.com" // MCP API endpoints for CLI authorization management. - DefaultMCPBaseURL = "https://mcp.dingtalk.com" + DefaultMCPBaseURL = config.DefaultMCPBaseURL CLIAuthEnabledPath = "/cli/cliAuthEnabled" SuperAdminPath = "/cli/superAdmin" SendCliAuthApplyPath = "/cli/sendCliAuthApply" @@ -131,13 +131,7 @@ func GetDeveloperSettingsURL() string { // 1. ~/.dws/mcp_url file content (for pre-release environment) // 2. Default value (https://mcp.dingtalk.com) func GetMCPBaseURL() string { - mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url") - if data, err := os.ReadFile(mcpURLPath); err == nil { - if url := strings.TrimSpace(string(data)); url != "" { - return url - } - } - return DefaultMCPBaseURL + return config.GetMCPBaseURL() } // Runtime overrides set via CLI flags (--client-id, --client-secret). diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 86b23282..9fdf901f 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -110,8 +110,8 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。 不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整 -输入 Schema(JSON Schema 格式)、输出 Schema、MCP 注解和 CLI -层的 flag overlay(alias/transform/env_default)。 +输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP +注解和 CLI 层的 flag overlay(alias/transform/env_default)。 路径支持三种写法: product.rpc_name 规范路径 (e.g. ding.send_ding_message) @@ -123,6 +123,7 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { dws schema ding.send_ding_message # 规范路径 dws schema "ding message send" # CLI 路径(空格) dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好) + dws schema calendar.create_event --jq '.tool.auth' dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示 dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`, Args: cobra.MaximumNArgs(1), @@ -785,6 +786,9 @@ func compactTool(t ir.ToolDescriptor) map[string]any { if t.Annotations != nil { tool["annotations"] = t.Annotations } + if t.Auth != nil { + tool["auth"] = t.Auth + } if len(t.FlagOverlay) > 0 { tool["flag_overlay"] = t.FlagOverlay } diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index 84aa72df..2bd5e0b6 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -147,6 +147,12 @@ func TestCompactToolEmitsExtendedFields(t *testing.T) { }, }, Annotations: &ir.ToolAnnotations{DestructiveHint: &destructive}, + Auth: &ir.ToolAuthMetadata{ + ProductCode: "calendar", + RequiredPermissions: []string{"Calendar.Event.Write"}, + GrantProductCodes: []string{"calendar"}, + AuthMetaHash: "sha256:test", + }, FlagOverlay: map[string]ir.FlagOverlay{ "receiverUserIdList": {Alias: "users", Transform: "csv_to_array"}, }, @@ -171,6 +177,13 @@ func TestCompactToolEmitsExtendedFields(t *testing.T) { if _, ok := out["annotations"]; !ok { t.Errorf("annotations missing, keys = %v", keysOf(out)) } + auth, ok := out["auth"].(*ir.ToolAuthMetadata) + if !ok { + t.Fatalf("auth type = %T", out["auth"]) + } + if auth.RequiredPermissions[0] != "Calendar.Event.Write" { + t.Errorf("auth required permissions = %#v", auth.RequiredPermissions) + } overlay, ok := out["flag_overlay"].(map[string]ir.FlagOverlay) if !ok { t.Fatalf("flag_overlay type = %T", out["flag_overlay"]) diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 828dd816..b67edce9 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -113,7 +113,7 @@ const ( CatalogFixtureEnv = "DWS_CATALOG_FIXTURE" CacheDirEnv = "DWS_CACHE_DIR" PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT" - DefaultMarketBaseURL = "https://mcp.dingtalk.com" + DefaultMarketBaseURL = config.DefaultMCPBaseURL // defaultDiscoveryTimeout bounds the time spent on live registry discovery. // Tightened to 4s so a slow/unreachable discovery endpoint cannot block @@ -203,7 +203,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) { // eliminating the historical split where the command tree came from // Wukong Portal while runtime endpoint resolution silently read the // open-source Market cache (see fix-wukong-endpoint-partition plan). - baseURL := DefaultMarketBaseURL + baseURL := config.GetMCPBaseURL() if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" { baseURL = editionURL } diff --git a/internal/ir/catalog.go b/internal/ir/catalog.go index 1ee76716..6379a234 100644 --- a/internal/ir/catalog.go +++ b/internal/ir/catalog.go @@ -96,6 +96,7 @@ type ToolDescriptor struct { InputSchema map[string]any `json:"input_schema,omitempty"` OutputSchema map[string]any `json:"output_schema,omitempty"` Sensitive bool `json:"sensitive"` + Auth *ToolAuthMetadata `json:"auth,omitempty"` Annotations *ToolAnnotations `json:"annotations,omitempty"` Hidden bool `json:"hidden,omitempty"` FlagHints map[string]CLIFlagHint `json:"flag_hints,omitempty"` @@ -104,6 +105,25 @@ type ToolDescriptor struct { CanonicalPath string `json:"canonical_path"` } +type ToolAuthMetadata struct { + Version string `json:"version,omitempty"` + ProductCode string `json:"productCode,omitempty"` + Domain string `json:"domain,omitempty"` + ClientObservedScopes []string `json:"clientObservedScopes,omitempty"` + RequiredScopes []string `json:"requiredScopes,omitempty"` + RequiredPermissions []string `json:"requiredPermissions,omitempty"` + RecommendedScopes []string `json:"recommendedScopes,omitempty"` + ExcludedScopes []string `json:"excludedScopes,omitempty"` + GrantProductCodes []string `json:"grantProductCodes,omitempty"` + RiskHint string `json:"riskHint,omitempty"` + RiskAction string `json:"riskAction,omitempty"` + ConfirmationRequired bool `json:"confirmationRequired,omitempty"` + Identities []string `json:"identities,omitempty"` + Source string `json:"source,omitempty"` + AuthMetaVersion string `json:"authMetaVersion,omitempty"` + AuthMetaHash string `json:"authMetaHash,omitempty"` +} + func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { sorted := append([]discovery.RuntimeServer(nil), runtimeServers...) sort.Slice(sorted, func(i, j int) bool { @@ -235,15 +255,17 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { if cliName == "" { cliName = tool.Name } + inputSchema := cloneMap(tool.InputSchema) tools = append(tools, ToolDescriptor{ RPCName: tool.Name, CLIName: cliName, Group: strings.TrimSpace(toolGroup[tool.Name]), Title: title, Description: description, - InputSchema: cloneMap(tool.InputSchema), + InputSchema: inputSchema, OutputSchema: cloneMap(tool.OutputSchema), Sensitive: sensitive, + Auth: extractToolAuthMetadata(inputSchema, productID), Annotations: deriveAnnotations(sensitive), Hidden: toolHidden[tool.Name], FlagHints: cloneFlagHints(toolFlagHints[tool.Name]), @@ -303,6 +325,76 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { return Catalog{Products: products} } +func extractToolAuthMetadata(schema map[string]any, productID string) *ToolAuthMetadata { + raw := any(nil) + if len(schema) > 0 { + raw = schema["x-dingtalk-auth"] + } + if raw == nil { + return productGrantAuthMetadata(productID) + } + data, err := json.Marshal(raw) + if err != nil { + return nil + } + var metadata ToolAuthMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil + } + if !hasMeaningfulToolAuthMetadata(metadata) { + return nil + } + return &metadata +} + +func hasMeaningfulToolAuthMetadata(metadata ToolAuthMetadata) bool { + return strings.TrimSpace(metadata.Version) != "" || + strings.TrimSpace(metadata.ProductCode) != "" || + strings.TrimSpace(metadata.Domain) != "" || + len(metadata.ClientObservedScopes) > 0 || + len(metadata.RequiredScopes) > 0 || + len(metadata.RequiredPermissions) > 0 || + len(metadata.RecommendedScopes) > 0 || + len(metadata.ExcludedScopes) > 0 || + len(metadata.GrantProductCodes) > 0 || + strings.TrimSpace(metadata.RiskHint) != "" || + strings.TrimSpace(metadata.RiskAction) != "" || + metadata.ConfirmationRequired || + len(metadata.Identities) > 0 || + strings.TrimSpace(metadata.Source) != "" || + strings.TrimSpace(metadata.AuthMetaVersion) != "" || + strings.TrimSpace(metadata.AuthMetaHash) != "" +} + +func productGrantAuthMetadata(productID string) *ToolAuthMetadata { + productID = strings.TrimSpace(productID) + if productID == "" { + return nil + } + metadata := ToolAuthMetadata{ + Version: "v1", + ProductCode: productID, + Domain: productID, + GrantProductCodes: []string{ + productID, + }, + Source: "dws-product-fallback", + AuthMetaVersion: "v1", + } + metadata.AuthMetaHash = toolAuthMetaHash(metadata) + return &metadata +} + +func toolAuthMetaHash(metadata ToolAuthMetadata) string { + metadata.AuthMetaHash = "" + data, err := json.Marshal(metadata) + if err != nil { + return "" + } + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) { for _, product := range c.Products { if product.ID == id { diff --git a/internal/ir/catalog_test.go b/internal/ir/catalog_test.go index 6cca9f4c..eccaaa2d 100644 --- a/internal/ir/catalog_test.go +++ b/internal/ir/catalog_test.go @@ -216,6 +216,140 @@ func TestBuildCatalogFallsBackToRuntimeSensitiveMetadata(t *testing.T) { } } +func TestBuildCatalogCarriesDingTalkAuthMetadata(t *testing.T) { + t.Parallel() + + catalog := BuildCatalog([]discovery.RuntimeServer{ + { + Server: market.ServerDescriptor{ + Key: "calendar-key", + DisplayName: "日历", + Endpoint: "https://example.com/server/calendar", + }, + Tools: []transport.ToolDescriptor{ + { + Name: "createEvent", + InputSchema: map[string]any{ + "type": "object", + "x-dingtalk-auth": map[string]any{ + "version": "v1", + "productCode": "calendar", + "domain": "calendar", + "clientObservedScopes": []any{"Calendar.Event.Write"}, + "requiredPermissions": []any{"Calendar.Event.Write"}, + "grantProductCodes": []any{"calendar"}, + "riskAction": "write", + "authMetaHash": "sha256:test", + }, + }, + }, + }, + }, + }) + + tool, ok := catalog.Products[0].FindTool("createEvent") + if !ok { + t.Fatalf("FindTool(createEvent) = not found") + } + if tool.Auth == nil { + t.Fatal("tool.Auth = nil") + } + if tool.Auth.ProductCode != "calendar" { + t.Fatalf("ProductCode = %q, want calendar", tool.Auth.ProductCode) + } + if got := tool.Auth.RequiredPermissions; len(got) != 1 || got[0] != "Calendar.Event.Write" { + t.Fatalf("RequiredPermissions = %#v, want Calendar.Event.Write", got) + } + if got := tool.Auth.GrantProductCodes; len(got) != 1 || got[0] != "calendar" { + t.Fatalf("GrantProductCodes = %#v, want calendar", got) + } +} + +func TestBuildCatalogCarriesGrantOnlyAuthMetadata(t *testing.T) { + t.Parallel() + + catalog := BuildCatalog([]discovery.RuntimeServer{ + { + Server: market.ServerDescriptor{ + Key: "calendar-key", + DisplayName: "日历", + Endpoint: "https://example.com/server/calendar", + }, + Tools: []transport.ToolDescriptor{ + { + Name: "createEvent", + InputSchema: map[string]any{ + "type": "object", + "x-dingtalk-auth": map[string]any{ + "grantProductCodes": []any{"calendar"}, + "recommendedScopes": []any{"calendar.event:create"}, + "riskAction": "high-risk-write", + "confirmationRequired": true, + }, + }, + }, + }, + }, + }) + + tool, ok := catalog.Products[0].FindTool("createEvent") + if !ok { + t.Fatalf("FindTool(createEvent) = not found") + } + if tool.Auth == nil { + t.Fatal("tool.Auth = nil") + } + if got := tool.Auth.GrantProductCodes; len(got) != 1 || got[0] != "calendar" { + t.Fatalf("GrantProductCodes = %#v, want calendar", got) + } + if got := tool.Auth.RecommendedScopes; len(got) != 1 || got[0] != "calendar.event:create" { + t.Fatalf("RecommendedScopes = %#v, want calendar.event:create", got) + } + if !tool.Auth.ConfirmationRequired { + t.Fatal("ConfirmationRequired = false, want true") + } +} + +func TestBuildCatalogFallsBackToProductGrantAuthMetadata(t *testing.T) { + t.Parallel() + + catalog := BuildCatalog([]discovery.RuntimeServer{ + { + Server: market.ServerDescriptor{ + Key: "calendar-key", + DisplayName: "日历", + Endpoint: "https://example.com/server/calendar", + CLI: market.CLIOverlay{ + Command: "calendar", + }, + }, + Tools: []transport.ToolDescriptor{ + { + Name: "list_calendar_events", + InputSchema: map[string]any{"type": "object"}, + }, + }, + }, + }) + + tool, ok := catalog.Products[0].FindTool("list_calendar_events") + if !ok { + t.Fatalf("FindTool(list_calendar_events) = not found") + } + if tool.Auth == nil { + t.Fatal("tool.Auth = nil") + } + if tool.Auth.ProductCode != "calendar" { + t.Fatalf("ProductCode = %q, want calendar", tool.Auth.ProductCode) + } + if got := tool.Auth.GrantProductCodes; len(got) != 1 || got[0] != "calendar" { + t.Fatalf("GrantProductCodes = %#v, want calendar", got) + } + if tool.Auth.Source != "dws-product-fallback" { + t.Fatalf("Source = %q, want dws-product-fallback", tool.Auth.Source) + } +} + func TestBuildCatalogCarriesToolOverrideGroupAndFlagOverlay(t *testing.T) { t.Parallel() diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 0653a7c3..b5ae49f3 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -29,37 +29,45 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +const ( + sessionIDEnvDingtalk = "DINGTALK_SESSION_ID" + sessionIDEnvDWS = "DWS_SESSION_ID" + sessionIDEnvRewind = "REWIND_SESSION_ID" +) + // resolveSessionIDFromEnv returns the effective session id from environment -// variables. Resolution order: -// 1. DWS_SESSION_ID (primary, stable env name). -// 2. REWIND_SESSION_ID (compatibility alias; kept only so hosts that +// variables. Resolution order matches the MCP header resolver: +// 1. DINGTALK_SESSION_ID (primary gateway header env). +// 2. DWS_SESSION_ID (stable CLI/session-grant env name). +// 3. REWIND_SESSION_ID (compatibility alias; kept only so hosts that // already inject the legacy trace triple keep working without code // churn). // -// When both are set to different non-empty values, DWS_SESSION_ID wins -// silently. We deliberately do NOT log either raw session id value or -// any derived fingerprint: this resolver is invoked by `dws pat chmod` -// session grants, and any stderr / ~/.dws/logs capture of those -// identifiers can land verbatim in attached troubleshooting bundles. -// Hosts that need to detect a mismatch between the two env vars must do -// so on the host side before invoking the CLI. +// When multiple env vars are set to different non-empty values, the first +// one above wins silently. We deliberately do NOT log either raw session id +// value or any derived fingerprint: this resolver is invoked by `dws pat +// chmod` session grants, and any stderr / ~/.dws/logs capture of those +// identifiers can land verbatim in attached troubleshooting bundles. Hosts +// that need to detect a mismatch must do so before invoking the CLI. func resolveSessionIDFromEnv() string { - if dws := os.Getenv("DWS_SESSION_ID"); dws != "" { - return dws + for _, key := range []string{sessionIDEnvDingtalk, sessionIDEnvDWS, sessionIDEnvRewind} { + if value := os.Getenv(key); value != "" { + return value + } } - return os.Getenv("REWIND_SESSION_ID") + return "" } // agentCodeEnv is the canonical (and only) environment variable name // used as a per-shell fallback for the --agentCode flag on `dws pat *` // commands. // -// Why: agent hosts typically set their business agent code once when -// spawning a long-lived shell / sub-process; requiring `--agentCode` on -// every command in that shell forces the host to rewrite every argv. -// Exposing DINGTALK_DWS_AGENTCODE lets the host export the code once and -// let the CLI resolve it on every pat subcommand. The flag always wins -// when both are set so scripted one-offs remain deterministic. +// Why: agent hosts may set their business agent code once when spawning +// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets +// the host export the code once and let the CLI resolve it on every pat +// subcommand. The flag always wins when both are set so scripted one-offs +// remain deterministic. When neither flag nor env is set, the request is +// sent without agentCode and lippi-pat-core applies its default agentCode. // // Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE // are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was @@ -139,9 +147,18 @@ const ( // patGrantToolName is the English-first wire name for the PAT grant tool. patGrantToolName = "pat.grant" + // patBatchGrantToolName is the English-first wire name for PAT batch grant. + patBatchGrantToolName = "pat.batch_grant" + + // patBatchPlanToolName is the English-first wire name for PAT batch plan. + patBatchPlanToolName = "pat.batch_plan" + // patGrantToolNameLegacyAlias is retained for server builds that still // expose only the legacy Chinese display name. patGrantToolNameLegacyAlias = "个人授权" + + patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" + patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" ) var validGrantTypes = map[string]bool{ @@ -155,6 +172,12 @@ var validGrantTypes = map[string]bool{ // var) so multiple RegisterCommands invocations never share mutable flag / // RunE state across concurrent tests. func newChmodCommand(c edition.ToolCaller) *cobra.Command { + var recommend bool + var productFlags []string + var productsFlag []string + var domainFlags []string + var domainsFlag []string + chmodCmd := &cobra.Command{ Use: "chmod ...", Short: "授予指定权限", @@ -167,33 +190,58 @@ grantType 规则: once 一次性,执行一次后自动失效 session 当前会话有效(默认),需要 --session-id permanent 永久有效`, - Args: cobra.MinimumNArgs(1), - Example: ` dws pat chmod aitable.record:read --agentCode agt-xxxx --grant-type session --session-id session-xxx - dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx - dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`, + Args: func(cmd *cobra.Command, args []string) error { + productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) + if len(args) > 0 || recommend || len(productCodes) > 0 { + return nil + } + return cobra.MinimumNArgs(1)(cmd, args) + }, + Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx + dws pat chmod chat.message:list --grant-type once + dws pat chmod aitable.record:read aitable.record:write --grant-type permanent + dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx + dws pat chmod --recommend --grant-type session --session-id session-xxx`, RunE: func(cmd *cobra.Command, args []string) error { flagVal, _ := cmd.Flags().GetString("agentCode") - agentCode, err := resolveAgentCode(flagVal, true) + agentCode, err := resolveAgentCode(flagVal, false) if err != nil { return err } scopes := args + productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) + usesPlan := recommend || len(productCodes) > 0 grantType, _ := cmd.Flags().GetString("grant-type") sessionID, _ := cmd.Flags().GetString("session-id") + if sessionID == "" { + sessionID = resolveSessionIDFromEnv() + } if !validGrantTypes[grantType] { return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType) } - if grantType == "session" && sessionID == "" && resolveSessionIDFromEnv() == "" { - return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod --agentCode --grant-type session --session-id ") + if grantType == "session" && sessionID == "" { + return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod --grant-type session --session-id ") } if c != nil && c.DryRun() { + if usesPlan { + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true) + result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) + if err != nil { + return fmt.Errorf("pat chmod plan failed: %w", err) + } + return handleToolResult(cmd, c, result) + } bold := color.New(color.FgYellow, color.Bold) bold.Println("[DRY-RUN] Preview only, not executed:") - fmt.Printf("%-16s%s\n", "Tool:", patGrantToolName) - fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) + fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName) + if agentCode != "" { + fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) + } else { + fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)") + } fmt.Printf("%-16s%v\n", "Scope:", scopes) fmt.Printf("%-16s%s\n", "GrantType:", grantType) if sessionID != "" { @@ -206,15 +254,33 @@ grantType 规则: return fmt.Errorf("internal error: tool runtime not initialized") } - if sessionID == "" { - sessionID = resolveSessionIDFromEnv() + if usesPlan { + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true) + planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) + if err != nil { + return fmt.Errorf("pat chmod plan failed: %w", err) + } + scopes, err = extractSelectedScopes(planResult) + if err != nil { + return err + } + if len(scopes) == 0 { + return handleToolResult(cmd, c, planResult) + } } - toolArgs := map[string]any{ - "agentCode": agentCode, + batchArgs := map[string]any{ "scopes": scopes, "grantType": grantType, } + toolArgs := map[string]any{ + "scopes": scopes, + "grantType": grantType, + } + if agentCode != "" { + toolArgs["agentCode"] = agentCode + } if sessionID != "" { + batchArgs["sessionId"] = sessionID toolArgs["sessionId"] = sessionID } // Legacy server schema accepted singular "scope"; clone the @@ -230,27 +296,211 @@ grantType 规则: } ctx := context.Background() - result, err := callPATToolWithLegacyFallback(ctx, c, "pat", patGrantToolName, patGrantToolNameLegacyAlias, toolArgs, legacyToolArgs) + result, err := callPATBatchGrantWithLegacyFallback( + ctx, + c, + agentCode, + sessionID, + batchArgs, + toolArgs, + legacyToolArgs, + ) if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } - return handleToolResult(result) + return handleToolResult(cmd, c, result) }, } - // --agentCode is required, but we deliberately do NOT call - // MarkFlagRequired here. The agent code may also come from the - // DINGTALK_DWS_AGENTCODE env var; cobra's MarkFlagRequired would - // refuse to run before our resolver has a chance to consume the env. chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(必填;亦可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先)") + "Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)") chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") + chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价") + chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔") + chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权") + chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔") + chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权") return chmodCmd } +func collectChmodProductCodes(groups ...[]string) []string { + seen := map[string]bool{} + result := make([]string, 0) + for _, group := range groups { + for _, raw := range group { + for _, part := range strings.Split(raw, ",") { + code := strings.TrimSpace(part) + if code == "" || seen[code] { + continue + } + seen[code] = true + result = append(result, code) + } + } + } + return result +} + +func withPATContextEnv(agentCode, sessionID string, fn func() (*edition.ToolResult, error)) (*edition.ToolResult, error) { + restore := map[string]*string{} + setEnv := func(key, value string) { + if value == "" { + return + } + if _, seen := restore[key]; !seen { + if old, ok := os.LookupEnv(key); ok { + oldCopy := old + restore[key] = &oldCopy + } else { + restore[key] = nil + } + } + _ = os.Setenv(key, value) + } + setEnv(agentCodeEnv, agentCode) + setEnv(sessionIDEnvDingtalk, sessionID) + setEnv(sessionIDEnvDWS, sessionID) + defer func() { + for key, old := range restore { + if old == nil { + _ = os.Unsetenv(key) + continue + } + _ = os.Setenv(key, *old) + } + }() + return fn() +} + +func callPATBatchGrantWithLegacyFallback( + ctx context.Context, + c edition.ToolCaller, + agentCode string, + sessionID string, + batchArgs map[string]any, + canonicalGrantArgs map[string]any, + legacyGrantArgs map[string]any, +) (*edition.ToolResult, error) { + if c == nil { + return nil, fmt.Errorf("internal error: tool runtime not initialized") + } + result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs) + }) + if err == nil && !isPATBatchUnsupportedResult(result) { + return result, nil + } + if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) { + return nil, err + } + return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return callPATToolWithLegacyFallback( + ctx, + c, + "pat", + patGrantToolName, + patGrantToolNameLegacyAlias, + canonicalGrantArgs, + legacyGrantArgs, + ) + }) +} + +func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sessionID string, args map[string]any) (*edition.ToolResult, error) { + if c == nil { + return nil, fmt.Errorf("internal error: tool runtime not initialized") + } + return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return c.CallTool(ctx, "pat", patBatchPlanToolName, args) + }) +} + +func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, sessionID string, dryRun bool) map[string]any { + args := map[string]any{ + "scopes": scopes, + "productCodes": productCodes, + "recommend": recommend, + "grantType": grantType, + "dryRun": dryRun, + } + if sessionID != "" { + args["sessionId"] = sessionID + } + return args +} + +func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { + text := firstToolResultText(result) + if text == "" { + return nil, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return nil, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return nil, fmt.Errorf("PAT batch plan result missing data.selectedScopes") + } + rawScopes, _ := data["selectedScopes"].([]any) + if len(rawScopes) == 0 { + if allGranted, _ := data["allGranted"].(bool); allGranted { + return []string{}, nil + } + return nil, fmt.Errorf("PAT batch plan selectedScopes is empty") + } + scopes := make([]string, 0, len(rawScopes)) + for _, raw := range rawScopes { + scope, ok := raw.(string) + if ok && strings.TrimSpace(scope) != "" { + scopes = append(scopes, scope) + } + } + if len(scopes) == 0 { + if allGranted, _ := data["allGranted"].(bool); allGranted { + return []string{}, nil + } + return nil, fmt.Errorf("PAT batch plan selectedScopes is empty") + } + return scopes, nil +} + +func firstToolResultText(result *edition.ToolResult) string { + if result == nil { + return "" + } + for _, c := range result.Content { + if c.Type == "text" && strings.TrimSpace(c.Text) != "" { + return strings.TrimSpace(c.Text) + } + } + return "" +} + +func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { + text := firstToolResultText(result) + if text == "" { + return false + } + var body map[string]any + if json.Unmarshal([]byte(text), &body) != nil { + return false + } + for _, key := range []string{"code", "errorCode", "error_code"} { + if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) { + return true + } + } + return false +} + +func isPATBatchUnsupportedError(err error) bool { + return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower) +} + // callPATToolWithLegacyFallback invokes the canonical PAT grant tool first, // then silently retries the legacy Chinese alias when the server has not // registered the canonical tool yet. The retry intentionally emits no stderr @@ -406,7 +656,7 @@ func containsAny(msg string, needles ...string) bool { } // handleToolResult processes a ToolResult and writes output to stdout. -func handleToolResult(result *edition.ToolResult) error { +func handleToolResult(cmd *cobra.Command, caller edition.ToolCaller, result *edition.ToolResult) error { if result == nil { return fmt.Errorf("empty tool result") } @@ -417,9 +667,153 @@ func handleToolResult(result *edition.ToolResult) error { if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil { return respErr } + if !shouldEmitRawPATResult(cmd) { + if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" { + fmt.Print(summary) + return nil + } + } fmt.Println(c.Text) return nil } data, _ := json.Marshal(result) return fmt.Errorf("empty PAT authorization result: %s", string(data)) } + +func shouldEmitRawPATResult(cmd *cobra.Command) bool { + if commandBoolFlag(cmd, "verbose") { + return true + } + if !commandFlagChanged(cmd, "format") { + return false + } + format := strings.ToLower(strings.TrimSpace(commandStringFlag(cmd, "format"))) + return format == "json" || format == "raw" +} + +func commandBoolFlag(cmd *cobra.Command, name string) bool { + if cmd == nil { + return false + } + if flag := cmd.Flags().Lookup(name); flag != nil { + value, err := cmd.Flags().GetBool(name) + return err == nil && value + } + root := cmd.Root() + if root == nil { + return false + } + value, err := root.PersistentFlags().GetBool(name) + return err == nil && value +} + +func commandFlagChanged(cmd *cobra.Command, name string) bool { + if cmd == nil { + return false + } + if flag := cmd.Flags().Lookup(name); flag != nil && flag.Changed { + return true + } + root := cmd.Root() + if root == nil { + return false + } + flag := root.PersistentFlags().Lookup(name) + return flag != nil && flag.Changed +} + +func commandStringFlag(cmd *cobra.Command, name string) string { + if cmd == nil { + return "" + } + if flag := cmd.Flags().Lookup(name); flag != nil { + value, _ := cmd.Flags().GetString(name) + return value + } + root := cmd.Root() + if root == nil { + return "" + } + value, _ := root.PersistentFlags().GetString(name) + return value +} + +func formatPATAuthorizationSummary(text string, caller edition.ToolCaller) string { + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return "" + } + data, _ := body["data"].(map[string]any) + if data == nil { + return "" + } + + lines := []string{"PAT authorization"} + if code := stringField(body, "code"); code != "" { + lines = append(lines, "status: "+code) + } else if success, ok := body["success"].(bool); ok { + lines = append(lines, fmt.Sprintf("success: %v", success)) + } + if agentCode := stringField(data, "agentCode"); agentCode != "" { + lines = append(lines, "agentCode: "+agentCode) + } + if grantType := stringField(data, "grantType"); grantType != "" { + lines = append(lines, "grantType: "+grantType) + } + if allGranted, ok := data["allGranted"].(bool); ok { + lines = append(lines, fmt.Sprintf("allGranted: %v", allGranted)) + } + + appendCountLine := func(label, key string) { + if count, ok := countField(data, key); ok { + lines = append(lines, fmt.Sprintf("%s: %d", label, count)) + } + } + appendCountLine("items", "items") + appendCountLine("selected", "selectedScopes") + appendCountLine("granted", "grantedScopes") + appendCountLine("alreadyGranted", "alreadyGrantedScopes") + appendCountLine("skipped", "skippedScopes") + appendCountLine("pending", "pendingScopes") + + lines = append(lines, "suggestion: "+patAuthorizationSuggestion(data, caller)) + lines = append(lines, "hint: use --format json or --verbose for full scope details") + return strings.Join(lines, "\n") + "\n" +} + +func patAuthorizationSuggestion(data map[string]any, caller edition.ToolCaller) string { + if allGranted, ok := data["allGranted"].(bool); ok && allGranted { + return "no action needed" + } + if count, ok := countField(data, "selectedScopes"); ok && count > 0 { + if caller != nil && caller.DryRun() { + return "rerun this command without --dry-run to grant selected scopes" + } + return "selected scopes are ready to grant" + } + if count, ok := countField(data, "pendingScopes"); ok && count > 0 { + return "complete authorization, then retry the command" + } + return "check auth status or rerun with --format json for details" +} + +func countField(data map[string]any, key string) (int, bool) { + raw, ok := data[key] + if !ok { + return 0, false + } + switch v := raw.(type) { + case []any: + return len(v), true + case []string: + return len(v), true + } + return 0, false +} + +func stringField(data map[string]any, key string) string { + if value, ok := data[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index fc60294b..d3af564f 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -16,6 +16,8 @@ package pat import ( "context" "errors" + "io" + "os" "strings" "sync" "testing" @@ -30,12 +32,15 @@ import ( // assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error // resolver feeds into the outgoing MCP argv. type fakeToolCaller struct { - mu sync.Mutex - dryRun bool - gotTool string - gotArgs map[string]any - callN int - resultOK bool + mu sync.Mutex + dryRun bool + gotTool string + gotArgs map[string]any + gotAgentEnv string + gotSessionEnv string + gotDingSessionEnv string + callN int + resultOK bool } func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { @@ -43,6 +48,9 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, defer f.mu.Unlock() f.callN++ f.gotTool = toolName + f.gotAgentEnv = os.Getenv(agentCodeEnv) + f.gotSessionEnv = os.Getenv(sessionIDEnvDWS) + f.gotDingSessionEnv = os.Getenv(sessionIDEnvDingtalk) // defensive copy — RunE / runApply may mutate the map after return f.gotArgs = make(map[string]any, len(args)) for k, v := range args { @@ -187,6 +195,28 @@ func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ strin func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" } func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false } +type sequenceToolCaller struct { + calls []recordedToolCall + responses []string + dryRun bool +} + +func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { + copied := make(map[string]any, len(args)) + for k, v := range args { + copied[k] = v + } + s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied}) + response := `{"success":true,"data":{}}` + if len(s.responses) >= len(s.calls) { + response = s.responses[len(s.calls)-1] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil +} + +func (s *sequenceToolCaller) Format() string { return "json" } +func (s *sequenceToolCaller) DryRun() bool { return s.dryRun } + func stringSliceArgEqual(got any, want []string) bool { gotSlice, ok := got.([]string) if !ok || len(gotSlice) != len(want) { @@ -200,6 +230,25 @@ func stringSliceArgEqual(got any, want []string) bool { return true } +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + oldStdout := os.Stdout + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe error = %v", err) + } + os.Stdout = writePipe + runErr := fn() + _ = writePipe.Close() + os.Stdout = oldStdout + out, readErr := io.ReadAll(readPipe) + _ = readPipe.Close() + if readErr != nil { + t.Fatalf("ReadAll stdout error = %v", readErr) + } + return string(out), runErr +} + // buildChmod returns a freshly constructed chmod cobra.Command wired to // fake. Using the factory (instead of a package-level var) keeps every // subtest hermetic and matches the upstream shared-state fix in PR #129. @@ -208,6 +257,254 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command { return newChmodCommand(fake) } +func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { + root := &cobra.Command{Use: "dws"} + RegisterCommands(root, &fakeToolCaller{}) + + patCmd, _, err := root.Find([]string{"pat"}) + if err != nil { + t.Fatalf("pat command not found: %v", err) + } + children := map[string]bool{} + for _, child := range patCmd.Commands() { + children[child.Name()] = true + } + if !children["chmod"] { + t.Fatal("pat chmod command not registered") + } + for _, unexpected := range []string{"grant-batch", "grant-recommend", "scopes", "check"} { + if children[unexpected] { + t.Fatalf("unexpected pat subcommand %q registered; authorization must stay on chmod", unexpected) + } + } +} + +func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read","aitable.record:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar,aitable") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar", "aitable"}) { + t.Fatalf("productCodes = %#v, want calendar/aitable", got) + } + if got := fake.calls[0].args["recommend"]; got != false { + t.Fatalf("recommend = %#v, want false", got) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) { + t.Fatalf("grant scopes = %#v, want selected scopes", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("batch grant args must not contain agentCode: %#v", fake.calls[1].args) + } +} + +func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("products", "calendar") + _ = cmd.Flags().Set("session-id", "session-123") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if got := fake.calls[0].args["grantType"]; got != "session" { + t.Fatalf("plan grantType = %#v, want session", got) + } + if got := fake.calls[0].args["sessionId"]; got != "session-123" { + t.Fatalf("plan sessionId = %#v, want session-123", got) + } + if got := fake.calls[1].args["grantType"]; got != "session" { + t.Fatalf("grant grantType = %#v, want session", got) + } + if got := fake.calls[1].args["sessionId"]; got != "session-123" { + t.Fatalf("grant sessionId = %#v, want session-123", got) + } +} + +func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + t.Setenv(sessionIDEnvDWS, "env-session-123") + fake := &sequenceToolCaller{ + dryRun: true, + responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("products", "calendar") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want 1", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["sessionId"]; got != "env-session-123" { + t.Fatalf("plan sessionId = %#v, want env-session-123", got) + } +} + +func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { + t.Setenv(sessionIDEnvDingtalk, "ding-session") + t.Setenv(sessionIDEnvDWS, "dws-session") + t.Setenv(sessionIDEnvRewind, "rewind-session") + + if got := resolveSessionIDFromEnv(); got != "ding-session" { + t.Fatalf("resolveSessionIDFromEnv() = %q, want DINGTALK_SESSION_ID", got) + } + + t.Setenv(sessionIDEnvDingtalk, "") + if got := resolveSessionIDFromEnv(); got != "dws-session" { + t.Fatalf("resolveSessionIDFromEnv() = %q, want DWS_SESSION_ID", got) + } + + t.Setenv(sessionIDEnvDWS, "") + if got := resolveSessionIDFromEnv(); got != "rewind-session" { + t.Fatalf("resolveSessionIDFromEnv() = %q, want REWIND_SESSION_ID", got) + } +} + +func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { + t.Setenv(sessionIDEnvDingtalk, "ding-session-123") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if got := fake.gotArgs["sessionId"]; got != "ding-session-123" { + t.Fatalf("sessionId arg = %#v, want ding-session-123", got) + } + if fake.gotDingSessionEnv != "ding-session-123" { + t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDingtalk, fake.gotDingSessionEnv) + } + if fake.gotSessionEnv != "ding-session-123" { + t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDWS, fake.gotSessionEnv) + } +} + +func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { + t.Setenv(sessionIDEnvDingtalk, "stale-session") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("session-id", "flag-session") + + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if got := fake.gotArgs["sessionId"]; got != "flag-session" { + t.Fatalf("sessionId arg = %#v, want flag-session", got) + } + if fake.gotDingSessionEnv != "flag-session" { + t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDingtalk, fake.gotDingSessionEnv) + } + if fake.gotSessionEnv != "flag-session" { + t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDWS, fake.gotSessionEnv) + } +} + +func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["recommended.scope:read"]}}`, + `{"success":true,"data":{"grantedScopes":["recommended.scope:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("recommend", "true") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("recommend = %#v, want true", got) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } +} + +func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want only plan call", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } +} + +func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &fakeToolCaller{dryRun: true} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + + output, err := captureStdout(t, func() error { + return cmd.RunE(cmd, []string{"aitable.record:read"}) + }) + if err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if !strings.Contains(output, patBatchGrantToolName) { + t.Fatalf("dry-run output = %q, want %q", output, patBatchGrantToolName) + } + if strings.Contains(output, patGrantToolName+"\n") { + t.Fatalf("dry-run output = %q, must not advertise legacy %q", output, patGrantToolName) + } +} + // --------------------------------------------------------------------------- // T1 · Agent-code env fallback tests // --------------------------------------------------------------------------- @@ -227,8 +524,14 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { t.Fatalf("chmod RunE error = %v (must not report flag missing)", err) } - if got := fake.gotArgs["agentCode"]; got != "qoderwork" { - t.Fatalf("agentCode in argv = %v, want %q (env fallback)", got, "qoderwork") + if fake.gotTool != patBatchGrantToolName { + t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) + } + if got := fake.gotAgentEnv; got != "qoderwork" { + t.Fatalf("agent env = %q, want %q", got, "qoderwork") + } + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs) } if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) @@ -238,6 +541,30 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } +func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { + t.Setenv(agentCodeEnv, "") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if fake.gotTool != patBatchGrantToolName { + t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) + } + if got := fake.gotAgentEnv; got != "" { + t.Fatalf("agent env = %q, want empty so server default agentCode is used", got) + } + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs) + } + if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { + t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) + } +} + func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) { fake := &fallbackToolCaller{} canonicalArgs := map[string]any{ @@ -288,8 +615,8 @@ func TestChmod_emptyCanonicalResultReturnsError(t *testing.T) { if len(fake.calls) != 1 { t.Fatalf("CallTool call count = %d, want 1", len(fake.calls)) } - if fake.calls[0].tool != patGrantToolName { - t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName) + if fake.calls[0].tool != patBatchGrantToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) } } @@ -467,8 +794,27 @@ func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) { } } +func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) { + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"pat_batch_auth_unsupported"}`}}} + if !isPATBatchUnsupportedResult(result) { + t.Fatal("isPATBatchUnsupportedResult() = false, want true") + } +} + +func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { + err := apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PAT_BATCH_AUTH_UNSUPPORTED", + }), + ) + if !isPATBatchUnsupportedError(err) { + t.Fatal("isPATBatchUnsupportedError() = false, want true") + } +} + func TestHandleToolResult_emptyResultReturnsError(t *testing.T) { - err := handleToolResult(&edition.ToolResult{}) + err := handleToolResult(nil, nil, &edition.ToolResult{}) if err == nil { t.Fatal("handleToolResult error = nil, want empty PAT authorization result error") } @@ -477,6 +823,100 @@ func TestHandleToolResult_emptyResultReturnsError(t *testing.T) { } } +func TestHandleToolResult_defaultSummarizesBatchPlan(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "json", "") + cmd := &cobra.Command{Use: "chmod"} + root.AddCommand(cmd) + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{ + "success": true, + "code": "OK", + "data": { + "agentCode": "ding-agent", + "allGranted": false, + "items": [ + {"scope": "calendar.event:list"}, + {"scope": "calendar.event:create"} + ], + "selectedScopes": ["calendar.event:create"], + "skippedScopes": ["calendar.event:list"], + "pendingScopes": [] + } + }`}}} + + output, err := captureStdout(t, func() error { + return handleToolResult(cmd, &sequenceToolCaller{dryRun: true}, result) + }) + if err != nil { + t.Fatalf("handleToolResult error = %v", err) + } + for _, want := range []string{ + "PAT authorization", + "status: OK", + "agentCode: ding-agent", + "allGranted: false", + "items: 2", + "selected: 1", + "skipped: 1", + "pending: 0", + "suggestion: rerun this command without --dry-run to grant selected scopes", + } { + if !strings.Contains(output, want) { + t.Fatalf("summary output missing %q: %s", want, output) + } + } + if strings.Contains(output, "calendar.event:create") || strings.Contains(output, `"items"`) { + t.Fatalf("summary output leaked raw item details: %s", output) + } +} + +func TestHandleToolResult_explicitJSONKeepsRawPayload(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "json", "") + cmd := &cobra.Command{Use: "chmod"} + root.AddCommand(cmd) + if err := root.PersistentFlags().Set("format", "json"); err != nil { + t.Fatalf("set format error = %v", err) + } + text := `{"success":true,"code":"OK","data":{"items":[{"scope":"calendar.event:create"}],"selectedScopes":["calendar.event:create"]}}` + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} + + output, err := captureStdout(t, func() error { + return handleToolResult(cmd, &sequenceToolCaller{dryRun: true}, result) + }) + if err != nil { + t.Fatalf("handleToolResult error = %v", err) + } + if !strings.Contains(output, `"items"`) || !strings.Contains(output, "calendar.event:create") { + t.Fatalf("explicit json output did not preserve raw payload: %s", output) + } + if strings.Contains(output, "PAT authorization") { + t.Fatalf("explicit json output must not be summarized: %s", output) + } +} + +func TestHandleToolResult_verboseKeepsRawPayload(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("verbose", false, "") + cmd := &cobra.Command{Use: "chmod"} + root.AddCommand(cmd) + if err := root.PersistentFlags().Set("verbose", "true"); err != nil { + t.Fatalf("set verbose error = %v", err) + } + text := `{"success":true,"code":"OK","data":{"items":[{"scope":"calendar.event:create"}]}}` + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} + + output, err := captureStdout(t, func() error { + return handleToolResult(cmd, &sequenceToolCaller{}, result) + }) + if err != nil { + t.Fatalf("handleToolResult error = %v", err) + } + if !strings.Contains(output, `"items"`) || !strings.Contains(output, "calendar.event:create") { + t.Fatalf("verbose output did not preserve raw payload: %s", output) + } +} + // TestChmod_agentCode_env_invalid verifies that a malformed // DINGTALK_DWS_AGENTCODE value (whitespace, shell metacharacters) is // rejected by the regex gate in validateAgentCode before any MCP call @@ -519,17 +959,21 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { t.Fatalf("chmod RunE error = %v", err) } - if got := fake.gotArgs["agentCode"]; got != "flagval" { - t.Fatalf("agentCode in argv = %v, want %q (flag must win over env)", got, "flagval") + if fake.gotTool != patBatchGrantToolName { + t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) + } + if got := fake.gotAgentEnv; got != "flagval" { + t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval") + } + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs) } } // TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after // the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the -// legacy env MUST NOT satisfy the --agentCode requirement. The command -// is expected to fail with an error that explicitly names the canonical -// DINGTALK_DWS_AGENTCODE env, and MUST NOT mention DWS_AGENTCODE as a -// usable fallback. No MCP call is permitted. +// legacy env MUST NOT be consumed. The command is still allowed to run, +// omits agentCode, and lets lippi-pat-core write its default agentCode. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") t.Setenv("DWS_AGENTCODE", "legacyval") @@ -538,23 +982,17 @@ func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { cmd := buildChmod(t, fake) _ = cmd.Flags().Set("grant-type", "once") - err := cmd.RunE(cmd, []string{"aitable.record:read"}) - if err == nil { - t.Fatalf("expected hard error when only legacy DWS_AGENTCODE is set, got nil") + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) } - if !strings.Contains(err.Error(), "DINGTALK_DWS_AGENTCODE") { - t.Fatalf("error = %q, want to name canonical DINGTALK_DWS_AGENTCODE env", err.Error()) + if fake.callN != 1 { + t.Fatalf("CallTool was invoked %d times, want 1", fake.callN) } - // Defensive: the canonical env naturally contains the substring - // "DWS_AGENTCODE" as part of "DINGTALK_DWS_AGENTCODE"; the above - // assertion plus the absence check below precisely guard against - // advertising the legacy alias as usable. - hint := strings.ReplaceAll(err.Error(), "DINGTALK_DWS_AGENTCODE", "") - if strings.Contains(hint, "DWS_AGENTCODE") { - t.Fatalf("error = %q must not advertise DWS_AGENTCODE as usable", err.Error()) + if got := fake.gotAgentEnv; got != "" { + t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got) } - if fake.callN != 0 { - t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy --agentCode", fake.callN) + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs) } } diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 0d918c95..3d17b11c 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -34,8 +34,8 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { dws pat browser-policy 配置 PAT 浏览器打开策略 能力说明: - --format 只控制 PAT 撞墙时的输出形态;当 --format json 时, - CLI 只返回结构化 JSON,不混入非结构化文本。 + pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, + 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 diff --git a/pkg/config/constants.go b/pkg/config/constants.go index ed668afa..c986e45a 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -161,6 +161,10 @@ const ( // Shared across auth, errors, and device-flow packages. const ( + // DefaultMCPBaseURL is the DingTalk MCP base URL. + // Override at runtime via ~/.dws/mcp_url file. + DefaultMCPBaseURL = "https://mcp.dingtalk.com" + // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" @@ -183,6 +187,19 @@ func DefaultConfigDir() string { return filepath.Join(homeDir, ".dws") } +// GetMCPBaseURL returns the MCP base URL with priority: +// 1. ~/.dws/mcp_url file content (for custom environment) +// 2. Default value (https://mcp.dingtalk.com) +func GetMCPBaseURL() string { + mcpURLPath := filepath.Join(DefaultConfigDir(), "mcp_url") + if data, err := os.ReadFile(mcpURLPath); err == nil { + if u := strings.TrimSpace(string(data)); u != "" { + return u + } + } + return DefaultMCPBaseURL +} + // GetTerminalBaseURL returns the terminal base URL with priority: // 1. ~/.dws/terminal_url file content (for pre-release environment) // 2. Default value (https://open-dev.dingtalk.com) diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index 6115d204..4cb538d5 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "testing" "time" ) @@ -192,6 +194,17 @@ func TestDefaultFetchServersLimit(t *testing.T) { } } +func TestGetMCPBaseURLUsesConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if got := GetMCPBaseURL(); got != "https://custom-mcp.example.com" { + t.Fatalf("GetMCPBaseURL() = %q, want configured URL", got) + } +} + func TestMaxUploadFileSize(t *testing.T) { t.Parallel() var want int64 = 100 * 1024 * 1024