From dd42fd833b41a53f36ed09b74624cf7c1117a987 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 29 May 2026 15:22:00 +0800 Subject: [PATCH 01/23] feat(pat): support batch chmod flows --- internal/app/runner.go | 12 +- internal/pat/chmod.go | 239 ++++++++++++++++++++++++++++++++++++- internal/pat/chmod_test.go | 210 ++++++++++++++++++++++++++++++-- 3 files changed, 444 insertions(+), 17 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index f456e255..4cfcb304 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 { @@ -664,11 +667,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/pat/chmod.go b/internal/pat/chmod.go index 0653a7c3..7bd19f0a 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -139,6 +139,12 @@ 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 = "个人授权" @@ -155,6 +161,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,10 +179,18 @@ grantType 规则: once 一次性,执行一次后自动失效 session 当前会话有效(默认),需要 --session-id permanent 永久有效`, - Args: cobra.MinimumNArgs(1), + 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 --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`, + dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --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) @@ -178,6 +198,8 @@ grantType 规则: 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") @@ -190,9 +212,17 @@ grantType 规则: } if c != nil && c.DryRun() { + if usesPlan { + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, true) + result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) + if err != nil { + return fmt.Errorf("pat chmod plan failed: %w", err) + } + return handleToolResult(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", "Tool:", patBatchGrantToolName) fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) fmt.Printf("%-16s%v\n", "Scope:", scopes) fmt.Printf("%-16s%s\n", "GrantType:", grantType) @@ -209,6 +239,24 @@ grantType 规则: if sessionID == "" { sessionID = resolveSessionIDFromEnv() } + if usesPlan { + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, 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(planResult) + } + } + batchArgs := map[string]any{ + "scopes": scopes, + "grantType": grantType, + } toolArgs := map[string]any{ "agentCode": agentCode, "scopes": scopes, @@ -230,7 +278,15 @@ 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) } @@ -247,10 +303,185 @@ grantType 规则: "Agent 唯一标识(必填;亦可通过 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("DWS_SESSION_ID", 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, dryRun bool) map[string]any { + return map[string]any{ + "scopes": scopes, + "productCodes": productCodes, + "recommend": recommend, + "grantType": grantType, + "dryRun": dryRun, + } +} + +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 && code == "PAT_BATCH_AUTH_UNSUPPORTED" { + return true + } + } + return false +} + +func isPATBatchUnsupportedError(err error) bool { + return err != nil && strings.Contains(normalizedPATErrorText(err), strings.ToLower("PAT_BATCH_AUTH_UNSUPPORTED")) +} + // 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 diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index fc60294b..def3b09f 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,14 @@ 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 + callN int + resultOK bool } func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { @@ -43,6 +47,8 @@ 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("DWS_SESSION_ID") // 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 +193,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 +228,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 +255,133 @@ 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_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 +401,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"}) @@ -288,8 +468,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) } } @@ -519,8 +699,14 @@ 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) } } From 202cb509a044a5c60ca3086e2329c97acc33161e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 29 May 2026 16:48:09 +0800 Subject: [PATCH 02/23] feat: use server default agentCode for chmod --- internal/app/doctor_command.go | 7 ++--- internal/app/legacy.go | 8 ++---- internal/auth/endpoints.go | 10 ++----- internal/cli/loader.go | 4 +-- internal/pat/chmod.go | 38 +++++++++++++------------ internal/pat/chmod_test.go | 52 ++++++++++++++++++++++------------ pkg/config/constants.go | 17 +++++++++++ pkg/config/constants_test.go | 13 +++++++++ 8 files changed, 93 insertions(+), 56 deletions(-) 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/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/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/pat/chmod.go b/internal/pat/chmod.go index 7bd19f0a..72595cb9 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -54,12 +54,12 @@ func resolveSessionIDFromEnv() string { // 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 @@ -186,14 +186,14 @@ grantType 规则: } return cobra.MinimumNArgs(1)(cmd, args) }, - 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 + 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 } @@ -208,7 +208,7 @@ 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 ") + 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() { @@ -223,7 +223,11 @@ grantType 规则: bold := color.New(color.FgYellow, color.Bold) bold.Println("[DRY-RUN] Preview only, not executed:") fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName) - fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) + 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 != "" { @@ -258,10 +262,12 @@ grantType 规则: "grantType": grantType, } toolArgs := map[string]any{ - "agentCode": agentCode, "scopes": scopes, "grantType": grantType, } + if agentCode != "" { + toolArgs["agentCode"] = agentCode + } if sessionID != "" { toolArgs["sessionId"] = sessionID } @@ -295,12 +301,8 @@ grantType 规则: }, } - // --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 等价") diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index def3b09f..8dac7caa 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -418,6 +418,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{ @@ -712,10 +736,8 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { // 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") @@ -724,23 +746,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/pkg/config/constants.go b/pkg/config/constants.go index ed668afa..3f7c1b0c 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 pre-release 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..55582235 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://pre-mcp.dingtalk.com\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if got := GetMCPBaseURL(); got != "https://pre-mcp.dingtalk.com" { + t.Fatalf("GetMCPBaseURL() = %q, want prepub URL", got) + } +} + func TestMaxUploadFileSize(t *testing.T) { t.Parallel() var want int64 = 100 * 1024 * 1024 From 3f09eb5c0f37829216901da88b271478a94cdb3d Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 1 Jun 2026 20:50:15 +0800 Subject: [PATCH 03/23] feat: surface tool auth metadata --- internal/cli/canonical.go | 3 +++ internal/cli/canonical_test.go | 13 +++++++++ internal/ir/catalog.go | 47 +++++++++++++++++++++++++++++++- internal/ir/catalog_test.go | 49 ++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 86b23282..c8d7e947 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -785,6 +785,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/ir/catalog.go b/internal/ir/catalog.go index 1ee76716..ca5eba96 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), Annotations: deriveAnnotations(sensitive), Hidden: toolHidden[tool.Name], FlagHints: cloneFlagHints(toolFlagHints[tool.Name]), @@ -303,6 +325,29 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { return Catalog{Products: products} } +func extractToolAuthMetadata(schema map[string]any) *ToolAuthMetadata { + raw := any(nil) + if len(schema) > 0 { + raw = schema["x-dingtalk-auth"] + } + if raw == nil { + return nil + } + data, err := json.Marshal(raw) + if err != nil { + return nil + } + var metadata ToolAuthMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil + } + if len(metadata.RequiredScopes) == 0 && len(metadata.RequiredPermissions) == 0 && + len(metadata.ClientObservedScopes) == 0 && metadata.AuthMetaHash == "" { + return nil + } + return &metadata +} + 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..6f35d151 100644 --- a/internal/ir/catalog_test.go +++ b/internal/ir/catalog_test.go @@ -216,6 +216,55 @@ 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 TestBuildCatalogCarriesToolOverrideGroupAndFlagOverlay(t *testing.T) { t.Parallel() From a7879edca174b8536d7ae75510db99eba59003f3 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 1 Jun 2026 21:02:15 +0800 Subject: [PATCH 04/23] feat: infer product grant auth metadata --- internal/ir/catalog.go | 35 +++++++++++++++++++++++++++++--- internal/ir/catalog_test.go | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/internal/ir/catalog.go b/internal/ir/catalog.go index ca5eba96..5b9369b4 100644 --- a/internal/ir/catalog.go +++ b/internal/ir/catalog.go @@ -265,7 +265,7 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { InputSchema: inputSchema, OutputSchema: cloneMap(tool.OutputSchema), Sensitive: sensitive, - Auth: extractToolAuthMetadata(inputSchema), + Auth: extractToolAuthMetadata(inputSchema, productID), Annotations: deriveAnnotations(sensitive), Hidden: toolHidden[tool.Name], FlagHints: cloneFlagHints(toolFlagHints[tool.Name]), @@ -325,13 +325,13 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { return Catalog{Products: products} } -func extractToolAuthMetadata(schema map[string]any) *ToolAuthMetadata { +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 nil + return productGrantAuthMetadata(productID) } data, err := json.Marshal(raw) if err != nil { @@ -348,6 +348,35 @@ func extractToolAuthMetadata(schema map[string]any) *ToolAuthMetadata { return &metadata } +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 6f35d151..79d42d26 100644 --- a/internal/ir/catalog_test.go +++ b/internal/ir/catalog_test.go @@ -265,6 +265,46 @@ func TestBuildCatalogCarriesDingTalkAuthMetadata(t *testing.T) { } } +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() From 73e2de7fe8b4c0bd523cc128aa3f9d51258fe242 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Jun 2026 14:18:49 +0000 Subject: [PATCH 05/23] chore: update coverage badge [skip ci] --- .github/badges/coverage.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index b052c53a..1c4871c4 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.7%coverage54.7% \ No newline at end of file From 9eb3099881653c70e1406d886c0ba4d6fd6ee0da Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 2 Jun 2026 09:35:47 +0800 Subject: [PATCH 06/23] feat(pat): summarize chmod output by default --- internal/pat/chmod.go | 152 ++++++++++++++++++++++++++++++++++++- internal/pat/chmod_test.go | 96 ++++++++++++++++++++++- internal/pat/pat.go | 4 +- 3 files changed, 245 insertions(+), 7 deletions(-) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 72595cb9..7819aac6 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -218,7 +218,7 @@ grantType 规则: if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) } - return handleToolResult(result) + return handleToolResult(cmd, c, result) } bold := color.New(color.FgYellow, color.Bold) bold.Println("[DRY-RUN] Preview only, not executed:") @@ -254,7 +254,7 @@ grantType 规则: return err } if len(scopes) == 0 { - return handleToolResult(planResult) + return handleToolResult(cmd, c, planResult) } } batchArgs := map[string]any{ @@ -297,7 +297,7 @@ grantType 规则: return fmt.Errorf("pat chmod failed: %w", err) } - return handleToolResult(result) + return handleToolResult(cmd, c, result) }, } @@ -639,7 +639,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") } @@ -650,9 +650,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 8dac7caa..8699857c 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -672,7 +672,7 @@ func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) { } 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") } @@ -681,6 +681,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 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;不传则写入全局默认策略。 From 4bbd52fc5870dbee9f2a48c737cf053dd92f9ab1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 01:38:39 +0000 Subject: [PATCH 07/23] chore: update coverage badge [skip ci] --- .github/badges/coverage.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 1c4871c4..ed40a879 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1 +1 @@ -coverage: 54.7%coverage54.7% \ No newline at end of file +coverage: 54.9%coverage54.9% \ No newline at end of file From 3ce64db2fc71d3a62cfb6fa91a70c4fe9bed57fc Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 2 Jun 2026 14:36:59 +0800 Subject: [PATCH 08/23] fix(pat): preserve batch session metadata --- internal/ir/catalog.go | 22 ++++++++++++++++-- internal/ir/catalog_test.go | 45 +++++++++++++++++++++++++++++++++++++ internal/pat/chmod.go | 13 +++++++---- internal/pat/chmod_test.go | 31 +++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/internal/ir/catalog.go b/internal/ir/catalog.go index 5b9369b4..6379a234 100644 --- a/internal/ir/catalog.go +++ b/internal/ir/catalog.go @@ -341,13 +341,31 @@ func extractToolAuthMetadata(schema map[string]any, productID string) *ToolAuthM if err := json.Unmarshal(data, &metadata); err != nil { return nil } - if len(metadata.RequiredScopes) == 0 && len(metadata.RequiredPermissions) == 0 && - len(metadata.ClientObservedScopes) == 0 && metadata.AuthMetaHash == "" { + 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 == "" { diff --git a/internal/ir/catalog_test.go b/internal/ir/catalog_test.go index 79d42d26..eccaaa2d 100644 --- a/internal/ir/catalog_test.go +++ b/internal/ir/catalog_test.go @@ -265,6 +265,51 @@ func TestBuildCatalogCarriesDingTalkAuthMetadata(t *testing.T) { } } +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() diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 7819aac6..6cce6c0f 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -213,7 +213,7 @@ grantType 规则: if c != nil && c.DryRun() { if usesPlan { - planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, true) + 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) @@ -244,7 +244,7 @@ grantType 规则: sessionID = resolveSessionIDFromEnv() } if usesPlan { - planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, true) + 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) @@ -269,6 +269,7 @@ grantType 规则: toolArgs["agentCode"] = agentCode } if sessionID != "" { + batchArgs["sessionId"] = sessionID toolArgs["sessionId"] = sessionID } // Legacy server schema accepted singular "scope"; clone the @@ -405,14 +406,18 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess }) } -func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, dryRun bool) map[string]any { - return map[string]any{ +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) { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 8699857c..326e48cb 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -314,6 +314,37 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } +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_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ From e653616aa3901d30d5d7ab11962e3da04acd10cd Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 2 Jun 2026 14:56:23 +0800 Subject: [PATCH 09/23] fix(pat): align dry-run session and schema docs --- README.md | 4 ++++ docs/reference.md | 2 ++ internal/cli/canonical.go | 5 +++-- internal/pat/chmod.go | 8 ++++---- internal/pat/chmod_test.go | 27 +++++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) 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/cli/canonical.go b/internal/cli/canonical.go index c8d7e947..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), diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 6cce6c0f..10b29043 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -202,12 +202,15 @@ grantType 规则: 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() == "" { + 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 ") } @@ -240,9 +243,6 @@ 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) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 326e48cb..ebf70a7d 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -345,6 +345,33 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { } } +func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + t.Setenv("DWS_SESSION_ID", "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 TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ From dd1c4e34fd52e17f14a3cd0a2d08666ac3c0d25f Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 2 Jun 2026 16:01:38 +0800 Subject: [PATCH 10/23] chore(config): use public example MCP override URL --- pkg/config/constants.go | 2 +- pkg/config/constants_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/config/constants.go b/pkg/config/constants.go index 3f7c1b0c..c986e45a 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -188,7 +188,7 @@ func DefaultConfigDir() string { } // GetMCPBaseURL returns the MCP base URL with priority: -// 1. ~/.dws/mcp_url file content (for pre-release environment) +// 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") diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index 55582235..4cb538d5 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -197,11 +197,11 @@ 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://pre-mcp.dingtalk.com\n"), FilePerm); err != nil { + 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://pre-mcp.dingtalk.com" { - t.Fatalf("GetMCPBaseURL() = %q, want prepub URL", got) + if got := GetMCPBaseURL(); got != "https://custom-mcp.example.com" { + t.Fatalf("GetMCPBaseURL() = %q, want configured URL", got) } } From 7652bda320d54ac59a558b297a8a1f4dfaa90bb1 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 2 Jun 2026 20:38:38 +0800 Subject: [PATCH 11/23] fix(pat): align chmod session env handling --- CHANGELOG.md | 4 ++ internal/app/runner_test.go | 24 +++++++++ internal/pat/chmod.go | 44 +++++++++------ internal/pat/chmod_test.go | 104 ++++++++++++++++++++++++++++++++---- 4 files changed, 150 insertions(+), 26 deletions(-) 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/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/pat/chmod.go b/internal/pat/chmod.go index 10b29043..b5ae49f3 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -29,25 +29,33 @@ 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 @@ -148,6 +156,9 @@ const ( // 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{ @@ -350,7 +361,8 @@ func withPATContextEnv(agentCode, sessionID string, fn func() (*edition.ToolResu _ = os.Setenv(key, value) } setEnv(agentCodeEnv, agentCode) - setEnv("DWS_SESSION_ID", sessionID) + setEnv(sessionIDEnvDingtalk, sessionID) + setEnv(sessionIDEnvDWS, sessionID) defer func() { for key, old := range restore { if old == nil { @@ -478,7 +490,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return false } for _, key := range []string{"code", "errorCode", "error_code"} { - if code, ok := body[key].(string); ok && code == "PAT_BATCH_AUTH_UNSUPPORTED" { + if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) { return true } } @@ -486,7 +498,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { } func isPATBatchUnsupportedError(err error) bool { - return err != nil && strings.Contains(normalizedPATErrorText(err), strings.ToLower("PAT_BATCH_AUTH_UNSUPPORTED")) + return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower) } // callPATToolWithLegacyFallback invokes the canonical PAT grant tool first, diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index ebf70a7d..d3af564f 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -32,14 +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 - gotAgentEnv string - gotSessionEnv string - 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) { @@ -48,7 +49,8 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, f.callN++ f.gotTool = toolName f.gotAgentEnv = os.Getenv(agentCodeEnv) - f.gotSessionEnv = os.Getenv("DWS_SESSION_ID") + 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 { @@ -347,7 +349,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") - t.Setenv("DWS_SESSION_ID", "env-session-123") + t.Setenv(sessionIDEnvDWS, "env-session-123") fake := &sequenceToolCaller{ dryRun: true, responses: []string{ @@ -372,6 +374,69 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { } } +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{ @@ -729,6 +794,25 @@ 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(nil, nil, &edition.ToolResult{}) if err == nil { From fc0873b0c65c3046846488a00339ea7e0651b98e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 8 Jun 2026 15:36:57 +0800 Subject: [PATCH 12/23] docs,test(pat): carry chmod batch auth updates --- internal/pat/chmod.go | 8 +++++++- internal/pat/chmod_test.go | 42 +++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index b5ae49f3..fcfda1e2 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -189,7 +189,12 @@ scope 格式: .: grantType 规则: once 一次性,执行一次后自动失效 session 当前会话有效(默认),需要 --session-id - permanent 永久有效`, + permanent 永久有效 + +批量授权: + 产品线批量授权推荐使用 --recommend --product 。 + --products / --domain / --domains 保持兼容;裸 --recommend 保持可用, + 但会按推荐集合规划,可能跨产品。`, Args: func(cmd *cobra.Command, args []string) error { productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) if len(args) > 0 || recommend || len(productCodes) > 0 { @@ -200,6 +205,7 @@ grantType 规则: 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 --recommend --product minutes --grant-type permanent --dry-run 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 { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index d3af564f..2905405c 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -68,8 +68,9 @@ func (f *fakeToolCaller) Format() string { return "json" } func (f *fakeToolCaller) DryRun() bool { return f.dryRun } type recordedToolCall struct { - tool string - args map[string]any + tool string + args map[string]any + agentEnv string } type fallbackToolCaller struct { @@ -206,7 +207,11 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri for k, v := range args { copied[k] = v } - s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied}) + s.calls = append(s.calls, recordedToolCall{ + tool: toolName, + args: copied, + agentEnv: os.Getenv(agentCodeEnv), + }) response := `{"success":true,"data":{}}` if len(s.responses) >= len(s.calls) { response = s.responses[len(s.calls)-1] @@ -305,14 +310,23 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[0].args["recommend"]; got != false { t.Fatalf("recommend = %#v, want false", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if _, ok := fake.calls[0].args["agentCode"]; ok { + t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + } 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 got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } if _, ok := fake.calls[1].args["agentCode"]; ok { - t.Fatalf("batch grant args must not contain agentCode: %#v", fake.calls[1].args) + t.Fatalf("batch grant args must not carry agentCode identity field: %#v", fake.calls[1].args) } } @@ -339,12 +353,24 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { if got := fake.calls[0].args["sessionId"]; got != "session-123" { t.Fatalf("plan sessionId = %#v, want session-123", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if _, ok := fake.calls[0].args["agentCode"]; ok { + t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + } 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) } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("batch grant args must not carry agentCode identity field: %#v", fake.calls[1].args) + } } func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { @@ -372,6 +398,12 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { if got := fake.calls[0].args["sessionId"]; got != "env-session-123" { t.Fatalf("plan sessionId = %#v, want env-session-123", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if _, ok := fake.calls[0].args["agentCode"]; ok { + t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + } } func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { @@ -511,7 +543,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) { // TestChmod_agentCode_env_fallback verifies that when --agentCode is // omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks -// the env value up and forwards it verbatim in the MCP argv. +// the env value up and forwards it through the MCP call environment. func TestChmod_agentCode_env_fallback(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") From 4c86a9f8e172b33a7fa0a742c189a3dccbeeead4 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 09:38:45 +0800 Subject: [PATCH 13/23] fix(pat): carry agentCode in batch auth args --- internal/app/runner.go | 5 +- internal/app/runner_test.go | 12 ++ internal/auth/channel.go | 39 +++- internal/pat/chmod.go | 187 ++++++++++++---- internal/pat/chmod_test.go | 273 ++++++++++++++++++++---- internal/pat/pat.go | 5 +- test/unit/pat_host_owned_signal_test.go | 36 +++- 7 files changed, 461 insertions(+), 96 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index eb026b15..bc4b48dd 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() { hostOwnedPATDecisionOnce.Do(func() { slog.Debug("runtime.host_owned_pat", "hostOwned", authpkg.HostOwnsPATFlow(), - "agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "", + "agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(), ) }) } @@ -687,9 +687,10 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } + agentCode, _ := authpkg.AgentCodeFromEnv() envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), - "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)), + "x-dingtalk-dws-agent-code": agentCode, "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), "x-dingtalk-session-id": sessionID, "x-dingtalk-message-id": os.Getenv(envDingtalkMessageID), diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index fd3fd673..0803ff79 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -321,6 +321,7 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) { func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(authpkg.AgentCodeEnv, " cursor ") + t.Setenv(authpkg.AgentCodeEnvCompat, "") headers := resolveIdentityHeaders() if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" { @@ -328,6 +329,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } +func TestResolveIdentityHeadersForwardsCompatAgentCode(t *testing.T) { + setupRuntimeCommandTest(t) + t.Setenv(authpkg.AgentCodeEnv, "") + t.Setenv(authpkg.AgentCodeEnvCompat, " compat ") + + headers := resolveIdentityHeaders() + if got := headers["x-dingtalk-dws-agent-code"]; got != "compat" { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want compat", got) + } +} + func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(envDingtalkSessionID, "ding-session") diff --git a/internal/auth/channel.go b/internal/auth/channel.go index e9c1f49d..38c3753c 100644 --- a/internal/auth/channel.go +++ b/internal/auth/channel.go @@ -19,19 +19,40 @@ import ( ) const ( - // AgentCodeEnv is the sole per-spawn environment variable the host injects - // to declare "this process is driven by a third-party Agent host, render - // authorization UI yourselves". + // AgentCodeEnv is the primary per-spawn environment variable the host + // injects to declare "this process is driven by a third-party Agent host, + // render authorization UI yourselves". AgentCodeEnv = "DINGTALK_DWS_AGENTCODE" + + // AgentCodeEnvCompat is a compatibility alias for hosts that shipped the + // reversed prefix before AgentCodeEnv became the public spelling. + AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE" ) +// AgentCodeFromEnv returns the effective host agent code and the env name that +// supplied it. The primary public spelling wins over the compatibility alias. +func AgentCodeFromEnv() (string, string) { + if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" { + return value, AgentCodeEnv + } + if value := strings.TrimSpace(os.Getenv(AgentCodeEnvCompat)); value != "" { + return value, AgentCodeEnvCompat + } + return "", "" +} + +func AgentCodeEnvPresent() bool { + value, _ := AgentCodeFromEnv() + return value != "" +} + // HostOwnsPATFlow reports whether the current process is running under a // third-party Agent host that will render the PAT authorization card -// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being -// non-empty. The CLI deliberately does not consult any other signal -// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this -// decision so that server-side routing tags and the host-owned UI contract -// remain independent concerns. +// itself. The trigger is the effective agent-code env (DINGTALK_DWS_AGENTCODE +// or DWS_DINGTALK_AGENTCODE) being non-empty. The CLI deliberately does not +// consult any other signal (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type +// header) for this decision so that server-side routing tags and the host-owned +// UI contract remain independent concerns. func HostOwnsPATFlow() bool { - return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != "" + return AgentCodeEnvPresent() } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index fcfda1e2..88c219cf 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -25,6 +25,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -58,23 +59,25 @@ func resolveSessionIDFromEnv() string { return "" } -// agentCodeEnv is the canonical (and only) environment variable name -// used as a per-shell fallback for the --agentCode flag on `dws pat *` -// commands. +// agentCodeEnv is the canonical environment variable name used as a +// per-shell fallback for the --agentCode flag on `dws pat *` commands. // // 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. +// remain deterministic. When neither flag nor env is set, `pat chmod` fails +// locally instead of letting the server reject the request later. Batch PAT +// tools receive the resolved agentCode in arguments, while the CLI also keeps +// exporting it through env for older gateway paths. // -// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE -// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was -// hard-removed once the public integration surface landed on -// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a -// silent fallback. -const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" +// Namespace note: DWS_DINGTALK_AGENTCODE is kept as a compatibility alias for +// hosts that shipped the reversed prefix early. DWS_AGENTCODE / +// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed. +const ( + agentCodeEnv = authpkg.AgentCodeEnv + agentCodeEnvCompat = authpkg.AgentCodeEnvCompat +) // agentCodePattern is the validation regex for any --agentCode value // resolved from either the flag or the agent-code env var. It matches @@ -84,16 +87,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" // argument. var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) -// resolveAgentCodeFromEnv returns the fallback agent code from the -// canonical DINGTALK_DWS_AGENTCODE env var. The second return value -// reports the env name that was consumed (for error attribution); it -// is "" when the env is unset or blank. No legacy aliases are honored. +// resolveAgentCodeFromEnv returns the fallback agent code from the canonical +// DINGTALK_DWS_AGENTCODE env var, then the DWS_DINGTALK_AGENTCODE compatibility +// alias. The second return value reports the env name that was consumed (for +// error attribution); it is "" when both env vars are unset or blank. func resolveAgentCodeFromEnv() (string, string) { - primary := strings.TrimSpace(os.Getenv(agentCodeEnv)) - if primary != "" { - return primary, agentCodeEnv - } - return "", "" + return authpkg.AgentCodeFromEnv() } // validateAgentCode rejects agent codes that would be ambiguous or unsafe @@ -116,7 +115,8 @@ func validateAgentCode(code string) error { // // 1. explicit --agentCode flag value (highest priority; wins over env) // 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback) -// 3. empty ("") when required=false; typed error when required=true. +// 3. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) +// 4. empty ("") when required=false; typed error when required=true. // // Any non-empty resolved value is validated via validateAgentCode, so // callers never have to re-validate. @@ -128,17 +128,30 @@ func resolveAgentCode(flagVal string, required bool) (string, error) { } if code == "" { if required { - return "", fmt.Errorf( - "flag --agentCode is required (or set env %s)\n hint: dws pat chmod ... --agentCode \n hint: export %s=", - agentCodeEnv, agentCodeEnv) + return "", apperrors.NewValidation( + fmt.Sprintf("flag --agentCode is required (or set env %s or %s)", agentCodeEnv, agentCodeEnvCompat), + apperrors.WithReason("missing_agent_code"), + apperrors.WithHint(fmt.Sprintf("dws pat chmod ... --agentCode \n or: export %s=", agentCodeEnv)), + apperrors.WithActions( + "dws pat chmod ... --agentCode ", + fmt.Sprintf("export %s=", agentCodeEnv), + fmt.Sprintf("export %s=", agentCodeEnvCompat), + ), + ) } return "", nil } if err := validateAgentCode(code); err != nil { if envSource != "" { - return "", fmt.Errorf("%s env: %w", envSource, err) + return "", apperrors.NewValidation( + fmt.Sprintf("%s env: %v", envSource, err), + apperrors.WithReason("invalid_agent_code"), + ) } - return "", err + return "", apperrors.NewValidation( + err.Error(), + apperrors.WithReason("invalid_agent_code"), + ) } return code, nil } @@ -159,8 +172,25 @@ const ( patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" ) +var patBatchMetadataContractCodes = map[string]bool{ + "pat_batch_auth_metadata_required": true, + "pat_batch_scope_not_declared": true, + "pat_batch_product_not_declared": true, +} + +var patBatchIdentityArgumentKeys = map[string]bool{ + "agentCode": true, + "sessionId": true, + "orgId": true, + "uid": true, + "source": true, + "caller": true, +} + var validGrantTypes = map[string]bool{ "once": true, "session": true, @@ -210,7 +240,7 @@ grantType 规则: 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, false) + agentCode, err := resolveAgentCode(flagVal, true) if err != nil { return err } @@ -233,7 +263,7 @@ grantType 规则: if c != nil && c.DryRun() { if usesPlan { - planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true) + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true) result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) @@ -245,8 +275,6 @@ grantType 规则: 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) @@ -261,7 +289,7 @@ grantType 规则: } if usesPlan { - planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true) + planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true) planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) @@ -283,6 +311,7 @@ grantType 规则: "grantType": grantType, } if agentCode != "" { + batchArgs["agentCode"] = agentCode toolArgs["agentCode"] = agentCode } if sessionID != "" { @@ -320,7 +349,7 @@ grantType 规则: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)") + "Agent 唯一标识(必填;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;会进入 batch 参数并同步注入兼容 env)") chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价") @@ -393,13 +422,11 @@ func callPATBatchGrantWithLegacyFallback( 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) { + result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs) + if err == nil && !isPATBatchFallbackResult(result) { return result, nil } - if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) { + if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) { return nil, err } return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { @@ -419,12 +446,23 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess if c == nil { return nil, fmt.Errorf("internal error: tool runtime not initialized") } + return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args) +} + +func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) { + result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return c.CallTool(ctx, "pat", toolName, args) + }) + if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) { + return result, err + } + compatArgs := cloneWithoutPATIdentityArgs(args) return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { - return c.CallTool(ctx, "pat", patBatchPlanToolName, args) + return c.CallTool(ctx, "pat", toolName, compatArgs) }) } -func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, sessionID string, dryRun bool) map[string]any { +func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any { args := map[string]any{ "scopes": scopes, "productCodes": productCodes, @@ -432,6 +470,9 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, "grantType": grantType, "dryRun": dryRun, } + if agentCode != "" { + args["agentCode"] = agentCode + } if sessionID != "" { args["sessionId"] = sessionID } @@ -487,6 +528,25 @@ func firstToolResultText(result *edition.ToolResult) string { } func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patBatchUnsupportedCode) + }) +} + +func isPATBatchFallbackResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + normalized := strings.ToLower(strings.TrimSpace(code)) + return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized] + }) +} + +func isPATForgedIdentityResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patForgedIdentityCode) + }) +} + +func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool { text := firstToolResultText(result) if text == "" { return false @@ -496,7 +556,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return false } for _, key := range []string{"code", "errorCode", "error_code"} { - if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) { + if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) { return true } } @@ -507,6 +567,53 @@ func isPATBatchUnsupportedError(err error) bool { return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower) } +func isPATBatchFallbackError(err error) bool { + if isPATBatchUnsupportedError(err) { + return true + } + text := normalizedPATErrorText(err) + for code := range patBatchMetadataContractCodes { + if strings.Contains(text, code) { + return true + } + } + return false +} + +func isPATForgedIdentityError(err error) bool { + return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower) +} + +func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool { + if !hasPATIdentityArgs(args) { + return false + } + if err != nil { + return isPATForgedIdentityError(err) + } + return isPATForgedIdentityResult(result) +} + +func hasPATIdentityArgs(args map[string]any) bool { + for key := range args { + if patBatchIdentityArgumentKeys[key] { + return true + } + } + return false +} + +func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any { + out := make(map[string]any, len(args)) + for key, value := range args { + if patBatchIdentityArgumentKeys[key] { + continue + } + out[key] = value + } + return out +} + // 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 diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 2905405c..d7441cd4 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -68,9 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" } func (f *fakeToolCaller) DryRun() bool { return f.dryRun } type recordedToolCall struct { - tool string - args map[string]any - agentEnv string + tool string + args map[string]any + agentEnv string + sessionEnv string + dingSessionEnv string } type fallbackToolCaller struct { @@ -199,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false } type sequenceToolCaller struct { calls []recordedToolCall responses []string + errs []error dryRun bool } @@ -207,11 +210,13 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri for k, v := range args { copied[k] = v } - s.calls = append(s.calls, recordedToolCall{ - tool: toolName, - args: copied, - agentEnv: os.Getenv(agentCodeEnv), - }) + s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied}) + s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv) + s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS) + s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk) + if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil { + return nil, s.errs[len(s.calls)-1] + } response := `{"success":true,"data":{}}` if len(s.responses) >= len(s.calls) { response = s.responses[len(s.calls)-1] @@ -313,8 +318,8 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[0].agentEnv; got != "qoderwork" { t.Fatalf("plan agent env = %q, want qoderwork", got) } - if _, ok := fake.calls[0].args["agentCode"]; ok { - t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("batch plan agentCode = %#v, want qoderwork", got) } if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) @@ -325,12 +330,12 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[1].agentEnv; got != "qoderwork" { t.Fatalf("grant agent env = %q, want qoderwork", got) } - if _, ok := fake.calls[1].args["agentCode"]; ok { - t.Fatalf("batch grant args must not carry agentCode identity field: %#v", fake.calls[1].args) + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("batch grant agentCode = %#v, want qoderwork", got) } } -func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { +func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, @@ -350,26 +355,32 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { if got := fake.calls[0].args["grantType"]; got != "session" { t.Fatalf("plan grantType = %#v, want session", got) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "session-123" { t.Fatalf("plan sessionId = %#v, want session-123", got) } if got := fake.calls[0].agentEnv; got != "qoderwork" { t.Fatalf("plan agent env = %q, want qoderwork", got) } - if _, ok := fake.calls[0].args["agentCode"]; ok { - t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + if fake.calls[0].dingSessionEnv != "session-123" { + t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) } if got := fake.calls[1].args["grantType"]; got != "session" { t.Fatalf("grant grantType = %#v, want session", got) } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("grant agentCode = %#v, want qoderwork", got) + } if got := fake.calls[1].args["sessionId"]; got != "session-123" { t.Fatalf("grant sessionId = %#v, want session-123", got) } if got := fake.calls[1].agentEnv; got != "qoderwork" { t.Fatalf("grant agent env = %q, want qoderwork", got) } - if _, ok := fake.calls[1].args["agentCode"]; ok { - t.Fatalf("batch grant args must not carry agentCode identity field: %#v", fake.calls[1].args) + if fake.calls[1].dingSessionEnv != "session-123" { + t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv) } } @@ -395,14 +406,100 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { if fake.calls[0].tool != patBatchPlanToolName { t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "env-session-123" { t.Fatalf("plan sessionId = %#v, want env-session-123", got) } if got := fake.calls[0].agentEnv; got != "qoderwork" { t.Fatalf("plan agent env = %q, want qoderwork", got) } - if _, ok := fake.calls[0].args["agentCode"]; ok { - t.Fatalf("batch plan args must not carry agentCode identity field: %#v", fake.calls[0].args) + if fake.calls[0].dingSessionEnv != "env-session-123" { + t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) + } +} + +func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + 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) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("first plan agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) + } +} + +func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"calendar.event:read"}); 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 != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("first grant agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) } } @@ -427,6 +524,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { } func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "ding-session-123") fake := &fakeToolCaller{resultOK: true} @@ -436,6 +534,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "ding-session-123" { t.Fatalf("sessionId arg = %#v, want ding-session-123", got) } @@ -448,6 +549,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { } func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "stale-session") fake := &fakeToolCaller{resultOK: true} @@ -458,6 +560,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "flag-session" { t.Fatalf("sessionId arg = %#v, want flag-session", got) } @@ -543,7 +648,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) { // TestChmod_agentCode_env_fallback verifies that when --agentCode is // omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks -// the env value up and forwards it through the MCP call environment. +// the env value up for both batch arguments and gateway-compatible env. func TestChmod_agentCode_env_fallback(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") @@ -562,8 +667,8 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { 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["agentCode"]; got != "qoderwork" { + t.Fatalf("batch agentCode = %#v, want qoderwork", got) } if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) @@ -573,8 +678,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } -func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { +func TestChmod_agentCode_compatEnvFallback(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "compatwork") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -586,14 +692,31 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { 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 got := fake.gotArgs["agentCode"]; got != "compatwork" { + t.Fatalf("batch agentCode = %#v, want compatwork", 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.gotAgentEnv; got != "compatwork" { + t.Fatalf("%s during CallTool = %q, want compatwork", agentCodeEnv, got) } - 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 TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "once") + + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") + } + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; missing agentCode must fail before MCP", fake.callN) } } @@ -806,6 +929,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t } } +func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) { + fake := &sequenceToolCaller{ + responses: []string{ + `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`, + `{"success":true,"data":{"authRequestId":"req-ok"}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("agentCode", "qoderwork") + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool call count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchGrantToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) + } + if fake.calls[1].tool != patGrantToolName { + t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("batch agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("fallback agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) { + t.Fatalf("fallback scopes = %#v, want mail:send", got) + } +} + func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) { err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具") if !isToolNotRegisteredError(err) { @@ -833,6 +990,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) { } } +func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) { + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}} + if !isPATBatchFallbackResult(result) { + t.Fatal("isPATBatchFallbackResult() = false, want true") + } +} + func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { err := apperrors.NewAPI("business error: success=false", apperrors.WithReason("business_error"), @@ -845,6 +1009,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { } } +func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) { + err := apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED", + }), + ) + if !isPATBatchFallbackError(err) { + t.Fatal("isPATBatchFallbackError() = false, want true") + } +} + func TestHandleToolResult_emptyResultReturnsError(t *testing.T) { err := handleToolResult(nil, nil, &edition.ToolResult{}) if err == nil { @@ -997,35 +1173,36 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { 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) + if got := fake.gotArgs["agentCode"]; got != "flagval" { + t.Fatalf("batch agentCode = %#v, want flagval", got) } } // 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 be consumed. The command is still allowed to run, -// omits agentCode, and lets lippi-pat-core write its default agentCode. +// legacy env MUST NOT satisfy the required agentCode contract. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacyval") 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) + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") + } + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) } - if fake.callN != 1 { - t.Fatalf("CallTool was invoked %d times, want 1", fake.callN) + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy agentCode", fake.callN) } if got := fake.gotAgentEnv; got != "" { t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got) } - if _, ok := fake.gotArgs["agentCode"]; ok { - t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs) - } } // --------------------------------------------------------------------------- @@ -1066,8 +1243,21 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src, "qoderwork", agentCodeEnv) } - // Empty primary → ("", ""). + t.Setenv(agentCodeEnvCompat, "compatwork") + if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want primary (%q, %q)", + code, src, "qoderwork", agentCodeEnv) + } + + t.Setenv(agentCodeEnv, "") + if code, src := resolveAgentCodeFromEnv(); code != "compatwork" || src != agentCodeEnvCompat { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want compat (%q, %q)", + code, src, "compatwork", agentCodeEnvCompat) + } + + // Empty primary + empty compat → ("", ""). t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src) } @@ -1075,6 +1265,7 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { // Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the // canonical env is unset — it was hard-removed as a legacy alias. t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacy") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored", diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 3d17b11c..ab55a162 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -37,7 +37,10 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 - 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 + pat chmod 必须传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / + DWS_DINGTALK_AGENTCODE;CLI 会把 agentCode 放入 batch 请求参数, + 并同步注入 gateway 兼容身份头。 + 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 Host-owned PAT 开关: diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go index 77de05d3..9123ad0b 100644 --- a/test/unit/pat_host_owned_signal_test.go +++ b/test/unit/pat_host_owned_signal_test.go @@ -21,9 +21,10 @@ import ( // TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the // "custom authorization card" contract: the CLI switches to host-owned -// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT / -// claw-type is purely a server-side routing tag and must NOT influence -// the decision, in either direction. +// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE or its compatibility +// alias DWS_DINGTALK_AGENTCODE. DINGTALK_AGENT / claw-type is purely a +// server-side routing tag and must NOT influence the decision, in either +// direction. // // Regression guard: several earlier drafts conflated the two signals, // causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE @@ -33,6 +34,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { cases := []struct { name string agentCode string + compat string agentEnv string want bool }{ @@ -48,6 +50,20 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { agentEnv: "", want: true, }, + { + name: "compat agent code only → host-owned", + agentCode: "", + compat: "agt-compat", + agentEnv: "", + want: true, + }, + { + name: "primary wins when both env names are set", + agentCode: "agt-primary", + compat: "agt-compat", + agentEnv: "", + want: true, + }, { name: "agent code + DINGTALK_AGENT=default → host-owned", agentCode: "agt-cursor", @@ -84,6 +100,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, tc.agentCode) + t.Setenv(authpkg.AgentCodeEnvCompat, tc.compat) // DINGTALK_AGENT is set purely to demonstrate that it does NOT // influence the host-owned decision. The literal env name is // used here because the auth package no longer exports a @@ -97,6 +114,19 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { got, tc.want, tc.agentCode, tc.agentEnv, ) } + if tc.want { + gotCode, gotSource := authpkg.AgentCodeFromEnv() + wantCode := tc.agentCode + wantSource := authpkg.AgentCodeEnv + if wantCode == "" { + wantCode = tc.compat + wantSource = authpkg.AgentCodeEnvCompat + } + if gotCode != wantCode || gotSource != wantSource { + t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)", + gotCode, gotSource, wantCode, wantSource) + } + } }) } } From d0fb3b40fd40bf603215528b0d0dc1101c8fffd5 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 09:38:45 +0800 Subject: [PATCH 14/23] fix(pat): carry agentCode in batch auth args --- internal/app/runner.go | 5 +- internal/app/runner_test.go | 12 ++ internal/auth/channel.go | 39 +++- internal/pat/chmod.go | 177 ++++++++++++---- internal/pat/chmod_test.go | 271 +++++++++++++++++++++--- internal/pat/pat.go | 5 +- test/unit/pat_host_owned_signal_test.go | 36 +++- 7 files changed, 463 insertions(+), 82 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index eb026b15..bc4b48dd 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() { hostOwnedPATDecisionOnce.Do(func() { slog.Debug("runtime.host_owned_pat", "hostOwned", authpkg.HostOwnsPATFlow(), - "agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "", + "agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(), ) }) } @@ -687,9 +687,10 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } + agentCode, _ := authpkg.AgentCodeFromEnv() envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), - "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)), + "x-dingtalk-dws-agent-code": agentCode, "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), "x-dingtalk-session-id": sessionID, "x-dingtalk-message-id": os.Getenv(envDingtalkMessageID), diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index fd3fd673..0803ff79 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -321,6 +321,7 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) { func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(authpkg.AgentCodeEnv, " cursor ") + t.Setenv(authpkg.AgentCodeEnvCompat, "") headers := resolveIdentityHeaders() if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" { @@ -328,6 +329,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } +func TestResolveIdentityHeadersForwardsCompatAgentCode(t *testing.T) { + setupRuntimeCommandTest(t) + t.Setenv(authpkg.AgentCodeEnv, "") + t.Setenv(authpkg.AgentCodeEnvCompat, " compat ") + + headers := resolveIdentityHeaders() + if got := headers["x-dingtalk-dws-agent-code"]; got != "compat" { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want compat", got) + } +} + func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(envDingtalkSessionID, "ding-session") diff --git a/internal/auth/channel.go b/internal/auth/channel.go index e9c1f49d..38c3753c 100644 --- a/internal/auth/channel.go +++ b/internal/auth/channel.go @@ -19,19 +19,40 @@ import ( ) const ( - // AgentCodeEnv is the sole per-spawn environment variable the host injects - // to declare "this process is driven by a third-party Agent host, render - // authorization UI yourselves". + // AgentCodeEnv is the primary per-spawn environment variable the host + // injects to declare "this process is driven by a third-party Agent host, + // render authorization UI yourselves". AgentCodeEnv = "DINGTALK_DWS_AGENTCODE" + + // AgentCodeEnvCompat is a compatibility alias for hosts that shipped the + // reversed prefix before AgentCodeEnv became the public spelling. + AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE" ) +// AgentCodeFromEnv returns the effective host agent code and the env name that +// supplied it. The primary public spelling wins over the compatibility alias. +func AgentCodeFromEnv() (string, string) { + if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" { + return value, AgentCodeEnv + } + if value := strings.TrimSpace(os.Getenv(AgentCodeEnvCompat)); value != "" { + return value, AgentCodeEnvCompat + } + return "", "" +} + +func AgentCodeEnvPresent() bool { + value, _ := AgentCodeFromEnv() + return value != "" +} + // HostOwnsPATFlow reports whether the current process is running under a // third-party Agent host that will render the PAT authorization card -// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being -// non-empty. The CLI deliberately does not consult any other signal -// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this -// decision so that server-side routing tags and the host-owned UI contract -// remain independent concerns. +// itself. The trigger is the effective agent-code env (DINGTALK_DWS_AGENTCODE +// or DWS_DINGTALK_AGENTCODE) being non-empty. The CLI deliberately does not +// consult any other signal (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type +// header) for this decision so that server-side routing tags and the host-owned +// UI contract remain independent concerns. func HostOwnsPATFlow() bool { - return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != "" + return AgentCodeEnvPresent() } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index c5cafdcb..2c14d9ab 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -25,6 +25,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -58,23 +59,25 @@ func resolveSessionIDFromEnv() string { return "" } -// agentCodeEnv is the canonical (and only) environment variable name -// used as a per-shell fallback for the --agentCode flag on `dws pat *` -// commands. +// agentCodeEnv is the canonical environment variable name used as a +// per-shell fallback for the --agentCode flag on `dws pat *` commands. // // 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. +// remain deterministic. When neither flag nor env is set, `pat chmod` fails +// locally instead of letting the server reject the request later. Batch PAT +// tools receive the resolved agentCode in arguments, while the CLI also keeps +// exporting it through env for older gateway paths. // -// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE -// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was -// hard-removed once the public integration surface landed on -// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a -// silent fallback. -const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" +// Namespace note: DWS_DINGTALK_AGENTCODE is kept as a compatibility alias for +// hosts that shipped the reversed prefix early. DWS_AGENTCODE / +// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed. +const ( + agentCodeEnv = authpkg.AgentCodeEnv + agentCodeEnvCompat = authpkg.AgentCodeEnvCompat +) // agentCodePattern is the validation regex for any --agentCode value // resolved from either the flag or the agent-code env var. It matches @@ -84,16 +87,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" // argument. var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) -// resolveAgentCodeFromEnv returns the fallback agent code from the -// canonical DINGTALK_DWS_AGENTCODE env var. The second return value -// reports the env name that was consumed (for error attribution); it -// is "" when the env is unset or blank. No legacy aliases are honored. +// resolveAgentCodeFromEnv returns the fallback agent code from the canonical +// DINGTALK_DWS_AGENTCODE env var, then the DWS_DINGTALK_AGENTCODE compatibility +// alias. The second return value reports the env name that was consumed (for +// error attribution); it is "" when both env vars are unset or blank. func resolveAgentCodeFromEnv() (string, string) { - primary := strings.TrimSpace(os.Getenv(agentCodeEnv)) - if primary != "" { - return primary, agentCodeEnv - } - return "", "" + return authpkg.AgentCodeFromEnv() } // validateAgentCode rejects agent codes that would be ambiguous or unsafe @@ -116,7 +115,8 @@ func validateAgentCode(code string) error { // // 1. explicit --agentCode flag value (highest priority; wins over env) // 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback) -// 3. empty ("") when required=false; typed error when required=true. +// 3. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) +// 4. empty ("") when required=false; typed error when required=true. // // Any non-empty resolved value is validated via validateAgentCode, so // callers never have to re-validate. @@ -128,17 +128,30 @@ func resolveAgentCode(flagVal string, required bool) (string, error) { } if code == "" { if required { - return "", fmt.Errorf( - "flag --agentCode is required (or set env %s)\n hint: dws pat chmod ... --agentCode \n hint: export %s=", - agentCodeEnv, agentCodeEnv) + return "", apperrors.NewValidation( + fmt.Sprintf("flag --agentCode is required (or set env %s or %s)", agentCodeEnv, agentCodeEnvCompat), + apperrors.WithReason("missing_agent_code"), + apperrors.WithHint(fmt.Sprintf("dws pat chmod ... --agentCode \n or: export %s=", agentCodeEnv)), + apperrors.WithActions( + "dws pat chmod ... --agentCode ", + fmt.Sprintf("export %s=", agentCodeEnv), + fmt.Sprintf("export %s=", agentCodeEnvCompat), + ), + ) } return "", nil } if err := validateAgentCode(code); err != nil { if envSource != "" { - return "", fmt.Errorf("%s env: %w", envSource, err) + return "", apperrors.NewValidation( + fmt.Sprintf("%s env: %v", envSource, err), + apperrors.WithReason("invalid_agent_code"), + ) } - return "", err + return "", apperrors.NewValidation( + err.Error(), + apperrors.WithReason("invalid_agent_code"), + ) } return code, nil } @@ -159,8 +172,25 @@ const ( patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" ) +var patBatchMetadataContractCodes = map[string]bool{ + "pat_batch_auth_metadata_required": true, + "pat_batch_scope_not_declared": true, + "pat_batch_product_not_declared": true, +} + +var patBatchIdentityArgumentKeys = map[string]bool{ + "agentCode": true, + "sessionId": true, + "orgId": true, + "uid": true, + "source": true, + "caller": true, +} + var validGrantTypes = map[string]bool{ "once": true, "session": true, @@ -204,7 +234,7 @@ grantType 规则: 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, false) + agentCode, err := resolveAgentCode(flagVal, true) if err != nil { return err } @@ -239,8 +269,6 @@ grantType 规则: 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) @@ -315,7 +343,7 @@ grantType 规则: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)") + "Agent 唯一标识(必填;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;会进入 batch 参数并同步注入兼容 env)") chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价") @@ -388,13 +416,11 @@ func callPATBatchGrantWithLegacyFallback( 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) { + result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs) + if err == nil && !isPATBatchFallbackResult(result) { return result, nil } - if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) { + if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) { return nil, err } return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { @@ -414,8 +440,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess if c == nil { return nil, fmt.Errorf("internal error: tool runtime not initialized") } + return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args) +} + +func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) { + result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return c.CallTool(ctx, "pat", toolName, args) + }) + if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) { + return result, err + } + compatArgs := cloneWithoutPATIdentityArgs(args) return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { - return c.CallTool(ctx, "pat", patBatchPlanToolName, args) + return c.CallTool(ctx, "pat", toolName, compatArgs) }) } @@ -485,6 +522,25 @@ func firstToolResultText(result *edition.ToolResult) string { } func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patBatchUnsupportedCode) + }) +} + +func isPATBatchFallbackResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + normalized := strings.ToLower(strings.TrimSpace(code)) + return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized] + }) +} + +func isPATForgedIdentityResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patForgedIdentityCode) + }) +} + +func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool { text := firstToolResultText(result) if text == "" { return false @@ -494,7 +550,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return false } for _, key := range []string{"code", "errorCode", "error_code"} { - if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) { + if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) { return true } } @@ -505,6 +561,53 @@ func isPATBatchUnsupportedError(err error) bool { return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower) } +func isPATBatchFallbackError(err error) bool { + if isPATBatchUnsupportedError(err) { + return true + } + text := normalizedPATErrorText(err) + for code := range patBatchMetadataContractCodes { + if strings.Contains(text, code) { + return true + } + } + return false +} + +func isPATForgedIdentityError(err error) bool { + return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower) +} + +func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool { + if !hasPATIdentityArgs(args) { + return false + } + if err != nil { + return isPATForgedIdentityError(err) + } + return isPATForgedIdentityResult(result) +} + +func hasPATIdentityArgs(args map[string]any) bool { + for key := range args { + if patBatchIdentityArgumentKeys[key] { + return true + } + } + return false +} + +func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any { + out := make(map[string]any, len(args)) + for key, value := range args { + if patBatchIdentityArgumentKeys[key] { + continue + } + out[key] = value + } + return out +} + // 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 diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 57443096..d7441cd4 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" } func (f *fakeToolCaller) DryRun() bool { return f.dryRun } type recordedToolCall struct { - tool string - args map[string]any + tool string + args map[string]any + agentEnv string + sessionEnv string + dingSessionEnv string } type fallbackToolCaller struct { @@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false } type sequenceToolCaller struct { calls []recordedToolCall responses []string + errs []error dryRun bool } @@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri copied[k] = v } s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied}) + s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv) + s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS) + s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk) + if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil { + return nil, s.errs[len(s.calls)-1] + } response := `{"success":true,"data":{}}` if len(s.responses) >= len(s.calls) { response = s.responses[len(s.calls)-1] @@ -305,8 +315,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[0].args["recommend"]; got != false { t.Fatalf("recommend = %#v, want false", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + t.Fatalf("batch plan agentCode = %#v, want qoderwork", got) } if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) @@ -314,12 +327,15 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { 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 got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { - t.Fatalf("grant agentCode = %#v, want qoderwork", got) + t.Fatalf("batch grant agentCode = %#v, want qoderwork", got) } } -func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { +func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, @@ -339,20 +355,32 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { if got := fake.calls[0].args["grantType"]; got != "session" { t.Fatalf("plan grantType = %#v, want session", got) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "session-123" { t.Fatalf("plan sessionId = %#v, want session-123", got) } - if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if fake.calls[0].dingSessionEnv != "session-123" { + t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) } if got := fake.calls[1].args["grantType"]; got != "session" { t.Fatalf("grant grantType = %#v, want session", got) } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("grant agentCode = %#v, want qoderwork", got) + } if got := fake.calls[1].args["sessionId"]; got != "session-123" { t.Fatalf("grant sessionId = %#v, want session-123", got) } - if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { - t.Fatalf("grant agentCode = %#v, want qoderwork", got) + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } + if fake.calls[1].dingSessionEnv != "session-123" { + t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv) } } @@ -378,11 +406,100 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { if fake.calls[0].tool != patBatchPlanToolName { t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "env-session-123" { t.Fatalf("plan sessionId = %#v, want env-session-123", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if fake.calls[0].dingSessionEnv != "env-session-123" { + t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) + } +} + +func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + 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) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName) + } if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + t.Fatalf("first plan agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) + } +} + +func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"calendar.event:read"}); 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 != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("first grant agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) } } @@ -407,6 +524,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { } func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "ding-session-123") fake := &fakeToolCaller{resultOK: true} @@ -416,6 +534,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "ding-session-123" { t.Fatalf("sessionId arg = %#v, want ding-session-123", got) } @@ -428,6 +549,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { } func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "stale-session") fake := &fakeToolCaller{resultOK: true} @@ -438,6 +560,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "flag-session" { t.Fatalf("sessionId arg = %#v, want flag-session", got) } @@ -523,7 +648,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) { // TestChmod_agentCode_env_fallback verifies that when --agentCode is // omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks -// the env value up and forwards it verbatim in the MCP argv. +// the env value up for both batch arguments and gateway-compatible env. func TestChmod_agentCode_env_fallback(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") @@ -543,7 +668,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { t.Fatalf("agent env = %q, want %q", got, "qoderwork") } if got := fake.gotArgs["agentCode"]; got != "qoderwork" { - t.Fatalf("batch argv agentCode = %#v, want qoderwork", got) + t.Fatalf("batch agentCode = %#v, want qoderwork", got) } if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) @@ -553,8 +678,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } -func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { +func TestChmod_agentCode_compatEnvFallback(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "compatwork") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -566,14 +692,31 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { 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 got := fake.gotArgs["agentCode"]; got != "compatwork" { + t.Fatalf("batch agentCode = %#v, want compatwork", 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.gotAgentEnv; got != "compatwork" { + t.Fatalf("%s during CallTool = %q, want compatwork", agentCodeEnv, got) } - 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 TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "once") + + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") + } + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; missing agentCode must fail before MCP", fake.callN) } } @@ -786,6 +929,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t } } +func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) { + fake := &sequenceToolCaller{ + responses: []string{ + `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`, + `{"success":true,"data":{"authRequestId":"req-ok"}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("agentCode", "qoderwork") + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool call count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchGrantToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) + } + if fake.calls[1].tool != patGrantToolName { + t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("batch agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("fallback agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) { + t.Fatalf("fallback scopes = %#v, want mail:send", got) + } +} + func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) { err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具") if !isToolNotRegisteredError(err) { @@ -813,6 +990,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) { } } +func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) { + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}} + if !isPATBatchFallbackResult(result) { + t.Fatal("isPATBatchFallbackResult() = false, want true") + } +} + func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { err := apperrors.NewAPI("business error: success=false", apperrors.WithReason("business_error"), @@ -825,6 +1009,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { } } +func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) { + err := apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED", + }), + ) + if !isPATBatchFallbackError(err) { + t.Fatal("isPATBatchFallbackError() = false, want true") + } +} + func TestHandleToolResult_emptyResultReturnsError(t *testing.T) { err := handleToolResult(nil, nil, &edition.ToolResult{}) if err == nil { @@ -978,34 +1174,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval") } if got := fake.gotArgs["agentCode"]; got != "flagval" { - t.Fatalf("batch argv agentCode = %#v, want flagval", got) + t.Fatalf("batch agentCode = %#v, want flagval", got) } } // 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 be consumed. The command is still allowed to run, -// omits agentCode, and lets lippi-pat-core write its default agentCode. +// legacy env MUST NOT satisfy the required agentCode contract. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacyval") 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) + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") } - if fake.callN != 1 { - t.Fatalf("CallTool was invoked %d times, want 1", fake.callN) + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy agentCode", fake.callN) } if got := fake.gotAgentEnv; got != "" { t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got) } - if _, ok := fake.gotArgs["agentCode"]; ok { - t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs) - } } // --------------------------------------------------------------------------- @@ -1046,8 +1243,21 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src, "qoderwork", agentCodeEnv) } - // Empty primary → ("", ""). + t.Setenv(agentCodeEnvCompat, "compatwork") + if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want primary (%q, %q)", + code, src, "qoderwork", agentCodeEnv) + } + + t.Setenv(agentCodeEnv, "") + if code, src := resolveAgentCodeFromEnv(); code != "compatwork" || src != agentCodeEnvCompat { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want compat (%q, %q)", + code, src, "compatwork", agentCodeEnvCompat) + } + + // Empty primary + empty compat → ("", ""). t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src) } @@ -1055,6 +1265,7 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { // Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the // canonical env is unset — it was hard-removed as a legacy alias. t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacy") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored", diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 3d17b11c..ab55a162 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -37,7 +37,10 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 - 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 + pat chmod 必须传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / + DWS_DINGTALK_AGENTCODE;CLI 会把 agentCode 放入 batch 请求参数, + 并同步注入 gateway 兼容身份头。 + 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 Host-owned PAT 开关: diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go index 77de05d3..9123ad0b 100644 --- a/test/unit/pat_host_owned_signal_test.go +++ b/test/unit/pat_host_owned_signal_test.go @@ -21,9 +21,10 @@ import ( // TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the // "custom authorization card" contract: the CLI switches to host-owned -// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT / -// claw-type is purely a server-side routing tag and must NOT influence -// the decision, in either direction. +// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE or its compatibility +// alias DWS_DINGTALK_AGENTCODE. DINGTALK_AGENT / claw-type is purely a +// server-side routing tag and must NOT influence the decision, in either +// direction. // // Regression guard: several earlier drafts conflated the two signals, // causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE @@ -33,6 +34,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { cases := []struct { name string agentCode string + compat string agentEnv string want bool }{ @@ -48,6 +50,20 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { agentEnv: "", want: true, }, + { + name: "compat agent code only → host-owned", + agentCode: "", + compat: "agt-compat", + agentEnv: "", + want: true, + }, + { + name: "primary wins when both env names are set", + agentCode: "agt-primary", + compat: "agt-compat", + agentEnv: "", + want: true, + }, { name: "agent code + DINGTALK_AGENT=default → host-owned", agentCode: "agt-cursor", @@ -84,6 +100,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, tc.agentCode) + t.Setenv(authpkg.AgentCodeEnvCompat, tc.compat) // DINGTALK_AGENT is set purely to demonstrate that it does NOT // influence the host-owned decision. The literal env name is // used here because the auth package no longer exports a @@ -97,6 +114,19 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { got, tc.want, tc.agentCode, tc.agentEnv, ) } + if tc.want { + gotCode, gotSource := authpkg.AgentCodeFromEnv() + wantCode := tc.agentCode + wantSource := authpkg.AgentCodeEnv + if wantCode == "" { + wantCode = tc.compat + wantSource = authpkg.AgentCodeEnvCompat + } + if gotCode != wantCode || gotSource != wantSource { + t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)", + gotCode, gotSource, wantCode, wantSource) + } + } }) } } From d8efa816f1e87e406db832a94e4dd5525988424c Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 14:43:22 +0800 Subject: [PATCH 15/23] fix(pat): let core default missing agent code --- internal/pat/browser_policy.go | 2 +- internal/pat/chmod.go | 31 ++++++++----------------- internal/pat/chmod_test.go | 41 +++++++++++++++++++--------------- internal/pat/pat.go | 6 ++--- 4 files changed, 36 insertions(+), 44 deletions(-) diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go index c6c474b3..5667c9fc 100644 --- a/internal/pat/browser_policy.go +++ b/internal/pat/browser_policy.go @@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error { } func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) { - agentCode, err := resolveAgentCode(explicitAgentCode, false) + agentCode, err := resolveAgentCode(explicitAgentCode) if err != nil { return BrowserPolicySelection{}, err } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 2c14d9ab..00a3b69d 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -66,10 +66,10 @@ func resolveSessionIDFromEnv() string { // 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, `pat chmod` fails -// locally instead of letting the server reject the request later. Batch PAT -// tools receive the resolved agentCode in arguments, while the CLI also keeps -// exporting it through env for older gateway paths. +// remain deterministic. When neither flag nor env is set, `pat chmod` omits +// agentCode and lets the PAT server apply its open-source default. Batch PAT +// tools receive the resolved agentCode in arguments when present, while the CLI +// also keeps exporting it through env for older gateway paths. // // Namespace note: DWS_DINGTALK_AGENTCODE is kept as a compatibility alias for // hosts that shipped the reversed prefix early. DWS_AGENTCODE / @@ -110,35 +110,22 @@ func validateAgentCode(code string) error { return nil } -// resolveAgentCode implements the canonical two-tier lookup for -// --agentCode: +// resolveAgentCode implements the canonical optional lookup for --agentCode: // // 1. explicit --agentCode flag value (highest priority; wins over env) // 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback) // 3. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) -// 4. empty ("") when required=false; typed error when required=true. +// 4. empty ("") so PAT-core can apply its open-source default. // // Any non-empty resolved value is validated via validateAgentCode, so // callers never have to re-validate. -func resolveAgentCode(flagVal string, required bool) (string, error) { +func resolveAgentCode(flagVal string) (string, error) { code := strings.TrimSpace(flagVal) envSource := "" if code == "" { code, envSource = resolveAgentCodeFromEnv() } if code == "" { - if required { - return "", apperrors.NewValidation( - fmt.Sprintf("flag --agentCode is required (or set env %s or %s)", agentCodeEnv, agentCodeEnvCompat), - apperrors.WithReason("missing_agent_code"), - apperrors.WithHint(fmt.Sprintf("dws pat chmod ... --agentCode \n or: export %s=", agentCodeEnv)), - apperrors.WithActions( - "dws pat chmod ... --agentCode ", - fmt.Sprintf("export %s=", agentCodeEnv), - fmt.Sprintf("export %s=", agentCodeEnvCompat), - ), - ) - } return "", nil } if err := validateAgentCode(code); err != nil { @@ -234,7 +221,7 @@ grantType 规则: 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) if err != nil { return err } @@ -343,7 +330,7 @@ grantType 规则: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(必填;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;会进入 batch 参数并同步注入兼容 env)") + "Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)") chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价") diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index d7441cd4..b5d63fc8 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -29,8 +29,8 @@ import ( ) // fakeToolCaller captures the toolArgs passed to CallTool so tests can -// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error -// resolver feeds into the outgoing MCP argv. +// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds +// into the outgoing batch request. type fakeToolCaller struct { mu sync.Mutex dryRun bool @@ -700,7 +700,7 @@ func TestChmod_agentCode_compatEnvFallback(t *testing.T) { } } -func TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { +func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) { t.Setenv(agentCodeEnv, "") t.Setenv(agentCodeEnvCompat, "") @@ -708,15 +708,20 @@ func TestChmod_withoutAgentCodeFailsBeforeMCP(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.Fatal("chmod RunE error = nil, want missing agentCode error") + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err) } - if !strings.Contains(err.Error(), "flag --agentCode is required") { - t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + if fake.callN != 1 { + t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN) } - if fake.callN != 0 { - t.Fatalf("CallTool was invoked %d times; missing agentCode must fail before MCP", fake.callN) + if fake.gotTool != patBatchGrantToolName { + t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) + } + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs) + } + if got := fake.gotAgentEnv; got != "" { + t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got) } } @@ -1180,7 +1185,8 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { // 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 required agentCode contract. +// legacy env MUST NOT be consumed as agentCode. The request is still sent so +// PAT-core can apply its open-source default. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") t.Setenv(agentCodeEnvCompat, "") @@ -1190,15 +1196,14 @@ 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.Fatal("chmod RunE error = nil, want missing agentCode error") + if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { + t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err) } - if !strings.Contains(err.Error(), "flag --agentCode is required") { - t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + if fake.callN != 1 { + t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN) } - 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("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs) } if got := fake.gotAgentEnv; got != "" { t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got) diff --git a/internal/pat/pat.go b/internal/pat/pat.go index ab55a162..d4463581 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -37,9 +37,9 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 - pat chmod 必须传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / - DWS_DINGTALK_AGENTCODE;CLI 会把 agentCode 放入 batch 请求参数, - 并同步注入 gateway 兼容身份头。 + pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / + DWS_DINGTALK_AGENTCODE;CLI 会把显式 agentCode 放入 batch 请求参数, + 并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 From 31d081ddf40c1c8e9796645731f0ff5c9c22ad4c Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 14:59:23 +0800 Subject: [PATCH 16/23] fix(pat): require yes for batch grants --- internal/pat/chmod.go | 58 ++++++++++++++++--- internal/pat/chmod_test.go | 112 +++++++++++++++++++++++++++++++++++++ internal/pat/pat.go | 6 ++ 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 00a3b69d..871164f7 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -206,7 +206,21 @@ scope 格式: .: grantType 规则: once 一次性,执行一次后自动失效 session 当前会话有效(默认),需要 --session-id - permanent 永久有效`, + permanent 永久有效 + +批量授权: + dws pat chmod 支持一次传多个 scope 直接批量授予。 + 也支持 --products / --product 按产品编码批量展开 scope 模板, + --domains / --domain 按产品域批量展开 scope 模板, + --recommend 使用服务端推荐 scope 集合。 + 使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认 + selected / skipped / pending,再对 selected scopes 执行 batch grant; + --dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式 + 添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。 + +agentCode 兼容: + 可通过 --agentCode、DINGTALK_DWS_AGENTCODE 或 DWS_DINGTALK_AGENTCODE + 指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`, Args: func(cmd *cobra.Command, args []string) error { productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) if len(args) > 0 || recommend || len(productCodes) > 0 { @@ -216,9 +230,11 @@ grantType 规则: }, 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`, + dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes + dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json + dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes + dws pat chmod --domain calendar --domain chat --grant-type once --yes + dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`, RunE: func(cmd *cobra.Command, args []string) error { flagVal, _ := cmd.Flags().GetString("agentCode") agentCode, err := resolveAgentCode(flagVal) @@ -282,6 +298,11 @@ grantType 规则: if len(scopes) == 0 { return handleToolResult(cmd, c, planResult) } + if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil { + return err + } + } else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil { + return err } batchArgs := map[string]any{ "scopes": scopes, @@ -333,15 +354,34 @@ grantType 规则: "Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_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 集合批量授权") + chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes") + chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes") + chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes") + chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes") + chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes") return chmodCmd } +func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error { + if !usesPlan && len(scopes) <= 1 { + return nil + } + if commandBoolFlag(cmd, "yes") { + return nil + } + return apperrors.NewValidation( + "batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant", + apperrors.WithReason("pat_batch_requires_yes"), + apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"), + apperrors.WithActions( + "dws pat chmod ... --grant-type once --yes", + "dws pat chmod --products --grant-type once --yes", + "dws pat chmod --recommend --grant-type once --yes", + ), + ) +} + func collectChmodProductCodes(groups ...[]string) []string { seen := map[string]bool{} result := make([]string, 0) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index b5d63fc8..1fa84839 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -267,6 +267,18 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command { return newChmodCommand(fake) } +func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "skip confirmation") + root.AddCommand(cmd) + if yes { + if err := root.PersistentFlags().Set("yes", "true"); err != nil { + t.Fatalf("set root --yes: %v", err) + } + } +} + func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { root := &cobra.Command{Use: "dws"} RegisterCommands(root, &fakeToolCaller{}) @@ -289,6 +301,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { } } +func TestPATHelpDocumentsBatchAuthorization(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) + } + var out strings.Builder + patCmd.SetOut(&out) + patCmd.SetErr(&out) + if err := patCmd.Help(); err != nil { + t.Fatalf("pat help error = %v", err) + } + patHelp := out.String() + for _, want := range []string{ + "支持批量授权", + "--products / --product", + "--domains / --domain", + "--recommend", + "DWS_DINGTALK_AGENTCODE", + "未传 agentCode 时由服务端默认兜底", + } { + if !strings.Contains(patHelp, want) { + t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp) + } + } + + chmodCmd, _, err := root.Find([]string{"pat", "chmod"}) + if err != nil { + t.Fatalf("pat chmod command not found: %v", err) + } + out.Reset() + chmodCmd.SetOut(&out) + chmodCmd.SetErr(&out) + if err := chmodCmd.Help(); err != nil { + t.Fatalf("pat chmod help error = %v", err) + } + chmodHelp := out.String() + for _, want := range []string{ + "批量授权:", + "一次传多个 scope", + "batch plan", + "--dry-run 只返回授权计划", + "执行批量授权必须显式", + "由服务端默认兜底", + "aitable.record:read aitable.record:write --grant-type permanent --yes", + "dws pat chmod --product calendar --product aitable", + "dws pat chmod --domain calendar --domain chat", + } { + if !strings.Contains(chmodHelp, want) { + t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp) + } + } +} + func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ @@ -298,6 +366,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar,aitable") + attachRootYesFlag(t, cmd, true) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -335,6 +404,47 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } +func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar,aitable") + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("chmod RunE error = nil, want batch --yes blocker") + } + if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") { + t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error()) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want plan only", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } +} + +func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) { + fake := &fakeToolCaller{resultOK: true} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + + err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want batch --yes blocker") + } + if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") { + t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN) + } +} + func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ @@ -344,6 +454,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("products", "calendar") _ = cmd.Flags().Set("session-id", "session-123") + attachRootYesFlag(t, cmd, true) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -583,6 +694,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("recommend", "true") + attachRootYesFlag(t, cmd, true) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) diff --git a/internal/pat/pat.go b/internal/pat/pat.go index d4463581..9d754639 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -36,6 +36,12 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { 能力说明: pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 + pat chmod 支持批量授权:可一次传多个 scope,也可通过 + --products / --product、--domains / --domain 或 --recommend + 让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。 + 批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划, + 不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes + 时 CLI 会阻断并提示 agent 先确认。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / DWS_DINGTALK_AGENTCODE;CLI 会把显式 agentCode 放入 batch 请求参数, From a9eaa1bc1d409c94647c75c503fbb6564a35a5cd Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 15:17:36 +0800 Subject: [PATCH 17/23] test(pat): cover cli authorization matrix --- internal/pat/browser_policy_test.go | 28 ++ internal/pat/chmod_test.go | 411 ++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) diff --git a/internal/pat/browser_policy_test.go b/internal/pat/browser_policy_test.go index 7819f2c3..146fb352 100644 --- a/internal/pat/browser_policy_test.go +++ b/internal/pat/browser_policy_test.go @@ -16,6 +16,7 @@ package pat import ( "bytes" "encoding/json" + "strings" "testing" ) @@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing. t.Fatalf("len(policy.Agents) = %d, want 0", got) } } + +func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + cmd := newBrowserPolicyCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + cmd.SetArgs([]string{"--agentCode", "agt-command"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("browser-policy Execute() error = nil, want missing --enabled error") + } + if !strings.Contains(err.Error(), "--enabled is required") { + t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error()) + } + + policy, loadErr := LoadBrowserPolicy(configDir) + if loadErr != nil { + t.Fatalf("LoadBrowserPolicy error = %v", loadErr) + } + if policy.Default != nil || len(policy.Agents) != 0 { + t.Fatalf("policy was modified despite missing --enabled: %#v", policy) + } +} diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 1fa84839..ce0bc625 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -279,6 +279,25 @@ func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) { } } +func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "skip confirmation") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Bool("verbose", false, "") + root.AddCommand(cmd) + if yes { + if err := root.PersistentFlags().Set("yes", "true"); err != nil { + t.Fatalf("set root --yes: %v", err) + } + } + if formatChanged { + if err := root.PersistentFlags().Set("format", "json"); err != nil { + t.Fatalf("set root --format: %v", err) + } + } +} + func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { root := &cobra.Command{Use: "dws"} RegisterCommands(root, &fakeToolCaller{}) @@ -495,6 +514,398 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { } } +func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + attachRootPATFlags(t, cmd, false, false) + + output, err := captureStdout(t, func() error { + return cmd.RunE(cmd, []string{"contact.user:get-self"}) + }) + if 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 != patBatchGrantToolName { + t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) + } + if _, ok := fake.calls[0].args["agentCode"]; ok { + t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args) + } + if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") { + t.Fatalf("summary output missing server default agentCode:\n%s", output) + } +} + +func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) { + t.Setenv(agentCodeEnv, "envshouldlose") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("agentCode", "qoderwork") + attachRootPATFlags(t, cmd, false, false) + + output, err := captureStdout(t, func() error { + return cmd.RunE(cmd, []string{"chat.bot:search"}) + }) + if 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 got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got) + } + if !strings.Contains(output, "agentCode: qoderwork") { + t.Fatalf("summary output missing qoderwork agentCode:\n%s", output) + } +} + +func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) { + cases := []struct { + name string + args []string + setFlags func(*cobra.Command) + wantPlanProducts []string + wantRecommend bool + wantCallCount int + }{ + { + name: "direct multi scope", + args: []string{"calendar.event:list", "calendar.event:create"}, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + }, + wantCallCount: 1, + }, + { + name: "product repeated", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("product", "calendar") + _ = cmd.Flags().Set("product", "aitable") + }, + wantPlanProducts: []string{"calendar", "aitable"}, + wantCallCount: 2, + }, + { + name: "products comma list", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar,aitable") + }, + wantPlanProducts: []string{"calendar", "aitable"}, + wantCallCount: 2, + }, + { + name: "domain repeated", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("domain", "calendar") + _ = cmd.Flags().Set("domain", "chat") + }, + wantPlanProducts: []string{"calendar", "chat"}, + wantCallCount: 2, + }, + { + name: "domains comma list", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("domains", "calendar,chat") + }, + wantPlanProducts: []string{"calendar", "chat"}, + wantCallCount: 2, + }, + { + name: "recommend", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("recommend", "true") + }, + wantRecommend: true, + wantCallCount: 2, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + responses := []string{ + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`, + } + if tc.wantCallCount == 2 { + responses = []string{ + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`, + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`, + } + } + fake := &sequenceToolCaller{responses: responses} + cmd := newChmodCommand(fake) + tc.setFlags(cmd) + attachRootPATFlags(t, cmd, true, false) + + output, err := captureStdout(t, func() error { + return cmd.RunE(cmd, tc.args) + }) + if err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != tc.wantCallCount { + t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount) + } + if tc.wantCallCount == 1 { + if fake.calls[0].tool != patBatchGrantToolName { + t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) + } + if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) { + t.Fatalf("grant scopes = %#v, want %#v", got, tc.args) + } + } else { + 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, tc.wantPlanProducts) { + t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts) + } + if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend { + t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend) + } + 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:list", "calendar.event:create"}) { + t.Fatalf("grant scopes = %#v, want selected scopes", got) + } + } + last := fake.calls[len(fake.calls)-1] + if got := last.args["agentCode"]; got != "qoderwork" { + t.Fatalf("grant agentCode = %#v, want qoderwork", got) + } + if !strings.Contains(output, "agentCode: qoderwork") { + t.Fatalf("summary output missing qoderwork agentCode:\n%s", output) + } + }) + } +} + +func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) { + cases := []struct { + name string + setFlags func(*cobra.Command) + wantPlanProducts []string + wantRecommend bool + }{ + { + name: "product", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("products", "calendar,aitable") + }, + wantPlanProducts: []string{"calendar", "aitable"}, + }, + { + name: "domain", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("domains", "calendar,chat") + }, + wantPlanProducts: []string{"calendar", "chat"}, + }, + { + name: "recommend", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("recommend", "true") + }, + wantRecommend: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{ + dryRun: true, + responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + tc.setFlags(cmd) + attachRootPATFlags(t, cmd, false, false) + + output, err := captureStdout(t, func() error { + return cmd.RunE(cmd, nil) + }) + if err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) { + t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts) + } + if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend { + t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend) + } + if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") { + t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output) + } + }) + } +} + +func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) { + cases := []struct { + name string + args []string + setFlags func(*cobra.Command) + wantPlan bool + }{ + { + name: "direct multi scope", + args: []string{"calendar.event:list", "calendar.event:create"}, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + }, + }, + { + name: "product", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar") + }, + wantPlan: true, + }, + { + name: "domain", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("domains", "calendar") + }, + wantPlan: true, + }, + { + name: "recommend", + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("recommend", "true") + }, + wantPlan: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`, + }} + cmd := newChmodCommand(fake) + tc.setFlags(cmd) + + err := cmd.RunE(cmd, tc.args) + if err == nil { + t.Fatal("chmod RunE error = nil, want batch --yes blocker") + } + if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") { + t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error()) + } + if tc.wantPlan { + if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls) + } + return + } + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls)) + } + }) + } +} + +func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) { + cases := []struct { + name string + grantType string + sessionFlag string + sessionEnv string + wantSessionID string + wantErr string + }{ + {name: "once no session", grantType: "once"}, + {name: "permanent no session", grantType: "permanent"}, + {name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"}, + {name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"}, + {name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"}, + {name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + if tc.sessionEnv != "" { + t.Setenv(sessionIDEnvDWS, tc.sessionEnv) + } + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", tc.grantType) + if tc.sessionFlag != "" { + _ = cmd.Flags().Set("session-id", tc.sessionFlag) + } + + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr) + } + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls)) + } + return + } + if 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 got := fake.calls[0].args["grantType"]; got != tc.grantType { + t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType) + } + if tc.wantSessionID == "" { + if _, ok := fake.calls[0].args["sessionId"]; ok { + t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args) + } + return + } + if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID { + t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID) + } + if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID { + t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID) + } + }) + } +} + func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDWS, "env-session-123") From 38a162c72630bf36aa6c354f41f2fe8512f8ec7a Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 15:32:19 +0800 Subject: [PATCH 18/23] fix(pat): keep canonical agent code env only --- internal/app/runner_test.go | 9 ++--- internal/auth/channel.go | 24 ++++++------ internal/pat/chmod.go | 22 +++++------ internal/pat/chmod_test.go | 51 ++++++++++--------------- internal/pat/pat.go | 4 +- test/unit/pat_host_owned_signal_test.go | 28 ++++---------- 6 files changed, 56 insertions(+), 82 deletions(-) diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 0803ff79..1327e567 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -321,7 +321,6 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) { func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(authpkg.AgentCodeEnv, " cursor ") - t.Setenv(authpkg.AgentCodeEnvCompat, "") headers := resolveIdentityHeaders() if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" { @@ -329,14 +328,14 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } -func TestResolveIdentityHeadersForwardsCompatAgentCode(t *testing.T) { +func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(authpkg.AgentCodeEnv, "") - t.Setenv(authpkg.AgentCodeEnvCompat, " compat ") + t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ") headers := resolveIdentityHeaders() - if got := headers["x-dingtalk-dws-agent-code"]; got != "compat" { - t.Fatalf("x-dingtalk-dws-agent-code = %q, want compat", got) + if got := headers["x-dingtalk-dws-agent-code"]; got != "" { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got) } } diff --git a/internal/auth/channel.go b/internal/auth/channel.go index 38c3753c..4b8315a3 100644 --- a/internal/auth/channel.go +++ b/internal/auth/channel.go @@ -23,21 +23,19 @@ const ( // injects to declare "this process is driven by a third-party Agent host, // render authorization UI yourselves". AgentCodeEnv = "DINGTALK_DWS_AGENTCODE" - - // AgentCodeEnvCompat is a compatibility alias for hosts that shipped the - // reversed prefix before AgentCodeEnv became the public spelling. - AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE" ) // AgentCodeFromEnv returns the effective host agent code and the env name that -// supplied it. The primary public spelling wins over the compatibility alias. +// supplied it. +// +// Keep the public env surface intentionally single-spelled. The reversed +// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode, +// gateway identity headers, and `pat chmod --agentCode` fallback all agree on +// the same stable signal: DINGTALK_DWS_AGENTCODE. func AgentCodeFromEnv() (string, string) { if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" { return value, AgentCodeEnv } - if value := strings.TrimSpace(os.Getenv(AgentCodeEnvCompat)); value != "" { - return value, AgentCodeEnvCompat - } return "", "" } @@ -48,11 +46,11 @@ func AgentCodeEnvPresent() bool { // HostOwnsPATFlow reports whether the current process is running under a // third-party Agent host that will render the PAT authorization card -// itself. The trigger is the effective agent-code env (DINGTALK_DWS_AGENTCODE -// or DWS_DINGTALK_AGENTCODE) being non-empty. The CLI deliberately does not -// consult any other signal (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type -// header) for this decision so that server-side routing tags and the host-owned -// UI contract remain independent concerns. +// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI +// deliberately does not consult any other signal (DINGTALK_AGENT / +// DWS_CHANNEL / the wire claw-type header) for this decision so that +// server-side routing tags and the host-owned UI contract remain independent +// concerns. func HostOwnsPATFlow() bool { return AgentCodeEnvPresent() } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 871164f7..2e7f0b68 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -71,12 +71,11 @@ func resolveSessionIDFromEnv() string { // tools receive the resolved agentCode in arguments when present, while the CLI // also keeps exporting it through env for older gateway paths. // -// Namespace note: DWS_DINGTALK_AGENTCODE is kept as a compatibility alias for -// hosts that shipped the reversed prefix early. DWS_AGENTCODE / +// Namespace note: keep this as a single-spelled public contract. The reversed +// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE / // DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed. const ( - agentCodeEnv = authpkg.AgentCodeEnv - agentCodeEnvCompat = authpkg.AgentCodeEnvCompat + agentCodeEnv = authpkg.AgentCodeEnv ) // agentCodePattern is the validation regex for any --agentCode value @@ -88,9 +87,9 @@ const ( var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) // resolveAgentCodeFromEnv returns the fallback agent code from the canonical -// DINGTALK_DWS_AGENTCODE env var, then the DWS_DINGTALK_AGENTCODE compatibility -// alias. The second return value reports the env name that was consumed (for -// error attribution); it is "" when both env vars are unset or blank. +// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name +// that was consumed (for error attribution); it is "" when the env var is unset +// or blank. func resolveAgentCodeFromEnv() (string, string) { return authpkg.AgentCodeFromEnv() } @@ -114,8 +113,7 @@ func validateAgentCode(code string) error { // // 1. explicit --agentCode flag value (highest priority; wins over env) // 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback) -// 3. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) -// 4. empty ("") so PAT-core can apply its open-source default. +// 3. empty ("") so PAT-core can apply its open-source default. // // Any non-empty resolved value is validated via validateAgentCode, so // callers never have to re-validate. @@ -218,8 +216,8 @@ grantType 规则: --dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式 添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。 -agentCode 兼容: - 可通过 --agentCode、DINGTALK_DWS_AGENTCODE 或 DWS_DINGTALK_AGENTCODE +agentCode 配置: + 可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE 指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`, Args: func(cmd *cobra.Command, args []string) error { productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) @@ -351,7 +349,7 @@ agentCode 兼容: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)") + "Agent 唯一标识(可选;也可通过 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 等价;执行批量授权需 --yes") diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index ce0bc625..14d8294f 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -340,7 +340,7 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) { "--products / --product", "--domains / --domain", "--recommend", - "DWS_DINGTALK_AGENTCODE", + "DINGTALK_DWS_AGENTCODE", "未传 agentCode 时由服务端默认兜底", } { if !strings.Contains(patHelp, want) { @@ -516,7 +516,6 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) { t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`, }} @@ -943,8 +942,7 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { } func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "qoderwork") + t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{ errs: []error{ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", @@ -985,8 +983,7 @@ func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { } func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "qoderwork") + t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{ errs: []error{ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", @@ -1201,9 +1198,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } -func TestChmod_agentCode_compatEnvFallback(t *testing.T) { +func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) { t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "compatwork") + t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -1215,17 +1212,16 @@ func TestChmod_agentCode_compatEnvFallback(t *testing.T) { if fake.gotTool != patBatchGrantToolName { t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) } - if got := fake.gotArgs["agentCode"]; got != "compatwork" { - t.Fatalf("batch agentCode = %#v, want compatwork", got) + if _, ok := fake.gotArgs["agentCode"]; ok { + t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs) } - if got := fake.gotAgentEnv; got != "compatwork" { - t.Fatalf("%s during CallTool = %q, want compatwork", agentCodeEnv, got) + if got := fake.gotAgentEnv; got != "" { + t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got) } } func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) { t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -1706,14 +1702,14 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { } } -// 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 be consumed as agentCode. The request is still sent so -// PAT-core can apply its open-source default. +// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only +// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names +// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can +// apply its open-source default. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacyval") + t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -1771,21 +1767,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src, "qoderwork", agentCodeEnv) } - t.Setenv(agentCodeEnvCompat, "compatwork") - if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv { - t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want primary (%q, %q)", - code, src, "qoderwork", agentCodeEnv) - } - + // Reverse-guard: the draft reversed spelling is intentionally ignored. t.Setenv(agentCodeEnv, "") - if code, src := resolveAgentCodeFromEnv(); code != "compatwork" || src != agentCodeEnvCompat { - t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want compat (%q, %q)", - code, src, "compatwork", agentCodeEnvCompat) + t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork") + if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored", + code, src) } - // Empty primary + empty compat → ("", ""). + // Empty primary → ("", ""). t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") + t.Setenv("DWS_DINGTALK_AGENTCODE", "") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src) } @@ -1793,7 +1785,6 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { // Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the // canonical env is unset — it was hard-removed as a legacy alias. t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacy") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored", diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 9d754639..5e535f00 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -43,8 +43,8 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { 不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 - pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / - DWS_DINGTALK_AGENTCODE;CLI 会把显式 agentCode 放入 batch 请求参数, + pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE; + CLI 会把显式 agentCode 放入 batch 请求参数, 并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go index 9123ad0b..095a2f21 100644 --- a/test/unit/pat_host_owned_signal_test.go +++ b/test/unit/pat_host_owned_signal_test.go @@ -21,10 +21,9 @@ import ( // TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the // "custom authorization card" contract: the CLI switches to host-owned -// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE or its compatibility -// alias DWS_DINGTALK_AGENTCODE. DINGTALK_AGENT / claw-type is purely a -// server-side routing tag and must NOT influence the decision, in either -// direction. +// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT / +// claw-type is purely a server-side routing tag and must NOT influence the +// decision, in either direction. // // Regression guard: several earlier drafts conflated the two signals, // causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE @@ -34,7 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { cases := []struct { name string agentCode string - compat string + reversed string agentEnv string want bool }{ @@ -51,18 +50,11 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { want: true, }, { - name: "compat agent code only → host-owned", + name: "reversed draft agent code only → CLI-owned", agentCode: "", - compat: "agt-compat", + reversed: "agt-compat", agentEnv: "", - want: true, - }, - { - name: "primary wins when both env names are set", - agentCode: "agt-primary", - compat: "agt-compat", - agentEnv: "", - want: true, + want: false, }, { name: "agent code + DINGTALK_AGENT=default → host-owned", @@ -100,7 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, tc.agentCode) - t.Setenv(authpkg.AgentCodeEnvCompat, tc.compat) + t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed) // DINGTALK_AGENT is set purely to demonstrate that it does NOT // influence the host-owned decision. The literal env name is // used here because the auth package no longer exports a @@ -118,10 +110,6 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { gotCode, gotSource := authpkg.AgentCodeFromEnv() wantCode := tc.agentCode wantSource := authpkg.AgentCodeEnv - if wantCode == "" { - wantCode = tc.compat - wantSource = authpkg.AgentCodeEnvCompat - } if gotCode != wantCode || gotSource != wantSource { t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)", gotCode, gotSource, wantCode, wantSource) From 4eea61897d3e3ace2848199cec92fd22ac72289b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 16:48:52 +0800 Subject: [PATCH 19/23] fix(pat): guard chmod agent code mismatch --- internal/pat/chmod.go | 42 ++++++++++++++++++++++++++++++++++++++ internal/pat/chmod_test.go | 28 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 88c219cf..e58a5f7a 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -268,6 +268,9 @@ grantType 规则: if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) } + if err := ensurePATResultAgentCode(result, agentCode); err != nil { + return err + } return handleToolResult(cmd, c, result) } bold := color.New(color.FgYellow, color.Bold) @@ -294,6 +297,9 @@ grantType 规则: if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) } + if err := ensurePATResultAgentCode(planResult, agentCode); err != nil { + return err + } scopes, err = extractSelectedScopes(planResult) if err != nil { return err @@ -343,6 +349,9 @@ grantType 规则: if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } + if err := ensurePATResultAgentCode(result, agentCode); err != nil { + return err + } return handleToolResult(cmd, c, result) }, @@ -527,6 +536,39 @@ func firstToolResultText(result *edition.ToolResult) string { return "" } +func ensurePATResultAgentCode(result *edition.ToolResult, expectedAgentCode string) error { + expectedAgentCode = strings.TrimSpace(expectedAgentCode) + if expectedAgentCode == "" { + return nil + } + actualAgentCode := patResultAgentCode(result) + if actualAgentCode == "" || actualAgentCode == expectedAgentCode { + return nil + } + return fmt.Errorf( + "pat chmod returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent", + actualAgentCode, + expectedAgentCode, + agentCodeEnv, + ) +} + +func patResultAgentCode(result *edition.ToolResult) string { + text := firstToolResultText(result) + if text == "" { + return "" + } + var body map[string]any + if json.Unmarshal([]byte(text), &body) != nil { + return "" + } + data, _ := body["data"].(map[string]any) + if data == nil { + return "" + } + return stringField(data, "agentCode") +} + func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return patBatchResultHasCode(result, func(code string) bool { return strings.EqualFold(code, patBatchUnsupportedCode) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index d7441cd4..9b62aab9 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -700,6 +700,34 @@ func TestChmod_agentCode_compatEnvFallback(t *testing.T) { } } +func TestChmod_agentCode_envServerMismatchFails(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkkbjv3g","grantType":"permanent","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want agentCode mismatch error") + } + if !strings.Contains(err.Error(), "dingmbw5n9ktkkkbjv3g") || + !strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") { + t.Fatalf("error = %q, want both returned and expected agentCode", err.Error()) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want 1", len(fake.calls)) + } + if got := fake.calls[0].args["agentCode"]; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("batch grant agentCode = %#v, want DINGTALK_DWS_AGENTCODE", got) + } + if got := fake.calls[0].agentEnv; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("%s during CallTool = %q, want DINGTALK_DWS_AGENTCODE", agentCodeEnv, got) + } +} + func TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { t.Setenv(agentCodeEnv, "") t.Setenv(agentCodeEnvCompat, "") From 7f1d36c9ac90324e2593ec980817480f50e7be0b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 17:25:12 +0800 Subject: [PATCH 20/23] fix(pat): require yes for batch chmod --- internal/pat/chmod.go | 28 ++++++++++++++++++++++++++++ internal/pat/chmod_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index e58a5f7a..45537f1d 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -291,6 +291,18 @@ grantType 规则: return fmt.Errorf("internal error: tool runtime not initialized") } + if usesPlan && !batchAuthorizationConfirmed(cmd) { + return apperrors.NewValidation( + "batch PAT authorization requires explicit --yes before granting", + apperrors.WithReason("batch_auth_requires_yes"), + apperrors.WithHint("rerun with --dry-run to preview scopes, then add --yes to grant"), + apperrors.WithActions( + "dws pat chmod --recommend --product --grant-type --dry-run", + "dws pat chmod --recommend --product --grant-type --yes", + ), + ) + } + if usesPlan { planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true) planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) @@ -370,6 +382,22 @@ grantType 规则: return chmodCmd } +// batchAuthorizationConfirmed reads the inherited root --yes flag without +// making chmod own that flag. Only batch/product grants use this gate; explicit +// single-scope chmod keeps the historical non-interactive behavior. +func batchAuthorizationConfirmed(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + if yes, err := cmd.Flags().GetBool("yes"); err == nil { + return yes + } + if yes, err := cmd.InheritedFlags().GetBool("yes"); err == nil { + return yes + } + return false +} + func collectChmodProductCodes(groups ...[]string) []string { seen := map[string]bool{} result := make([]string, 0) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 9b62aab9..08994ae9 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -267,6 +267,14 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command { return newChmodCommand(fake) } +func setBatchYesForTest(t *testing.T, cmd *cobra.Command) { + t.Helper() + cmd.Flags().BoolP("yes", "y", false, "test-only root confirmation flag") + if err := cmd.Flags().Set("yes", "true"); err != nil { + t.Fatalf("set --yes: %v", err) + } +} + func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { root := &cobra.Command{Use: "dws"} RegisterCommands(root, &fakeToolCaller{}) @@ -298,6 +306,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar,aitable") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -335,6 +344,27 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } +func TestChmod_productsFlagRequiresYesBeforeGranting(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar") + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("chmod RunE error = nil, want --yes validation error") + } + if !strings.Contains(err.Error(), "requires explicit --yes") { + t.Fatalf("chmod RunE error = %v, want --yes requirement", err) + } + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want 0 before explicit --yes", len(fake.calls)) + } +} + func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ @@ -344,6 +374,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("products", "calendar") _ = cmd.Flags().Set("session-id", "session-123") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -441,6 +472,7 @@ func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -583,6 +615,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("recommend", "true") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -610,6 +643,7 @@ func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) From e637d793b025be88fb42643d8718ef67459687d1 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 20:40:15 +0800 Subject: [PATCH 21/23] fix(pat): verify chmod fallback agent code --- internal/pat/chmod.go | 53 ++++++++++++++++++++++++--- internal/pat/chmod_test.go | 73 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 45537f1d..e8e42e33 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -494,9 +494,16 @@ func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCal return result, err } compatArgs := cloneWithoutPATIdentityArgs(args) - return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + result, err = withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { return c.CallTool(ctx, "pat", toolName, compatArgs) }) + if err != nil { + return result, err + } + if err := ensurePATIdentityFallbackAgentCode(result, agentCode); err != nil { + return result, err + } + return result, nil } func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any { @@ -581,6 +588,29 @@ func ensurePATResultAgentCode(result *edition.ToolResult, expectedAgentCode stri ) } +func ensurePATIdentityFallbackAgentCode(result *edition.ToolResult, expectedAgentCode string) error { + expectedAgentCode = strings.TrimSpace(expectedAgentCode) + if expectedAgentCode == "" { + return nil + } + actualAgentCode := patResultAgentCode(result) + if actualAgentCode == expectedAgentCode { + return nil + } + if actualAgentCode == "" { + return fmt.Errorf( + "pat chmod identity fallback did not return agentCode %q; authorization target cannot be verified", + expectedAgentCode, + ) + } + return fmt.Errorf( + "pat chmod identity fallback returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent", + actualAgentCode, + expectedAgentCode, + agentCodeEnv, + ) +} + func patResultAgentCode(result *edition.ToolResult) string { text := firstToolResultText(result) if text == "" { @@ -590,11 +620,26 @@ func patResultAgentCode(result *edition.ToolResult) string { if json.Unmarshal([]byte(text), &body) != nil { return "" } + if code := stringField(body, "agentCode"); code != "" { + return code + } data, _ := body["data"].(map[string]any) - if data == nil { - return "" + if data != nil { + if code := stringField(data, "agentCode"); code != "" { + return code + } } - return stringField(data, "agentCode") + resultBody, _ := body["result"].(map[string]any) + if resultBody != nil { + if code := stringField(resultBody, "agentCode"); code != "" { + return code + } + resultData, _ := resultBody["data"].(map[string]any) + if resultData != nil { + return stringField(resultData, "agentCode") + } + } + return "" } func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 08994ae9..7e962c6a 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -466,7 +466,7 @@ func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { }, responses: []string{ "", - `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + `{"success":true,"data":{"agentCode":"qoderwork","allGranted":true,"selectedScopes":[]}}`, }, } cmd := newChmodCommand(fake) @@ -509,7 +509,7 @@ func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { }, responses: []string{ "", - `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"agentCode":"qoderwork","grantedScopes":["calendar.event:read"]}}`, }, } cmd := newChmodCommand(fake) @@ -535,6 +535,75 @@ func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { } } +func TestChmod_batchGrantIdentityFallbackRejectsMismatchedAgentCode(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"result":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want identity fallback agentCode mismatch") + } + if !strings.Contains(err.Error(), "identity fallback returned agentCode") || + !strings.Contains(err.Error(), "dingmbw5n9ktkkbbjv3g") || + !strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") { + t.Fatalf("error = %q, want fallback mismatch details", err.Error()) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry should still omit agentCode arg, got %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("compat retry %s = %q, want requested agentCode", agentCodeEnv, got) + } +} + +func TestChmod_batchGrantIdentityFallbackRejectsMissingAgentCode(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"grantedScopes":["chat.message:send"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want unverifiable fallback error") + } + if !strings.Contains(err.Error(), "authorization target cannot be verified") { + t.Fatalf("error = %q, want unverifiable fallback details", err.Error()) + } +} + func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { t.Setenv(sessionIDEnvDingtalk, "ding-session") t.Setenv(sessionIDEnvDWS, "dws-session") From 2e11a2338109b6ffd6018f35df7556b607b0129f Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 20:44:59 +0800 Subject: [PATCH 22/23] fix(keychain): add windows storage dir for packaging --- internal/keychain/keychain_windows.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index 908c83a4..cc715903 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -18,6 +18,8 @@ package keychain import ( "encoding/base64" "fmt" + "os" + "path/filepath" "regexp" "strings" "unsafe" @@ -32,6 +34,20 @@ import ( const regRootPath = `Software\DwsCli\keychain` +// StorageDir returns the storage directory for file-based keychain artifacts on Windows. +func StorageDir(service string) string { + if override := os.Getenv(StorageDirEnv); override != "" { + return filepath.Join(override, service) + } + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "DwsCli", "keychain", service) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, "AppData", "Roaming", "DwsCli", "keychain", service) + } + return filepath.Join(".dws", "keychain", service) +} + func registryPathForService(service string) string { return regRootPath + `\` + safeRegistryComponent(service) } From 9f744caafd5714aada79f15a9637c2540fef91f1 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 21:07:13 +0800 Subject: [PATCH 23/23] chore(config): default mcp endpoint to prepub --- internal/app/skill_command.go | 4 ++-- internal/auth/endpoints.go | 2 +- internal/cli/canonical_test.go | 2 +- internal/cli/loader.go | 2 +- internal/market/registry.go | 2 +- pkg/config/constants.go | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 53e0decb..263f8941 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -40,14 +40,14 @@ func init() { Name: "DWS_SKILL_API_HOST", Category: configmeta.CategoryNetwork, Description: "覆盖 Skill API 地址", - DefaultValue: "https://mcp.dingtalk.com", + DefaultValue: "https://pre-mcp.dingtalk.com", Example: "https://custom-mcp.example.com", }) } const ( // legacySkillAPIHost is the legacy skill market host used by the old cli. - legacySkillAPIHost = "https://mcp.dingtalk.com" + legacySkillAPIHost = "https://pre-mcp.dingtalk.com" // skillDownloadEndpoint is the API endpoint for downloading skills. skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download" // skillDownloadTimeout is the timeout for skill download operations. diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 3f3b3303..24d5204a 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -129,7 +129,7 @@ func GetDeveloperSettingsURL() string { // GetMCPBaseURL returns the MCP base URL with priority: // 1. ~/.dws/mcp_url file content (for pre-release environment) -// 2. Default value (https://mcp.dingtalk.com) +// 2. Default value (https://pre-mcp.dingtalk.com) func GetMCPBaseURL() string { return config.GetMCPBaseURL() } diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index 2bd5e0b6..7e009431 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -1285,7 +1285,7 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) { degradedErr := &CatalogDegraded{ Reason: DegradedMarketUnreachable, - Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络", + Hint: "无法连接 MCP 市场,请检查网络", } cmd := NewSchemaCommand(errorLoader{err: degradedErr}) diff --git a/internal/cli/loader.go b/internal/cli/loader.go index b67edce9..3810be5e 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string { if embedded { return "无法连接 MCP 市场,请检查网络" } - return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络" + return "无法连接 MCP 市场,请检查网络" case DegradedRuntimeAllFailed: if embedded { return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount) diff --git a/internal/market/registry.go b/internal/market/registry.go index d45af638..755fd6db 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -31,7 +31,7 @@ import ( ) const ( - defaultBaseURL = "https://mcp.dingtalk.com" + defaultBaseURL = "https://pre-mcp.dingtalk.com" registryMetadataKey = "com.dingtalk.mcp.registry/metadata" ) diff --git a/pkg/config/constants.go b/pkg/config/constants.go index c986e45a..8f2bbe65 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -163,7 +163,7 @@ const ( const ( // DefaultMCPBaseURL is the DingTalk MCP base URL. // Override at runtime via ~/.dws/mcp_url file. - DefaultMCPBaseURL = "https://mcp.dingtalk.com" + DefaultMCPBaseURL = "https://pre-mcp.dingtalk.com" // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. @@ -189,7 +189,7 @@ func DefaultConfigDir() string { // GetMCPBaseURL returns the MCP base URL with priority: // 1. ~/.dws/mcp_url file content (for custom environment) -// 2. Default value (https://mcp.dingtalk.com) +// 2. Default value (https://pre-mcp.dingtalk.com) func GetMCPBaseURL() string { mcpURLPath := filepath.Join(DefaultConfigDir(), "mcp_url") if data, err := os.ReadFile(mcpURLPath); err == nil {