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 @@
-
\ No newline at end of file
+
\ 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