From dd42fd833b41a53f36ed09b74624cf7c1117a987 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 29 May 2026 15:22:00 +0800 Subject: [PATCH 01/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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 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) - } } // --------------------------------------------------------------------------- @@ -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 4eea61897d3e3ace2848199cec92fd22ac72289b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 10 Jun 2026 16:48:52 +0800 Subject: [PATCH 14/61] 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 15/61] 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 16/61] fix(pat): verify chmod fallback agent code --- internal/pat/chmod.go | 55 +++++++++++++++++++++++++--- internal/pat/chmod_test.go | 73 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 7 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 "" } - data, _ := body["data"].(map[string]any) - if data == nil { - return "" + if code := stringField(body, "agentCode"); code != "" { + return code } - return stringField(data, "agentCode") + data, _ := body["data"].(map[string]any) + if data != nil { + if code := stringField(data, "agentCode"); code != "" { + return code + } + } + 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 17/61] 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 18/61] 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 { From 11dfd4ccf20ad0e1d1f8ce68843fbc2b0274014d Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 14:09:15 +0800 Subject: [PATCH 19/61] feat: add dws recommended batch auth flow --- docs/batch-auth-prd.json | 379 +++++++++++++++++ ...lippi-open-dev-front-batch-auth-handoff.md | 398 ++++++++++++++++++ internal/app/auth_command.go | 55 ++- internal/app/pat_auth_retry.go | 52 ++- internal/app/pat_auth_retry_test.go | 71 ++++ internal/app/root.go | 8 +- internal/app/tool_caller_adapter.go | 8 +- internal/errors/errors.go | 3 +- internal/errors/pat.go | 7 +- internal/errors/pat_test.go | 31 ++ internal/jsonutil/json.go | 44 ++ internal/output/filter.go | 3 +- internal/output/filter_test.go | 14 + internal/output/formatter.go | 7 +- internal/output/formatter_extra_test.go | 29 ++ internal/output/ndjson.go | 1 + internal/output/ndjson_csv_test.go | 5 + internal/pat/chmod.go | 187 ++++++-- internal/pat/chmod_test.go | 73 +++- 19 files changed, 1309 insertions(+), 66 deletions(-) create mode 100644 docs/batch-auth-prd.json create mode 100644 docs/lippi-open-dev-front-batch-auth-handoff.md create mode 100644 internal/jsonutil/json.go diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json new file mode 100644 index 00000000..01cb0d3a --- /dev/null +++ b/docs/batch-auth-prd.json @@ -0,0 +1,379 @@ +{ + "title": "DWS Batch Authorization and Login Recommend Flow", + "version": "2026-06-16", + "owners": { + "cli": "dingtalk-workspace-cli", + "gateway": "lippi-open-mcpserver-gw", + "patCore": "lippi-pat-core-wukong-capability", + "bff": "lippi-open-app-dev", + "frontend": "lippi-open-dev-front" + }, + "context": { + "problem": "DWS login --recommend and product-line batch authorization need a user-visible authorization page instead of silent bulk grants or local --yes-only blocking.", + "reference": { + "larkCli": { + "loginRecommend": "lark-cli auth login --recommend", + "domainAll": "lark-cli auth login --domain all", + "noWait": "lark-cli auth login --no-wait --json", + "deviceCode": "lark-cli auth login --device-code ", + "exclude": "lark-cli auth login --exclude ", + "scopeCatalog": "lark-cli auth scopes", + "scopeCheck": "lark-cli auth check --scope ", + "qrCode": "lark-cli auth qrcode " + }, + "ralphCli": { + "model": "PRD-driven long-running development with explicit task ledger, progress notes, verification gates, and failure analysis.", + "evidence": "ralph v0.16.0 help and /Users/xuan/knowledge/META-ralph-reference.md", + "mapping": "This PRD keeps a single SSOT for goals, contracts, tasks, security gates, acceptance criteria, and blocked evidence so implementation can continue across multiple repositories without redefining done." + }, + "existingDingTalkPage": "/src/app/personalAuthorization/index.jsx", + "targetPage": "Reuse the personalAuthorization route and render multiple scopes grouped by product." + }, + "accessNotes": [ + "The frontend GitLab repo was cloned through SSH to /Users/xuan/lippi-open-dev-front and implemented on branch codex/batch-auth-page-flow.", + "The DWS PAT BFF lives in /Users/xuan/lippi-open-app-dev and needs to be part of the chain because it owns /dingtalk-workspace-cli/oauth/device/action.", + "Acceptance is not complete after local build. It is complete only after prepub package/build succeeds for every changed repo and prepub functional verification passes.", + "The referenced DingTalk minutes URL could not be fetched because local dws is not logged in." + ] + }, + "goals": [ + "After dws auth login --recommend succeeds, DWS starts a PAT recommended batch plan and opens the authorization page.", + "For PAT batch authorization commands through dws pat chmod without --yes, including multi-scope, product-line, and recommend modes, DWS opens the same authorization page instead of returning a local validation error.", + "The server, not the CLI, selects grantable scopes. CLI must grant only selectedScopes from pat.batch_plan.", + "Recommended login must never auto-select destructive scopes such as delete, recall, remove, destroy, or drop.", + "High-risk scopes remain pending and must not be silently granted." + ], + "nonGoals": [ + "Do not trust CLI-provided orgId or uid.", + "Do not add a second authorization UI path.", + "Do not make high-risk destructive scopes part of default login recommend." + ], + "userFlows": [ + { + "name": "loginRecommend", + "steps": [ + "User runs dws auth login --recommend.", + "CLI completes OAuth loopback or device login and saves token.", + "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", + "PAT core loads the recommended set, filters destructive scopes server-side, and returns selectedScopes plus warnings/skipped scopes.", + "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", + "PAT core creates a single batch Device Code Flow and returns PAT_BATCH_AUTH_PENDING with flowId/userCode/uri.", + "CLI opens uri, polls flow status, and retries the original grant after approval." + ] + }, + { + "name": "batchChmodWithoutYes", + "steps": [ + "User runs dws pat chmod --recommend --product without --yes.", + "CLI plans first, then starts a batch Device Code Flow instead of direct grant.", + "The authorization page displays all grantable selected scopes grouped by product.", + "Approve writes selected grants; reject leaves all grants unchanged." + ] + }, + { + "name": "batchChmodWithYes", + "steps": [ + "User runs the same command with --yes.", + "CLI continues the existing non-interactive grant path.", + "PAT core writes grantable low/medium-risk scopes directly and returns high-risk scopes as pending." + ] + } + ], + "larkCliParity": [ + { + "lark": "auth login --recommend", + "dws": "dws auth login --recommend", + "serverContract": "pat.batch_plan recommend=true caller=auth_login_recommend, then pat.batch_grant selectedScopes startFlow=true noWait=true", + "status": "implemented locally in CLI, gateway, PAT core, BFF, and frontend; prepub package and verification pending" + }, + { + "lark": "auth login --domain |all", + "dws": "dws pat chmod --product or --products <...>", + "serverContract": "pat.batch_plan productCodes=[...] resolves service-owned product scope templates", + "status": "implemented for PAT chmod product-line flow" + }, + { + "lark": "auth login --scope ", + "dws": "dws pat chmod ", + "serverContract": "one scope preserves direct grant behavior; multiple scopes enter batch plan/flow when --yes is absent", + "status": "implemented for multi-scope PAT chmod" + }, + { + "lark": "auth login --exclude ", + "dws": "batch authorization page selectedScopes", + "serverContract": "frontend sends selectedScopes; PAT core rejects any selected scope outside flow scopes and treats unchecked flow scopes as rejectedScopes", + "status": "implemented locally in PAT core, BFF selectedScopes passthrough, and frontend grouped selection; prepub verification pending" + }, + { + "lark": "auth login --no-wait --json and later --device-code", + "dws": "PAT_BATCH_AUTH_PENDING JSON with uri/authUrl/flowId/userCode plus polling retry", + "serverContract": "host-owned JSON mode returns structured pending and does not block; local interactive mode opens browser and polls", + "status": "implemented in CLI retry path and PAT pending response" + }, + { + "lark": "auth scopes and auth check", + "dws": "pat.batch_plan dryRun and existing PAT auth check result", + "serverContract": "plan exposes selected/skipped/pending/already-granted scope sets; check/auth retry carries missing scope and grant options", + "status": "partially aligned; no new public scope-catalog command added in this slice" + } + ], + "ralphExecutionPlan": { + "project": "dws-batch-authorization-page", + "tasks": [ + { + "id": "1", + "title": "Build PRD and command parity map", + "description": "Use product-design style requirements plus Ralph-style SSOT to map DWS batch authorization against lark-cli.", + "steps": [ + "Define user flows for login recommend, product-line grant, multi-scope grant, yes direct grant, and JSON no-wait mode.", + "Record frontend access and DingTalk minutes access blockers explicitly.", + "Keep acceptance criteria tied to command outputs and service contracts." + ], + "done": true + }, + { + "id": "2", + "title": "Implement DWS CLI batch authorization entry points", + "description": "dws auth login --recommend and dws pat chmod batch/product/recommend without --yes start the page-based flow.", + "steps": [ + "Add --recommend to auth login and run PAT recommended plan after login.", + "Route product-line, recommend, and multi-scope chmod without --yes to pat.batch_grant startFlow.", + "Keep --yes as the explicit direct-write path." + ], + "done": true + }, + { + "id": "3", + "title": "Harden gateway identity and caller allowlist", + "description": "The gateway must derive orgId/uid from trusted identity and allow intended DWS batch callers.", + "steps": [ + "Keep CLI-provided orgId/uid untrusted.", + "Allow dws-cli alongside openclaw for intended batch write callers.", + "Preserve caller/source checks for default grant writes." + ], + "done": true + }, + { + "id": "4", + "title": "Implement PAT core recommended filtering and batch DCF", + "description": "PAT core owns selectedScopes, destructive filtering, batch Device Code Flow, selected approval, and idempotent writes.", + "steps": [ + "Filter destructive recommended scopes server-side for auth_login_recommend.", + "Create/reuse one batch flow for selected scopes.", + "Approve only selectedScopes and reject out-of-flow selections.", + "Fail closed on critical storage failures and leave flow retryable." + ], + "done": true + }, + { + "id": "5", + "title": "Implement lippi-open-dev-front batch authorization UI", + "description": "Reuse /src/app/personalAuthorization/index.jsx and render grouped product permissions with selectable scopes.", + "steps": [ + "Load flow detail and detect multiple scopes.", + "Default-check all server-selected flow scopes.", + "Allow product and individual-scope deselection.", + "Submit selectedScopes on approve and no orgId/uid.", + "Handle PAT_BATCH_PARTIAL_FAILURE by keeping the page retryable." + ], + "done": true + }, + { + "id": "6", + "title": "Submit all repos to prepub and verify package plus behavior", + "description": "Acceptance only passes after prepub package/build succeeds and prepub functional verification is OK.", + "steps": [ + "Push or submit branches for CLI, gateway, PAT core, BFF, and frontend.", + "Trigger prepub package/build for every changed repo.", + "Verify prepub deployed versions include the current change set.", + "Run login recommend and batch chmod functional checks on prepub.", + "Open the prepub authorization page and verify layout and approve/reject callbacks." + ], + "done": false + } + ] + }, + "prepubReleaseGate": { + "overallStatus": "not_passed_until_prepub_package_and_functional_verification_ok", + "changedRepos": [ + { + "repo": "dingtalk-workspace-cli", + "localPath": "/Users/xuan/dingtalk-workspace-cli", + "branch": "codex/dws-auth-login-recommend", + "prepubPackage": "pending", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-mcpserver-gw", + "localPath": "/Users/xuan/lippi-open-mcpserver-gw", + "branch": "codex/pat-batch-identity-args-gw", + "prepubPackage": "pending", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-pat-core-wukong-capability", + "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", + "branch": "codex/batch-auth-page-flow", + "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-app-dev", + "localPath": "/Users/xuan/lippi-open-app-dev", + "branch": "codex/batch-auth-bff-selected-scopes", + "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-dev-front", + "localPath": "/Users/xuan/lippi-open-dev-front", + "branch": "codex/batch-auth-page-flow", + "prepubPackage": "local_npm_build_passed_prepub_package_pending", + "prepubFunctionalVerification": "pending" + } + ], + "requiredEvidence": [ + "Every changed repo has a pushed branch or prepub release submission tied to the current change set.", + "Prepub package/build succeeds for CLI, gateway, PAT core, BFF, and frontend.", + "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", + "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, destructive-scope filtering, high-risk pending, and JSON no-wait pending response.", + "Prepub UI screenshot matches visualDesignTarget." + ] + }, + "serviceContract": { + "pat.batch_plan": { + "request": { + "scopes": "optional string[]", + "productCodes": "optional string[]", + "recommend": "boolean", + "grantType": "once|session|permanent", + "dryRun": "boolean", + "caller": "auth_login_recommend|batch_chmod_interactive|..." + }, + "response": { + "selectedScopes": "server-selected grantable scopes", + "skippedScopes": "not grantable or filtered scopes", + "pendingScopes": "high-risk scopes requiring separate confirmation", + "warnings": "destructive filter and partial selection warnings" + } + }, + "pat.batch_grant": { + "request": { + "scopes": "required string[], must be selectedScopes from plan", + "grantType": "once|session|permanent", + "startFlow": "boolean; true opens authorization page", + "noWait": "boolean; true returns URL immediately", + "clientRequestId": "idempotency key", + "caller": "auth_login_recommend|batch_chmod_interactive|..." + }, + "pendingResponse": { + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "data": { + "flowId": "batch device flow id", + "userCode": "device user code", + "uri": "opaque authorization URL", + "authUrl": "same URL for compatibility", + "pollIntervalSeconds": 3, + "expiresInSeconds": "PatSwitch.deviceCodeFlowTtlSeconds" + } + } + }, + "deviceFlowDetail": { + "route": "personalAuthorization", + "fields": [ + "agentCode", + "appName", + "appIcon", + "userName", + "userAvatar", + "status", + "batchFlow", + "defaultGrantType", + "scopes[]" + ], + "scopeFields": [ + "scope", + "displayName", + "productCode", + "productName", + "riskLevel", + "effectiveRiskLevel", + "riskDesc", + "operationSummary" + ] + }, + "handleFlowAction": { + "request": { + "flowId": "required string", + "userCode": "required string", + "action": "approve|reject", + "grantType": "optional once|session|permanent; batch page omits it so PAT core uses defaultGrantType from the flow", + "selectedScopes": "required for the new batch page when action=approve; must be a non-empty subset of flow.scopes" + }, + "compatibility": { + "selectedScopesNull": "legacy clients approve all flow-stored scopes", + "selectedScopesEmpty": "INVALID_PARAMETER", + "selectedScopesOutsideFlow": "INVALID_PARAMETER" + }, + "response": { + "status": "APPROVED|REJECTED", + "approvedScopes": "scopes written by this approve action", + "rejectedScopes": "flow scopes not approved by this action" + }, + "errorBehavior": { + "approveStorageFailure": "PAT_BATCH_PARTIAL_FAILURE; flow remains PENDING and the page can retry. Once tokens written by the current flow are cleaned by flowId ownership; permanent/session grants rely on transaction rollback and grant idempotency." + } + } + }, + "frontendRequirements": [ + "Reuse personalAuthorization page and API detail/action callbacks.", + "When scopes length is greater than one, render a grouped batch authorization surface.", + "Group by productCode/productName with product-level checkbox and expandable scope details.", + "Default checked state is all safe selected scopes.", + "Users can uncheck a product group or individual scopes; approving with zero selected scopes is disabled.", + "Approve must send selectedScopes matching the current checked scope list.", + "Reject rejects the whole flow.", + "Do not expose orgId or uid as trusted client input." + ], + "visualDesignTarget": { + "sourceImage": "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-7792e4ca-7e4b-446b-8527-f35609d4df12.png", + "page": "A white authorization card centered on a light #f5f6fa page, with the footer links below the card.", + "card": "Single white card with subtle #f2f2f6 border, soft shadow, 12px radius, desktop visual width about 660px and enough height to show account, permissions, manage tip, and two bottom buttons without scrolling for four product groups.", + "header": "Top centered CLI app icon, title DingtalkWorkSpace, and subtitle 请求以下钉钉账号进行授权.", + "accountBlock": "Light gray rounded account row below the header, with a blue square DingTalk avatar/tag at left and the current account name next to it.", + "permissionBlock": "Light gray rounded permission panel titled 可授权的权限. Product rows use a blue checked checkbox, bold product name, gray count such as 12 项, gray description text, and a right chevron for expandable details.", + "selectionBehavior": "All recommended groups are checked by default. Product checkbox toggles the entire group; expanding a product reveals individual scope checkboxes.", + "manageTip": "Under the permission panel, show the existing 个人数据授权管理 link text for later review or revoke.", + "buttons": "Bottom two-button layout: left outlined white 拒绝 button, right primary blue 同意并授权 button, same height and rounded pill style.", + "mobile": "On narrow screens the same hierarchy must remain visible without text overlap; buttons share the available width." + }, + "securityRules": [ + "Gateway derives orgId and uid from trusted token/header only.", + "Gateway allowlist must include openclaw and dws-cli for batch grant callers that are intended to write.", + "PAT core applies destructive-scope hard filtering for recommend=true and caller=auth_login_recommend.", + "PAT core must use flow-stored scopes as the allowlist and reject any selectedScopes value outside the flow.", + "PAT core must only clear pending keys owned by the current flow and leave the flow pending if approve write fails after precheck, so the same authorization page can retry safely.", + "Critical approve/reject storage writes must fail closed with PAT_BATCH_PARTIAL_FAILURE instead of reporting success on partial persistence.", + "Batch flow approval must be idempotent; repeated approval or retry must not duplicate grants." + ], + "acceptanceCriteria": [ + "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", + "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", + "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", + "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", + "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", + "On prepub, a recommended config containing a delete/recall/remove/destroy/drop scope returns warnings/skipped scopes and never grants that scope.", + "On prepub, approving a batch flow grants only selectedScopes and rejects scopes outside the flow.", + "On prepub, rejecting a batch flow writes no new grants.", + "On prepub, JSON mode returns structured PAT_BATCH_AUTH_PENDING with uri/openBrowser fields and does not block.", + "On prepub, the authorization page visual result matches visualDesignTarget and the provided screenshot style." + ], + "reviewPlan": { + "implementationCommander": "Check end-to-end CLI, gateway, PAT core integration contracts.", + "aiNativeUiArchitect": "Requested by goal but no local skill was available; use Product Design and existing frontend constraints as fallback.", + "qinzePuaReviewer": "Review security, idempotency, tests, and failure modes.", + "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", + "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." + } +} diff --git a/docs/lippi-open-dev-front-batch-auth-handoff.md b/docs/lippi-open-dev-front-batch-auth-handoff.md new file mode 100644 index 00000000..94c4c96a --- /dev/null +++ b/docs/lippi-open-dev-front-batch-auth-handoff.md @@ -0,0 +1,398 @@ +# lippi-open-dev-front 批量授权页改造交接稿 + +日期: 2026-06-16 + +## 当前状态 + +- 目标仓库: `http://gitlab.alibaba-inc.com/dingding/lippi-open-dev-front.git` +- 目标页面: `/src/app/personalAuthorization/index.jsx` +- 已通过 SSH clone 到: `/Users/xuan/lippi-open-dev-front` +- 前端实现分支: `codex/batch-auth-page-flow` +- 已在真实页面落代码,并通过 `npm ci` 与 `npm run build`;build 只有既有 asset size warning。 +- 额外链路修复: `/Users/xuan/lippi-open-app-dev` 的 BFF 需要透传 `selectedScopes`,否则页面取消勾选会被 PAT core 兼容语义批准为全部 scopes。 + +## 链路目标 + +`dws auth login --recommend` 和所有 PAT 批量授权指令缺少 `--yes` 时,都进入同一条 Device Code Flow。 +这里的“批量授权指令”限定为 `dws pat chmod` 的多 scope、`--product/--products/--domain/--domains` 产品线授权、`--recommend` 推荐授权;业务层 `batch-delete`、`batch-send`、AI 表格批量更新等仍走各自的业务确认或敏感操作逻辑,不进入 PAT 授权页。 + +1. CLI 调 `pat.batch_plan`,只拿服务端选出的 `selectedScopes`。 +2. CLI 调 `pat.batch_grant`,带 `startFlow=true,noWait=true`。 +3. PAT core 返回 `PAT_BATCH_AUTH_PENDING`,包含 `flowId/userCode/uri/authUrl`。 +4. CLI 打开 `uri`,页面通过 `flowId/userCode` 拉取 flow 详情。 +5. 页面展示所有 flow 中保存的 scopes,点击同意后调用 approve。 +6. CLI 轮询到 `APPROVED` 后重试原授权动作。 + +这和 lark-cli 的 agent split-flow 保持同一类能力: lark-cli 有 `auth login --recommend`、`auth login --domain `、`auth login --scope `、`auth login --exclude `、`auth login --no-wait --json` 返回授权 URL/设备码,再用 `--device-code` 完成;DWS 本地 CLI 场景可直接打开页面并轮询,JSON/host-owned 场景仍输出结构化 pending。 + +Ralph-cli 参考点不是页面能力,而是工程编排方式: 用 PRD 作为 SSOT,显式记录任务账本、验收项、阻塞证据和失败分析。本次 PRD 也采用这种结构,避免跨 CLI/GW/PAT/frontend 多仓改造时把“已实现的服务端链路”和“待拿权限落地的前端页面”混在一起。 + +## 服务端契约 + +### `pat.batch_grant` pending 返回 + +```json +{ + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "message": "部分权限需要用户确认", + "data": { + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "uri": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", + "authUrl": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", + "pollIntervalSeconds": 3, + "expiresInSeconds": 600 + } +} +``` + +### `queryFlowDetail` + +页面只可信任 `flowId/userCode`,不要从 URL 或客户端参数里拼 `orgId/uid`。BFF/HSF 继续由登录态派生 `uid/orgId`。 + +```json +{ + "agentCode": "qoderwork", + "appName": "DingtalkWorkSpace", + "appIcon": "https://...", + "userName": "小钉", + "userAvatar": "https://...", + "status": "PENDING", + "batchFlow": true, + "defaultGrantType": "permanent", + "scopes": [ + { + "scope": "calendar.event:read", + "displayName": "读取日程", + "productCode": "calendar", + "productName": "日历", + "riskLevel": 0, + "effectiveRiskLevel": 0, + "riskDesc": "低风险", + "operationSummary": "读取你的日历数据" + } + ] +} +``` + +### `handleFlowAction` + +approve 必须携带页面当前选中的 `selectedScopes`。旧前端不传该字段时,PAT core 兼容为批准 flow 中全部 scopes;新批量页必须显式传,保证 UI 勾选状态与授权真值一致。 + +```json +{ + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "action": "approve", + "selectedScopes": [ + "calendar.event:read", + "aitable.record:write" + ] +} +``` + +拒绝: + +```json +{ + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "action": "reject" +} +``` + +服务端规则: + +- `selectedScopes == null`: 兼容旧前端,批准 flow 中服务端持久化的全部 scopes。 +- `selectedScopes.isEmpty()`: 返回 `INVALID_PARAMETER`,前端应在 0 项时禁用“同意并授权”并引导用户拒绝。 +- 非空: 必须全部属于 flow scopes,否则返回 `INVALID_PARAMETER`。 +- approve/reject 完成后只清理当前 flow 拥有的 pending keys,避免误删同 uid/agent/scope 的其它 pending flow。 +- partial approve 返回 `approvedScopes` 与 `rejectedScopes`;未选 scope 会进入 `rejectedScopes`,表示被本次用户操作取消。 +- 如果 approve 写入阶段失败,PAT core 返回 `PAT_BATCH_PARTIAL_FAILURE` 并保持 flow 为 `PENDING`,前端展示错误并允许重试;once token 会按 flowId 归属清理,permanent/session grant 依赖事务与幂等逻辑吸收重试。 + +## 页面改造建议 + +### 1. 识别批量 flow + +在旧 `personalAuthorization/index.jsx` 查询详情后: + +```jsx +const scopes = Array.isArray(detail?.scopes) ? detail.scopes : []; +const isBatch = scopes.length > 1; +``` + +单 scope 保持旧页面。多 scope 进入批量布局。 + +### 2. scope 分组适配 + +```jsx +function groupScopes(scopes = []) { + const map = new Map(); + scopes.forEach((item) => { + const productCode = item.productCode || "unknown"; + const productName = item.productName || item.productCode || "其他权限"; + if (!map.has(productCode)) { + map.set(productCode, { + productCode, + productName, + summary: item.operationSummary || item.displayName || "", + scopes: [], + }); + } + map.get(productCode).scopes.push(item); + }); + return Array.from(map.values()); +} +``` + +### 3. 批量权限组件 + +默认全选,允许取消产品组或单个 scope。至少选中 1 项才允许点击“同意并授权”;如果用户不想授权任何项,点击“拒绝”。 + +```jsx +function BatchScopeList({ scopes, checkedScopes, onCheckedScopesChange }) { + const [expanded, setExpanded] = React.useState({}); + const groups = React.useMemo(() => groupScopes(scopes), [scopes]); + const checkedSet = React.useMemo(() => new Set(checkedScopes), [checkedScopes]); + + const toggleScope = (scope, checked) => { + const next = new Set(checkedSet); + if (checked) { + next.add(scope); + } else { + next.delete(scope); + } + onCheckedScopesChange(Array.from(next)); + }; + + const toggleGroup = (group, checked) => { + const next = new Set(checkedSet); + group.scopes.forEach((item) => { + if (checked) { + next.add(item.scope); + } else { + next.delete(item.scope); + } + }); + onCheckedScopesChange(Array.from(next)); + }; + + return ( +
+
可授权的权限
+ {groups.map((group) => { + const open = expanded[group.productCode]; + const groupScopes = group.scopes.map((item) => item.scope); + const checkedCount = groupScopes.filter((scope) => checkedSet.has(scope)).length; + const groupChecked = checkedCount === groupScopes.length; + const groupIndeterminate = checkedCount > 0 && checkedCount < groupScopes.length; + return ( +
+
+ { + if (node) { + node.indeterminate = groupIndeterminate; + } + }} + onChange={(event) => toggleGroup(group, event.target.checked)} + aria-label={`${group.productName} ${checkedCount}/${groupScopes.length} 已选择`} + /> + +
+
+ {group.summary || group.scopes.map((scope) => scope.displayName || scope.scope).slice(0, 2).join("、")} +
+ {open && ( +
+ {group.scopes.map((scope) => ( +
+ toggleScope(scope.scope, event.target.checked)} + aria-label={`${scope.displayName || scope.scope} 已选择`} + /> +
+
{scope.displayName || scope.scope}
+
{scope.scope}
+ {scope.riskDesc &&
{scope.riskDesc}
} +
+
+ ))} +
+ )} +
+ ); + })} +
+ ); +} +``` + +如果目标项目有 Checkbox/Icon/Button 组件,应替换原生 input 和字符箭头,保持旧授权页和钉钉 Design Token 的视觉一致性,不要引入新视觉体系。 + +### 4. approve/reject 调用 + +复用旧页面已有的 action API,只改 payload: + +```jsx +const [checkedScopes, setCheckedScopes] = React.useState([]); + +React.useEffect(() => { + setCheckedScopes(scopes.map((item) => item.scope).filter(Boolean)); +}, [scopes]); + +async function approve() { + if (checkedScopes.length === 0) { + return; + } + await handleFlowAction({ + flowId, + userCode, + action: "approve", + selectedScopes: checkedScopes, + // grantType 可不传,PAT core 会使用 flow 创建时持久化的 defaultGrantType。 + ...(grantType ? { grantType } : {}), + }); + setStatus("APPROVED"); +} + +async function reject() { + await handleFlowAction({ flowId, userCode, action: "reject" }); + setStatus("REJECTED"); +} +``` + +不要把 `orgId/uid/agentCode` 作为可信入参传给 action。`selectedScopes` 只是用户选择结果,服务端必须校验它是 flow scopes 的子集。 + +### 5. 文案与状态 + +- 标题: `{appName || "DingtalkWorkSpace"}` +- 副标题: `请求以下钉钉账号进行授权` +- 列表标题: `可授权的权限` +- 主按钮: `同意并授权` +- 次按钮: `拒绝` +- 0 项选中: 主按钮禁用,辅助文案“请选择至少 1 项权限,或拒绝本次授权”。 +- 空 scope: 展示“没有需要授权的权限”,主按钮禁用。 +- `APPROVED`: 标题“授权已完成”,说明“可回到命令行继续”,禁用操作按钮。 +- `REJECTED`: 标题“已拒绝授权”,说明“命令行将收到拒绝结果”,禁用操作按钮。 +- `EXPIRED`: 标题“授权已过期”,说明“请回到命令行重新发起授权”,禁用操作按钮。 +- `ERROR`: 展示错误说明,保留“重试加载”按钮;不要重复提交 approve。 + +### 6. 样式方向 + +保持截图中的居中白色授权卡片、8px 左右圆角、浅灰权限面板、DingTalk 蓝色主按钮。不要做营销页或额外 hero。页面首屏应直接是授权确认。 + +AI-native / 钉钉授权页约束: + +- 首屏元素控制在 7 个以内: 应用图标、应用名、账号行、权限面板、辅助说明、拒绝按钮、同意按钮。 +- 权限面板内部滚动,卡片最大高度建议 `min(720px, calc(100vh - 160px))`。 +- 多产品默认只展示产品组摘要,明细折叠;展开不推动底部按钮离屏。 +- 底部操作固定在卡片底部,移动端 375px 宽度下仍可直接拒绝/同意。 +- 终态页不再展示可编辑 checkbox,避免用户误以为还能修改。 + +建议 CSS 类: + +```css +.pa-scope-panel { + margin-top: 12px; + padding: 12px 16px; + background: #f7f8fa; + border-radius: 8px; + max-height: min(420px, calc(100vh - 360px)); + overflow-y: auto; +} + +.pa-product-row { + padding: 12px 8px; +} + +.pa-product-main { + display: flex; + align-items: center; + gap: 12px; +} + +.pa-product-button { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + border: 0; + background: transparent; + padding: 0; + text-align: left; + cursor: pointer; +} + +.pa-product-name { + font-weight: 600; + color: #1f2329; +} + +.pa-product-count { + margin-left: 8px; + color: #8f959e; +} + +.pa-product-arrow { + margin-left: auto; + color: #8f959e; +} + +.pa-product-desc, +.pa-scope-code, +.pa-scope-risk { + color: #8f959e; + font-size: 13px; + line-height: 20px; +} + +.pa-scope-detail-list { + margin: 10px 0 0 32px; + padding: 8px 12px; + border-radius: 6px; + background: #fff; +} + +.pa-scope-detail + .pa-scope-detail { + border-top: 1px solid #eff0f2; +} + +.pa-scope-detail { + display: flex; + gap: 10px; + padding: 8px 0; +} + +.pa-scope-copy { + min-width: 0; +} +``` + +### 7. 验收用例 + +1. 单 scope flow 仍渲染旧授权页。 +2. 多 scope flow 按 `productCode/productName` 分组,默认全部勾选展示。 +3. 点击产品组 checkbox 可整组选择/取消;点击单 scope checkbox 可单项选择/取消。 +4. 点击产品行可展开/收起 scope 明细。 +5. 选中数为 0 时“同意并授权”禁用。 +6. 点击“同意并授权”发送 `flowId/userCode/action/selectedScopes`,不发送 `orgId/uid`;批量授权时长由 flow detail 的 `defaultGrantType` 展示并由 PAT core 兜底使用。 +7. 服务端拒绝 flow 外 scope,前端展示错误且不进入成功终态。 +8. 点击“拒绝”只发送 reject action。 +9. `APPROVED/REJECTED/EXPIRED/ERROR` 终态不可重复提交。 +10. 移动端 375px 宽度下产品名、数量、描述不溢出,底部按钮不离屏。 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 99dd26b5..a5d8075b 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -30,18 +30,20 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) type authLoginConfig struct { - Token string - Force bool - Device bool + Token string + Force bool + Device bool + Recommend bool } -func buildAuthCommand() *cobra.Command { +func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "认证管理", @@ -55,7 +57,7 @@ func buildAuthCommand() *cobra.Command { } if !edition.Get().HideAuthLogin { - cmd.AddCommand(newAuthLoginCommand()) + cmd.AddCommand(newAuthLoginCommand(patCaller)) } cmd.AddCommand( newAuthLogoutCommand(), @@ -68,7 +70,7 @@ func buildAuthCommand() *cobra.Command { return cmd } -func newAuthLoginCommand() *cobra.Command { +func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "登录钉钉(自动刷新 token,必要时扫码)", @@ -91,7 +93,8 @@ func newAuthLoginCommand() *cobra.Command { dws auth login # 本机扫码登录 (loopback 流) dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 强制重新登录 (忽略缓存 token) - dws auth login --token xxx # 使用指定 token`, + dws auth login --token xxx # 使用指定 token + dws auth login --recommend # 登录成功后打开推荐权限批量授权页`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := resolveAuthLoginConfig(cmd) @@ -137,10 +140,32 @@ func newAuthLoginCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() + format, _ := cmd.Root().PersistentFlags().GetString("format") + runRecommend := func() error { + if !cfg.Recommend { + return nil + } + run := func(ctx context.Context) error { + return pat.RunLoginRecommendAuthorization(ctx, patCaller, cmd.ErrOrStderr()) + } + err := run(cmd.Context()) + if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { + return runDirectPATAuthCheck( + cmd.Context(), + &GlobalFlags{Format: format}, + patErr, + run, + cmd.ErrOrStderr(), + ) + } + return err + } // Check if JSON output is requested - format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { + if err := runRecommend(); err != nil { + return err + } return writeAuthLoginJSON(w, tokenData, cfg.Force) } @@ -166,12 +191,13 @@ func newAuthLoginCommand() *cobra.Command { } } fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n") - return nil + return runRecommend() }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)") + cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") @@ -572,10 +598,15 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") } + recommend, err := cmd.Flags().GetBool("recommend") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") + } return authLoginConfig{ - Token: strings.TrimSpace(token), - Force: force, - Device: device, + Token: strings.TrimSpace(token), + Force: force, + Device: device, + Recommend: recommend, }, nil } diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index e894c74f..278d29ac 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -32,6 +32,7 @@ import ( 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/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/fatih/color" ) @@ -229,7 +230,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { } data["openBrowser"] = openBrowser - encoded, err := json.Marshal(payload) + encoded, err := jsonutil.Marshal(payload) if err != nil { return raw } @@ -349,6 +350,12 @@ type patRetryingKeyType struct{} var patRetryingKey = patRetryingKeyType{} +type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error) + +func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + return f(ctx, invocation) +} + // IsPatRetrying returns true if the current context is already in a PAT retry. func IsPatRetrying(ctx context.Context) bool { v, _ := ctx.Value(patRetryingKey).(bool) @@ -381,6 +388,41 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) { fmt.Fprintf(output, " %s\n", trimmed) } +func runDirectPATAuthCheck( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + retry func(context.Context) error, + output io.Writer, +) error { + if retry == nil { + return patErr + } + runner := &runtimeRunner{ + globalFlags: globalFlags, + fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) { + if err := retry(retryCtx); err != nil { + return executor.Result{}, err + } + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "ok": true, + }, + }, nil + }), + } + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + Kind: "direct_pat_authorization", + Stage: "auth_login_recommend", + CanonicalProduct: defaultPATProductID, + Tool: "pat.batch_grant", + CanonicalPath: "pat.batch_grant", + }, patErr, defaultConfigDir(), output) + return err +} + // handlePatAuthCheck is called by runner.executeInvocation when a PAT // authorization error is detected. It injects the server-assigned clientId // as x-robot-uid header, prints authorization details, opens the browser, @@ -401,6 +443,7 @@ func handlePatAuthCheck( Desc string `json:"desc"` FlowID string `json:"flowId"` URI string `json:"uri"` + AuthURL string `json:"authUrl"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` } `json:"data"` @@ -408,6 +451,9 @@ func handlePatAuthCheck( if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { return executor.Result{}, patErr } + if patData.Data.URI == "" { + patData.Data.URI = patData.Data.AuthURL + } slog.Debug("PAT auth check", "clientId", patData.Data.ClientID, @@ -595,7 +641,7 @@ func enrichPATErrorForHostControl(raw string) string { apperrors.ApplyHostMutations(payload) // stderr JSON MUST be single-line. - encoded, err := json.Marshal(payload) + encoded, err := jsonutil.Marshal(payload) if err != nil { return raw } @@ -629,7 +675,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string "data": data, } // stderr JSON MUST be single-line. - b, err := json.Marshal(payload) + b, err := jsonutil.Marshal(payload) if err != nil { return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}` } diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 5e978083..da3225c8 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -633,6 +633,71 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) { } } +func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, _ := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-direct", "test-client-id")} + var retried atomic.Bool + var retryHadKey atomic.Bool + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error { + retried.Store(true) + retryHadKey.Store(IsPatRetrying(ctx)) + return nil + }, &bytes.Buffer{}) + if err != nil { + t.Fatalf("runDirectPATAuthCheck error = %v", err) + } + if !retried.Load() { + t.Fatal("expected direct PAT auth retry callback to run") + } + if !retryHadKey.Load() { + t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying") + } +} + +func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH" + raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}` + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"}, + &apperrors.PATError{RawJSON: raw}, + func(ctx context.Context) error { + t.Fatal("retry callback should not run in structured PAT output mode") + return nil + }, + &bytes.Buffer{}, + ) + if err == nil { + t.Fatal("expected structured PATError") + } + patOut, ok := err.(*apperrors.PATError) + if !ok { + t.Fatalf("expected *PATError, got %T: %v", err, err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { + t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) + } + if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" { + t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got) + } + data, _ := payload["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if got, ok := data["openBrowser"].(bool); !ok || got { + t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) + } +} + func TestHandlePatAuthCheck_Rejected(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") server, configDir := setupHandlePATServer(t, "REJECTED", "") @@ -928,6 +993,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T if !ok { t.Fatalf("expected *PATError, got %T: %v", err, err) } + if strings.Contains(patOut.RawJSON, `\u0026`) { + t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON) + } + if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") { + t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON) + } var payload map[string]any if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) diff --git a/internal/app/root.go b/internal/app/root.go index 86726148..bb4de9f8 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -323,9 +323,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) genSkillsCmd.Hidden = true mcpCmd := newMCPCommand(rootCtx, loader, runner, engine) mcpCmd.Hidden = true + patCaller := newToolCallerAdapter(runner, flags) utilityCommands := []*cobra.Command{ - newAuthCommand(), + newAuthCommand(patCaller), newAPICommand(flags), newSkillCommand(), newCacheCommand(), @@ -354,7 +355,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } // PAT authorization commands (open-source core) - patCaller := newToolCallerAdapter(runner, flags) pat.RegisterCommands(root, patCaller) if fn := edition.Get().RegisterExtraCommands; fn != nil { @@ -372,8 +372,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) return root } -func newAuthCommand() *cobra.Command { - return buildAuthCommand() +func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command { + return buildAuthCommand(patCaller) } func newSkillCommand() *cobra.Command { diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 3617e84f..05b385c0 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -15,10 +15,10 @@ package app import ( "context" - "encoding/json" "log/slog" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult { contentRaw, ok := resp["content"] if !ok { // Dry-run or echo mode: serialize the whole response as text. - data, _ := json.Marshal(resp) + data, _ := jsonutil.Marshal(resp) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } @@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult { } return &edition.ToolResult{Content: blocks} case map[string]any: - data, _ := json.Marshal(v) + data, _ := jsonutil.Marshal(v) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } default: - data, _ := json.Marshal(contentRaw) + data, _ := jsonutil.Marshal(contentRaw) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6f0be969..1c8c2240 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -21,6 +21,7 @@ import ( "io" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -309,7 +310,7 @@ func PrintJSON(w io.Writer, err error) error { } payload := map[string]any{"error": errorPayload} - data, marshalErr := json.MarshalIndent(payload, "", " ") + data, marshalErr := jsonutil.MarshalIndent(payload, "", " ") if marshalErr != nil { _, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n") return writeErr diff --git a/internal/errors/pat.go b/internal/errors/pat.go index 934ef06c..febc6e4d 100644 --- a/internal/errors/pat.go +++ b/internal/errors/pat.go @@ -20,6 +20,8 @@ import ( "net/url" "strings" "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" ) // hostControlProvider returns the host-owned clawType for the current @@ -146,6 +148,7 @@ var patNoPermissionCodes = map[string]bool{ // `dws auth login --scope `. var patAuthRequiredCodes = map[string]bool{ "AGENT_CODE_NOT_EXISTS": true, + "PAT_BATCH_AUTH_PENDING": true, "PAT_SCOPE_AUTH_REQUIRED": true, } @@ -240,7 +243,7 @@ func isBusinessError(body map[string]any) bool { // Check order: DWS gateway auth > PAT permission. func ClassifyToolResultContent(content map[string]any) error { if _, ok := getDWSGatewayErrorCode(content); ok { - raw, _ := json.Marshal(content) + raw, _ := jsonutil.Marshal(content) return NewAuth(string(raw), WithReason("gateway_auth_expired"), WithHint(authExpiredHint()), @@ -462,7 +465,7 @@ func cleanPATJSON(body map[string]any, code string) string { // stderr JSON MUST be a single-line, directly json.Unmarshal-able // payload — pretty-printing would break naïve host parsers that read // stderr line-by-line and fail on leading whitespace. - b, err := json.Marshal(out) + b, err := jsonutil.Marshal(out) if err != nil { return fmt.Sprintf(`{"success":false,"code":"%s"}`, code) } diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go index cce90bdd..46247905 100644 --- a/internal/errors/pat_test.go +++ b/internal/errors/pat_test.go @@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) { } } +func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") { + t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON) + } + if !strings.Contains(patErr.RawJSON, "flow-1") { + t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON) + } +} + func TestClassifyMCPResponseText_BusinessError(t *testing.T) { t.Parallel() text := `{"success":false,"errorMsg":"搜索内容不能为空"}` @@ -729,6 +748,12 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result) + } + if !strings.Contains(result, "&userCode=Q8RY-X6E9") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { @@ -810,6 +835,12 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result) + } + if !strings.Contains(result, "&userCode=CFTC-27ZN") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { diff --git a/internal/jsonutil/json.go b/internal/jsonutil/json.go new file mode 100644 index 00000000..0dbecb0f --- /dev/null +++ b/internal/jsonutil/json.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jsonutil + +import ( + "bytes" + "encoding/json" +) + +// Marshal matches json.Marshal but keeps &, <, and > unchanged. CLI JSON is +// consumed by shells and host processes, not embedded into HTML, so preserving +// URL query separators makes the output easier to copy and parse. +func Marshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// MarshalIndent matches json.MarshalIndent with HTML escaping disabled. +func MarshalIndent(v any, prefix, indent string) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/output/filter.go b/internal/output/filter.go index 15160a05..7fc40533 100644 --- a/internal/output/filter.go +++ b/internal/output/filter.go @@ -20,6 +20,7 @@ import ( "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/itchyny/gojq" ) @@ -171,7 +172,7 @@ func ApplyJQ(w io.Writer, payload any, expr string) error { } first = false - data, marshalErr := json.MarshalIndent(value, "", " ") + data, marshalErr := jsonutil.MarshalIndent(value, "", " ") if marshalErr != nil { return apperrors.NewInternal("failed to encode --jq result") } diff --git a/internal/output/filter_test.go b/internal/output/filter_test.go index ca2f6f75..65532db2 100644 --- a/internal/output/filter_test.go +++ b/internal/output/filter_test.go @@ -219,6 +219,20 @@ func TestApplyJQIdentity(t *testing.T) { } } +func TestApplyJQDoesNotEscapeAmpersands(t *testing.T) { + payload := map[string]any{"url": "https://example.test/auth?a=1&b=2"} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".url"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("ApplyJQ escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("ApplyJQ output missing literal ampersand: %s", buf.String()) + } +} + func TestApplyJQFieldAccess(t *testing.T) { payload := map[string]any{"name": "Alice", "age": float64(30)} var buf bytes.Buffer diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 27072978..417af44d 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -22,6 +22,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -257,7 +258,7 @@ func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet { // WriteJSON marshals payload as indented JSON and writes it to w. func WriteJSON(w io.Writer, payload any) error { - data, err := json.MarshalIndent(payload, "", " ") + data, err := jsonutil.MarshalIndent(payload, "", " ") if err != nil { return apperrors.NewInternal("failed to encode command output as JSON") } @@ -270,7 +271,7 @@ func writeRaw(w io.Writer, payload any) error { _, err := fmt.Fprintln(w, SanitizeForTerminal(text)) return err } - data, err := json.Marshal(payload) + data, err := jsonutil.Marshal(payload) if err != nil { return apperrors.NewInternal("failed to encode raw command output") } @@ -544,7 +545,7 @@ func formatValue(value any) string { case string: return SanitizeForTerminal(typed) default: - data, err := json.Marshal(typed) + data, err := jsonutil.Marshal(typed) if err != nil { return SanitizeForTerminal(fmt.Sprintf("%v", typed)) } diff --git a/internal/output/formatter_extra_test.go b/internal/output/formatter_extra_test.go index 93776677..8e111e99 100644 --- a/internal/output/formatter_extra_test.go +++ b/internal/output/formatter_extra_test.go @@ -22,6 +22,21 @@ func TestWriteJSON_Map(t *testing.T) { } } +func TestWriteJSON_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + url := "https://open-dev.dingtalk.com/fe/old#/personalAuthorization?flowId=flow&userCode=CODE" + if err := WriteJSON(&buf, map[string]any{"authorizationUrl": url}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("WriteJSON escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "&userCode=CODE") { + t.Fatalf("WriteJSON output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteJSON_Nil(t *testing.T) { t.Parallel() var buf bytes.Buffer @@ -150,6 +165,20 @@ func TestWriteRaw_NonString(t *testing.T) { } } +func TestWriteRaw_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeRaw(&buf, map[string]any{"url": "https://example.test/auth?a=1&b=2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("writeRaw escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("writeRaw output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteTableish_Map(t *testing.T) { t.Parallel() var buf bytes.Buffer diff --git a/internal/output/ndjson.go b/internal/output/ndjson.go index d86eb445..909f0089 100644 --- a/internal/output/ndjson.go +++ b/internal/output/ndjson.go @@ -41,6 +41,7 @@ func writeNDJSON(w io.Writer, payload any) error { bw := bufio.NewWriter(w) enc := json.NewEncoder(bw) + enc.SetEscapeHTML(false) // json.Encoder.Encode already appends a trailing newline per call. switch v := normalized.(type) { diff --git a/internal/output/ndjson_csv_test.go b/internal/output/ndjson_csv_test.go index a44bd138..b2aefd71 100644 --- a/internal/output/ndjson_csv_test.go +++ b/internal/output/ndjson_csv_test.go @@ -49,6 +49,11 @@ func TestWriteNDJSON(t *testing.T) { payload: map[string]any{"ok": true}, wantLines: []string{`{"ok":true}`}, }, + { + name: "url keeps ampersand", + payload: map[string]any{"url": "https://example.test/auth?a=1&b=2"}, + wantLines: []string{`{"url":"https://example.test/auth?a=1&b=2"}`}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index e8e42e33..b74d2835 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -18,9 +18,11 @@ import ( "encoding/json" stderrors "errors" "fmt" + "io" "os" "regexp" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -170,10 +172,13 @@ const ( // expose only the legacy Chinese display name. patGrantToolNameLegacyAlias = "个人授权" - patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" - patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" - patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" - patForgedIdentityCodeLower = "pat_forged_identity_field" + patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" + patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" + grantTypePermanent = "permanent" + patCallerAuthLoginRecommend = "auth_login_recommend" + patCallerBatchChmodInteractive = "batch_chmod_interactive" ) var patBatchMetadataContractCodes = map[string]bool{ @@ -224,7 +229,9 @@ grantType 规则: 批量授权: 产品线批量授权推荐使用 --recommend --product 。 --products / --domain / --domains 保持兼容;裸 --recommend 保持可用, - 但会按推荐集合规划,可能跨产品。`, + 但会按推荐集合规划,可能跨产品。 + 批量授权未传 --yes 时会打开授权确认页;传 --yes 时才直接写入服务端选中的权限。 + 批量授权未显式指定 --grant-type 且没有 session-id 时,会以 permanent 作为本次页面确认后的授权时长。`, Args: func(cmd *cobra.Command, args []string) error { productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) if len(args) > 0 || recommend || len(productCodes) > 0 { @@ -246,12 +253,16 @@ grantType 规则: } scopes := args productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) - usesPlan := recommend || len(productCodes) > 0 + usesPlan := recommend || len(productCodes) > 0 || len(scopes) > 1 grantType, _ := cmd.Flags().GetString("grant-type") sessionID, _ := cmd.Flags().GetString("session-id") if sessionID == "" { sessionID = resolveSessionIDFromEnv() } + interactiveBatchFlow := usesPlan && !batchAuthorizationConfirmed(cmd) + if interactiveBatchFlow && grantType == "session" && sessionID == "" && !cmd.Flags().Changed("grant-type") { + grantType = grantTypePermanent + } if !validGrantTypes[grantType] { return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType) @@ -291,20 +302,11 @@ 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) + if interactiveBatchFlow { + planArgs["caller"] = patCallerBatchChmodInteractive + } planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) @@ -336,6 +338,12 @@ grantType 规则: batchArgs["sessionId"] = sessionID toolArgs["sessionId"] = sessionID } + if interactiveBatchFlow { + batchArgs["startFlow"] = true + batchArgs["noWait"] = true + batchArgs["caller"] = patCallerBatchChmodInteractive + batchArgs["clientRequestId"] = newBatchClientRequestID("chmod") + } // Legacy server schema accepted singular "scope"; clone the // canonical argv and rename the key so the two payloads stay // in lock-step on every other field. @@ -349,15 +357,27 @@ grantType 规则: } ctx := context.Background() - result, err := callPATBatchGrantWithLegacyFallback( - ctx, - c, - agentCode, - sessionID, - batchArgs, - toolArgs, - legacyToolArgs, - ) + var result *edition.ToolResult + if interactiveBatchFlow { + result, err = callPATBatchToolWithIdentityFallback( + ctx, + c, + agentCode, + sessionID, + patBatchGrantToolName, + batchArgs, + ) + } else { + result, err = callPATBatchGrantWithLegacyFallback( + ctx, + c, + agentCode, + sessionID, + batchArgs, + toolArgs, + legacyToolArgs, + ) + } if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } @@ -523,6 +543,57 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, return args } +// RunLoginRecommendAuthorization plans the server-owned recommended scope set +// after a successful dws auth login, then starts a batch Device Code Flow for +// the server-selected grantable scopes. The actual browser open, polling, and +// retry are handled by the shared PAT runner once PAT_BATCH_AUTH_PENDING is +// classified as a PAT auth check. +func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, output io.Writer) error { + if c == nil { + return fmt.Errorf("internal error: tool runtime not initialized") + } + planArgs := buildBatchPlanArgs(nil, nil, true, grantTypePermanent, "", "", true) + planArgs["caller"] = patCallerAuthLoginRecommend + planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) + if err != nil { + return fmt.Errorf("pat login recommend plan failed: %w", err) + } + if err := classifyToolResultText(planResult); err != nil { + return err + } + scopes, err := extractSelectedScopesAllowEmpty(planResult) + if err != nil { + return err + } + if len(scopes) == 0 { + if output != nil { + _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") + } + return nil + } + grantArgs := map[string]any{ + "scopes": scopes, + "grantType": grantTypePermanent, + "startFlow": true, + "noWait": true, + "caller": patCallerAuthLoginRecommend, + "clientRequestId": newBatchClientRequestID("auth-login-recommend"), + } + result, err := callPATBatchToolWithIdentityFallback(ctx, c, "", "", patBatchGrantToolName, grantArgs) + if err != nil { + return fmt.Errorf("pat login recommend grant failed: %w", err) + } + return handleToolResultForWriter(output, result, c) +} + +func newBatchClientRequestID(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "batch" + } + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { text := firstToolResultText(result) if text == "" { @@ -559,6 +630,30 @@ func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { return scopes, nil } +func extractSelectedScopesAllowEmpty(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) + scopes := make([]string, 0, len(rawScopes)) + for _, raw := range rawScopes { + scope, ok := raw.(string) + if ok && strings.TrimSpace(scope) != "" { + scopes = append(scopes, scope) + } + } + return scopes, nil +} + func firstToolResultText(result *edition.ToolResult) string { if result == nil { return "" @@ -908,6 +1003,44 @@ func handleToolResult(cmd *cobra.Command, caller edition.ToolCaller, result *edi return fmt.Errorf("empty PAT authorization result: %s", string(data)) } +func handleToolResultForWriter(w io.Writer, result *edition.ToolResult, caller edition.ToolCaller) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil { + return respErr + } + if w == nil { + return nil + } + if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" { + _, _ = fmt.Fprint(w, summary) + return nil + } + _, _ = fmt.Fprintln(w, c.Text) + return nil + } + data, _ := json.Marshal(result) + return fmt.Errorf("empty PAT authorization result: %s", string(data)) +} + +func classifyToolResultText(result *edition.ToolResult) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + return apperrors.ClassifyMCPResponseText(c.Text) + } + return nil +} + func shouldEmitRawPATResult(cmd *cobra.Command) bool { if commandBoolFlag(cmd, "verbose") { return true diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 7e962c6a..db426f04 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -344,24 +344,79 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } -func TestChmod_productsFlagRequiresYesBeforeGranting(t *testing.T) { +func TestChmod_productsFlagWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"pendingScopes":[{"scope":"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 err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) } - if !strings.Contains(err.Error(), "requires explicit --yes") { - t.Fatalf("chmod RunE error = %v, want --yes requirement", err) + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want plan + batch flow", len(fake.calls)) } - if len(fake.calls) != 0 { - t.Fatalf("CallTool count = %d, want 0 before explicit --yes", 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["caller"]; got != patCallerBatchChmodInteractive { + t.Fatalf("plan caller = %#v, want %q", got, patCallerBatchChmodInteractive) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[1].args["startFlow"]; got != true { + t.Fatalf("grant startFlow = %#v, want true", got) + } + if got := fake.calls[1].args["noWait"]; got != true { + t.Fatalf("grant noWait = %#v, want true", got) + } + if got := fake.calls[1].args["caller"]; got != patCallerBatchChmodInteractive { + t.Fatalf("grant caller = %#v, want %q", got, patCallerBatchChmodInteractive) + } + if got := fake.calls[1].args["grantType"]; got != grantTypePermanent { + t.Fatalf("grantType = %#v, want %q for default interactive flow", got, grantTypePermanent) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) { + t.Fatalf("grant scopes = %#v, want selected scope", got) + } + if got, ok := fake.calls[1].args["clientRequestId"].(string); !ok || strings.TrimSpace(got) == "" { + t.Fatalf("grant clientRequestId = %#v, want non-empty string", got) + } +} + +func TestChmod_multipleScopesWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:write"]}}`, + `{"success":true,"data":{"pendingScopes":[{"scope":"calendar.event:read"},{"scope":"aitable.record:write"}]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + if err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want plan + batch flow", 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["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { + t.Fatalf("plan scopes = %#v, want explicit scopes", 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["startFlow"]; got != true { + t.Fatalf("grant startFlow = %#v, want true", got) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { + t.Fatalf("grant scopes = %#v, want selected scopes", got) } } From db2043c82b0b443f679a17b62ee09b27e0015360 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 14:45:10 +0800 Subject: [PATCH 20/61] docs: record batch auth prepub status --- docs/batch-auth-prd.json | 47 +++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 01cb0d3a..065168f0 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -200,35 +200,64 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "prepubPackage": "pending", + "commit": "11dfd4ccf20ad0e1d1f8ce68843fbc2b0274014d", + "prepubPackage": "blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-open-mcpserver-gw", "localPath": "/Users/xuan/lippi-open-mcpserver-gw", - "branch": "codex/pat-batch-identity-args-gw", - "prepubPackage": "pending", + "branch": "codex/pat-batch-auth-service-gate-20260616", + "commit": "ffaa951f13a107a08fb04c6246ba99bf7d583477", + "aoneAppId": 296391, + "aoneCrId": 34656146, + "aoneCrStatus": "INTG", + "aoneCrStatusLabel": "发布中", + "prepubPipelineId": 66, + "prepubPipelineInstanceId": 3094877547, + "prepubPackage": "pipeline_reached_final_prepub_validation_waiting_state", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "commit": "0c9b64d7b99c90a55d30b8e322cf379797783e45", + "aoneAppId": 312546, + "aoneCrId": 34656143, + "aoneCrStatus": "INTG", + "aoneCrStatusLabel": "发布中", + "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", + "aoneNewestRevision": "0c9b64d7b99c90a55d30b8e322cf379797783e45", + "prepubPipelineId": 66, + "prepubPipelineInstanceId": 3094877542, + "prepubPackage": "paused_by_user_for_pat_core_release_conflict_resolution; local_compile_success_after_tryConsumeOnceTokenFromTair_signature_fix; lippi-pat-core-api_1.0.5-SNAPSHOT_snapshot_deployed", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-open-app-dev", "localPath": "/Users/xuan/lippi-open-app-dev", "branch": "codex/batch-auth-bff-selected-scopes", - "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "commit": "139c9a245c724870a1dd17fe2b2f07384081100b", + "aoneAppId": 154450, + "aoneCrId": 34656145, + "aoneCrStatus": "PREINTG", + "aoneCrStatusLabel": "待发布", + "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", + "aoneNewestRevision": "139c9a245c724870a1dd17fe2b2f07384081100b", + "prepubPipelineId": 66, + "prepubPipelineInstanceId": 3094879946, + "prepubPackage": "local_compile_success_with_PAT_API_1.0.5-SNAPSHOT; prior_aone_build_failed_before_dependency_bump; deploy_paused_until_pat_core_prepub_is_ready", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-open-dev-front", "localPath": "/Users/xuan/lippi-open-dev-front", "branch": "codex/batch-auth-page-flow", - "prepubPackage": "local_npm_build_passed_prepub_package_pending", + "commit": "5dd46c172e3d793fb6aa161c2c89b8cd71ef0f40", + "aoneAppId": null, + "aoneCrId": null, + "prepubPackage": "blocked_no_aone_app_found_for_gitlab_repo_dingding_lippi_open_dev_front_local_npm_build_passed", "prepubFunctionalVerification": "pending" } ], @@ -238,6 +267,12 @@ "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, destructive-scope filtering, high-risk pending, and JSON no-wait pending response.", "Prepub UI screenshot matches visualDesignTarget." + ], + "latestDiagnostics": [ + "PAT core Aone compile error at PatAuthCheckService.java:517 was caused by a two-argument tryConsumeOnceTokenFromTair call after the method signature changed to require effective risk level. Current feature and release-merge worktrees use RiskLevelEnum.MEDIUM.getCode() for the high-risk-to-medium DCF bypass path and compile locally.", + "The Maven warning about requested profile lippi-pat-core is an Aone/Maven parameter warning: the root POM has no profile with id lippi-pat-core. It is not the cause of the Java compilation failure and does not justify a POM change in this slice.", + "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", + "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml." ] }, "serviceContract": { From 931d7e59ef7a32162ebec604ab0c8a433cba0def Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 14:57:58 +0800 Subject: [PATCH 21/61] docs: refresh PAT prepub dependency status --- docs/batch-auth-prd.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 065168f0..8d9243b5 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -222,16 +222,16 @@ "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "0c9b64d7b99c90a55d30b8e322cf379797783e45", + "commit": "db13b19957930e62e8b03d74ac4e8093ea4bf7e7", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "0c9b64d7b99c90a55d30b8e322cf379797783e45", + "aoneNewestRevision": "db13b19957930e62e8b03d74ac4e8093ea4bf7e7", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094877542, - "prepubPackage": "paused_by_user_for_pat_core_release_conflict_resolution; local_compile_success_after_tryConsumeOnceTokenFromTair_signature_fix; lippi-pat-core-api_1.0.5-SNAPSHOT_snapshot_deployed", + "prepubPackage": "paused_by_user_for_pat_core_release_conflict_resolution; local_compile_success_after_tryConsumeOnceTokenFromTair_signature_fix; lippi-pat-core-api_1.0.5-SNAPSHOT_snapshot_deployed; api_module_version_explicitly_aligned_to_1.0.5-SNAPSHOT; lippi-enterprise-soc-api.version_defined_as_1.0.30-SNAPSHOT", "prepubFunctionalVerification": "pending" }, { @@ -271,8 +271,11 @@ "latestDiagnostics": [ "PAT core Aone compile error at PatAuthCheckService.java:517 was caused by a two-argument tryConsumeOnceTokenFromTair call after the method signature changed to require effective risk level. Current feature and release-merge worktrees use RiskLevelEnum.MEDIUM.getCode() for the high-risk-to-medium DCF bypass path and compile locally.", "The Maven warning about requested profile lippi-pat-core is an Aone/Maven parameter warning: the root POM has no profile with id lippi-pat-core. It is not the cause of the Java compilation failure and does not justify a POM change in this slice.", + "PAT core API version is explicitly aligned: root property lippi-pat-core-api.version is 1.0.5-SNAPSHOT and lippi-pat-core-api/pom.xml project.version evaluates to 1.0.5-SNAPSHOT. Verification passed with Java 11 Maven help:evaluate and lippi-pat-core-api compile.", + "PAT core root POM now defines lippi-enterprise-soc-api.version as 1.0.30-SNAPSHOT, matching local reference repos /Users/xuan/lippi-pat-core and /Users/xuan/lippi-pat-core-prepub-merge-34465221. Verification passed with Java 11 Maven lippi-pat-core-service compile.", "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", - "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml." + "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml.", + "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only." ] }, "serviceContract": { From aa76f0101592ea9bebd69c2af80c38376f1e8da5 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 15:03:40 +0800 Subject: [PATCH 22/61] docs: record PAT app dev API build fix --- docs/batch-auth-prd.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 8d9243b5..582c40e9 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -222,16 +222,16 @@ "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "db13b19957930e62e8b03d74ac4e8093ea4bf7e7", + "commit": "78c1c311e91e2c2d204622b6219b8c1a179b6932", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "db13b19957930e62e8b03d74ac4e8093ea4bf7e7", + "aoneNewestRevision": "78c1c311e91e2c2d204622b6219b8c1a179b6932", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094877542, - "prepubPackage": "paused_by_user_for_pat_core_release_conflict_resolution; local_compile_success_after_tryConsumeOnceTokenFromTair_signature_fix; lippi-pat-core-api_1.0.5-SNAPSHOT_snapshot_deployed; api_module_version_explicitly_aligned_to_1.0.5-SNAPSHOT; lippi-enterprise-soc-api.version_defined_as_1.0.30-SNAPSHOT", + "prepubPackage": "latest_aone_build_failed_on_old_release_branch_commit_3555c3aa_missing_PatConnectorQueryService_from_lippi-open-app-dev-api_1.0.103; feature_branch_fixed_to_lippi-open-app-dev.version_1.2.0-SNAPSHOT_and_local_service_compile_passed; rerun_required", "prepubFunctionalVerification": "pending" }, { @@ -273,6 +273,7 @@ "The Maven warning about requested profile lippi-pat-core is an Aone/Maven parameter warning: the root POM has no profile with id lippi-pat-core. It is not the cause of the Java compilation failure and does not justify a POM change in this slice.", "PAT core API version is explicitly aligned: root property lippi-pat-core-api.version is 1.0.5-SNAPSHOT and lippi-pat-core-api/pom.xml project.version evaluates to 1.0.5-SNAPSHOT. Verification passed with Java 11 Maven help:evaluate and lippi-pat-core-api compile.", "PAT core root POM now defines lippi-enterprise-soc-api.version as 1.0.30-SNAPSHOT, matching local reference repos /Users/xuan/lippi-pat-core and /Users/xuan/lippi-pat-core-prepub-merge-34465221. Verification passed with Java 11 Maven lippi-pat-core-service compile.", + "PAT core Aone build task 3390400102 failed because release branch commit 3555c3aa resolved lippi-open-app-dev-api as 1.0.103, which lacks PatConnectorQueryService and PatConnectorQueryResult. Feature branch commit 78c1c311e91e2c2d204622b6219b8c1a179b6932 changes lippi-open-app-dev.version to 1.2.0-SNAPSHOT, matching /Users/xuan/lippi-pat-core and release merge worktree. Local Java 11 lippi-pat-core-service compile passed and the 1.2.0-SNAPSHOT jar contains both missing classes.", "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml.", "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only." From e82ccd3496719f73de1cf14f1062c771a1e69819 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 16:34:10 +0800 Subject: [PATCH 23/61] docs: refresh batch auth verification state --- docs/batch-auth-prd.json | 44 ++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 582c40e9..a3ddfcd8 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -19,7 +19,8 @@ "exclude": "lark-cli auth login --exclude ", "scopeCatalog": "lark-cli auth scopes", "scopeCheck": "lark-cli auth check --scope ", - "qrCode": "lark-cli auth qrcode " + "qrCode": "lark-cli auth qrcode ", + "yesFlag": "lark-cli auth login has no --yes flag; lark-cli auth login --yes --no-wait --json returns Error: unknown flag: --yes. Therefore --yes must not be treated as an auth-login popup bypass." }, "ralphCli": { "model": "PRD-driven long-running development with explicit task ledger, progress notes, verification gates, and failure analysis.", @@ -110,6 +111,12 @@ "serverContract": "host-owned JSON mode returns structured pending and does not block; local interactive mode opens browser and polls", "status": "implemented in CLI retry path and PAT pending response" }, + { + "lark": "auth login --yes", + "dws": "not supported for dws auth login; --yes belongs only to explicit batch grant commands such as dws pat chmod", + "serverContract": "auth login --recommend must still open or return the authorization page. For chmod-style batch commands, --yes may skip local CLI confirmation and attempt direct grant, but server-side pending/high-risk/page-required responses still win.", + "status": "verified on 2026-06-16 with local lark-cli: unknown flag --yes" + }, { "lark": "auth scopes and auth check", "dws": "pat.batch_plan dryRun and existing PAT auth check result", @@ -200,7 +207,7 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "11dfd4ccf20ad0e1d1f8ce68843fbc2b0274014d", + "commit": "aa76f0101592ea9bebd69c2af80c38376f1e8da5", "prepubPackage": "blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", "prepubFunctionalVerification": "pending" }, @@ -208,7 +215,7 @@ "repo": "lippi-open-mcpserver-gw", "localPath": "/Users/xuan/lippi-open-mcpserver-gw", "branch": "codex/pat-batch-auth-service-gate-20260616", - "commit": "ffaa951f13a107a08fb04c6246ba99bf7d583477", + "commit": "a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9", "aoneAppId": 296391, "aoneCrId": 34656146, "aoneCrStatus": "INTG", @@ -222,42 +229,42 @@ "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "78c1c311e91e2c2d204622b6219b8c1a179b6932", + "commit": "0b805ff866aaf255df86f49418fe4c6d4d47e8db", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "78c1c311e91e2c2d204622b6219b8c1a179b6932", + "aoneNewestRevision": "0b805ff866aaf255df86f49418fe4c6d4d47e8db", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094877542, - "prepubPackage": "latest_aone_build_failed_on_old_release_branch_commit_3555c3aa_missing_PatConnectorQueryService_from_lippi-open-app-dev-api_1.0.103; feature_branch_fixed_to_lippi-open-app-dev.version_1.2.0-SNAPSHOT_and_local_service_compile_passed; rerun_required", + "prepubPipelineInstanceId": 3094916508, + "prepubPackage": "SUCCESS through build, prepub deploy, and prepub integration test on 2026-06-16 16:20:13+08:00; Aone prepub validation stage is still RUNNING.", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-open-app-dev", "localPath": "/Users/xuan/lippi-open-app-dev", "branch": "codex/batch-auth-bff-selected-scopes", - "commit": "139c9a245c724870a1dd17fe2b2f07384081100b", + "commit": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "aoneAppId": 154450, "aoneCrId": 34656145, - "aoneCrStatus": "PREINTG", - "aoneCrStatusLabel": "待发布", + "aoneCrStatus": "INTG", + "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", - "aoneNewestRevision": "139c9a245c724870a1dd17fe2b2f07384081100b", + "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094879946, - "prepubPackage": "local_compile_success_with_PAT_API_1.0.5-SNAPSHOT; prior_aone_build_failed_before_dependency_bump; deploy_paused_until_pat_core_prepub_is_ready", + "prepubPipelineInstanceId": 3094919448, + "prepubPackage": "RUNNING on 2026-06-16 16:28:13+08:00 after latest master merge; code merge and build succeeded, prepub deploy was running when checked.", "prepubFunctionalVerification": "pending" }, { "repo": "lippi-open-dev-front", "localPath": "/Users/xuan/lippi-open-dev-front", "branch": "codex/batch-auth-page-flow", - "commit": "5dd46c172e3d793fb6aa161c2c89b8cd71ef0f40", + "commit": "356dcbab5d02c9f0225baabbd80a98b28d6f92b3", "aoneAppId": null, "aoneCrId": null, - "prepubPackage": "blocked_no_aone_app_found_for_gitlab_repo_dingding_lippi_open_dev_front_local_npm_build_passed", + "prepubPackage": "blocked_no_aone_app_found_for_gitlab_repo_dingding_lippi_open_dev_front_local_npm_build_passed; latest branch head 356dcbab5 tightens batch detection and reject terminal state, local npm run build passed with existing bundle-size warnings only.", "prepubFunctionalVerification": "pending" } ], @@ -274,8 +281,15 @@ "PAT core API version is explicitly aligned: root property lippi-pat-core-api.version is 1.0.5-SNAPSHOT and lippi-pat-core-api/pom.xml project.version evaluates to 1.0.5-SNAPSHOT. Verification passed with Java 11 Maven help:evaluate and lippi-pat-core-api compile.", "PAT core root POM now defines lippi-enterprise-soc-api.version as 1.0.30-SNAPSHOT, matching local reference repos /Users/xuan/lippi-pat-core and /Users/xuan/lippi-pat-core-prepub-merge-34465221. Verification passed with Java 11 Maven lippi-pat-core-service compile.", "PAT core Aone build task 3390400102 failed because release branch commit 3555c3aa resolved lippi-open-app-dev-api as 1.0.103, which lacks PatConnectorQueryService and PatConnectorQueryResult. Feature branch commit 78c1c311e91e2c2d204622b6219b8c1a179b6932 changes lippi-open-app-dev.version to 1.2.0-SNAPSHOT, matching /Users/xuan/lippi-pat-core and release merge worktree. Local Java 11 lippi-pat-core-service compile passed and the 1.2.0-SNAPSHOT jar contains both missing classes.", + "PAT core prepub runtime failure for flowId 8b35d5b6373c4cb9a7a67b339516fff2 was TDDL-4603 ERR_ACCROSS_DB_TRANSACTION. The failing path approved a batch flow by looping single-scope INSERT INTO agent_pat in one transaction, crossing sharding groups. Commit 0b805ff changes permanent/session batch approval and direct batch grant to PatAuthGrantService#grantScopesBatch, which uses AgentPatDAO.batchInsert and PatAuthRecordDAO.batchInsert where applicable, while DeviceCodeFlowService#handleFlowAction runs with Propagation.NOT_SUPPORTED to avoid opening a strict cross-shard transaction around the flow action.", + "PAT core local regression passed on 2026-06-16 with Java 11: mvn -pl lippi-pat-core-service -Dtest=PatAuthGrantServiceBatchTest,PatBatchGrantIdempotencyTest,DeviceCodeFlowServiceTransactionTest,DeviceCodeFlowServiceBatchSelectionTest test -s /Users/xuan/Downloads/settings.xml and full mvn -pl lippi-pat-core-service test -s /Users/xuan/Downloads/settings.xml.", + "PAT core Aone pipeline 66 instance 3094916508 reached SUCCESS for code merge, build, prepub deploy, and prepub integration test; prepub validation remained RUNNING when checked.", "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml.", + "BFF latest branch head is 9ac576c77d142b5f4ec318c8d7b8ecf472ea6341 after merging latest master. Aone CR 34656145 is INTG and pipeline 66 instance 3094919448 had code merge and build SUCCESS, with prepub deploy RUNNING at 2026-06-16 16:28:13+08:00.", + "Gateway latest branch head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9. It rejects pat.batch_grant calls that carry plan-only recommend/productCodes fields, normalizes non-empty selected scopes before forwarding to PAT core, and has local verification uv run python -m pytest test/test_pat_batch_bridge.py -q: 26 passed.", + "Frontend latest branch head is 356dcbab5d02c9f0225baabbd80a98b28d6f92b3. It only renders the batch page when the server explicitly marks batchFlow/isBatchFlow/flowType=BATCH, and reject success falls back to FLOW_REJECTED so the terminal page shows 已拒绝授权 instead of 授权失败. Verification: npm run build passed with existing bundle-size warnings.", + "lark-cli parity check on 2026-06-16: lark-cli auth login --help does not list --yes, and lark-cli auth login --yes --no-wait --json exits with Error: unknown flag: --yes. Lark recommended login therefore still requires Device Flow/browser authorization and does not provide a --yes bypass.", "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only." ] }, From b85353e04219742bc472d373a6828bcdbd0d7160 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 18:07:56 +0800 Subject: [PATCH 24/61] fix: emit single PAT authorization URL --- internal/app/pat_auth_retry.go | 37 +++++++++++----- internal/app/pat_auth_retry_test.go | 67 +++++++++++++++++++++++++++-- internal/errors/pat.go | 33 +++++++++----- internal/errors/pat_test.go | 49 ++++++++++++++++++--- 4 files changed, 155 insertions(+), 31 deletions(-) diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 278d29ac..1e9200f2 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -225,8 +225,11 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { data = map[string]any{} payload["data"] = data } - if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" { - data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI) + if rawURI := patAuthorizationURIFromData(data); rawURI != "" { + authURL := apperrors.PATAuthorizationURL(rawURI) + data["uri"] = authURL + delete(data, "authUrl") + delete(data, "authorizationUrl") } data["openBrowser"] = openBrowser @@ -237,6 +240,16 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { return string(encoded) } +func patAuthorizationURIFromData(data map[string]any) string { + for _, key := range []string{"uri", "authUrl", "authorizationUrl"} { + value, _ := data[key].(string) + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { @@ -370,8 +383,8 @@ func openPATAuthorizationURI(rawURI string) error { } // The PAT service returns the complete authorization URL. Treat it as an // opaque string unless it is the known legacy DingTalk hash-route variant. - // That variant is normalized by the PAT error contract helper while still - // preserving the original data.uri in structured output. + // That variant is normalized by the PAT error contract helper before being + // printed, opened, or returned in structured output. return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI)) } @@ -440,12 +453,13 @@ func handlePatAuthCheck( var patData struct { Code string `json:"code"` Data struct { - Desc string `json:"desc"` - FlowID string `json:"flowId"` - URI string `json:"uri"` - AuthURL string `json:"authUrl"` - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` + Desc string `json:"desc"` + FlowID string `json:"flowId"` + URI string `json:"uri"` + AuthURL string `json:"authUrl"` + AuthorizationURL string `json:"authorizationUrl"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` } `json:"data"` } if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { @@ -454,6 +468,9 @@ func handlePatAuthCheck( if patData.Data.URI == "" { patData.Data.URI = patData.Data.AuthURL } + if patData.Data.URI == "" { + patData.Data.URI = patData.Data.AuthorizationURL + } slog.Debug("PAT auth check", "clientId", patData.Data.ClientID, diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index da3225c8..5c037ced 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -693,11 +693,63 @@ func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want %q", got, rawURI) } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from structured PAT output") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from structured PAT output") + } if got, ok := data["openBrowser"].(bool); !ok || got { t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) } } +func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH" + wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH" + raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}` + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"}, + &apperrors.PATError{RawJSON: raw}, + func(ctx context.Context) error { + t.Fatal("retry callback should not run in structured PAT output mode") + return nil + }, + &bytes.Buffer{}, + ) + if err == nil { + t.Fatal("expected structured PATError") + } + patOut, ok := err.(*apperrors.PATError) + if !ok { + t.Fatalf("expected *PATError, got %T: %v", err, err) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { + t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) + } + if strings.Contains(patOut.RawJSON, `\u0026`) { + t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON) + } + data, _ := payload["data"].(map[string]any) + if got, _ := data["uri"].(string); got != wantURL { + t.Fatalf("data.uri = %q, want %q", got, wantURL) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after backfilling data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") + } +} + func TestHandlePatAuthCheck_Rejected(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") server, configDir := setupHandlePATServer(t, "REJECTED", "") @@ -1007,8 +1059,11 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) } - if got, _ := data["authorizationUrl"].(string); got != rawURI { - t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from json PAT output") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from json PAT output") } if got, ok := data["openBrowser"].(bool); !ok || !got { t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"]) @@ -1043,7 +1098,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { fallback: mock, globalFlags: &GlobalFlags{Format: "table"}, } - raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}` + raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}` var buf bytes.Buffer _, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{ @@ -1063,6 +1118,12 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { if !strings.Contains(buf.String(), "需要 PAT 授权") { t.Fatalf("expected human-readable PAT output, got %q", buf.String()) } + if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") { + t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String()) + } + if !strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=https://example.com/pat") { + t.Fatalf("expected PAT_AUTHORIZATION_URL export line, got %q", buf.String()) + } } func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) { diff --git a/internal/errors/pat.go b/internal/errors/pat.go index febc6e4d..172d6163 100644 --- a/internal/errors/pat.go +++ b/internal/errors/pat.go @@ -106,12 +106,10 @@ const ExitCodePermission = 4 // json.Unmarshal-able payload of the form // {"success":false,"code":,"data":{...}}. // -// When the payload includes data.uri, that URL is the authoritative -// server-provided authorization link. Hosts must treat it as opaque and open -// it verbatim instead of parsing and reconstructing it locally, because -// required parameters may live in query, encoded hash, or fragment sections. -// New hosts may prefer data.authorizationUrl when present; it preserves data.uri -// while adding a copy/open-safe URL for legacy DingTalk hash-route variants. +// When the payload includes data.uri/authUrl/authorizationUrl, that value is +// the authoritative server-provided authorization link. The CLI accepts all +// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a +// single data.uri field so terminals and hosts do not need to deduplicate links. type PATError struct { RawJSON string } @@ -355,8 +353,11 @@ func ApplyHostMutations(out map[string]any) { data = map[string]any{} out["data"] = data } - if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" { - data["authorizationUrl"] = PATAuthorizationURL(rawURI) + if rawURI := patAuthorizationURIFromData(data); rawURI != "" { + authURL := PATAuthorizationURL(rawURI) + data["uri"] = authURL + delete(data, "authUrl") + delete(data, "authorizationUrl") } if block := HostControlBlock(); block != nil { delete(data, "callbacks") @@ -365,6 +366,16 @@ func ApplyHostMutations(out map[string]any) { data["openBrowser"] = PATOpenBrowserValue() } +func patAuthorizationURIFromData(data map[string]any) string { + for _, key := range []string{"uri", "authUrl", "authorizationUrl"} { + value, _ := data[key].(string) + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // PATAuthorizationURL returns the best URL for hosts to open or show to users. // It keeps already-complete PAT URLs unchanged. For DingTalk's legacy // /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit @@ -445,9 +456,9 @@ func cleanPATJSON(body map[string]any, code string) string { "code": code, } if data, ok := body["data"]; ok { - // Keep data.uri exactly as returned by the service. Host consumers open - // that link directly, so local normalization would risk dropping - // parameters embedded in query/hash/fragment sections. + // ApplyHostMutations canonicalizes PAT URL aliases into one data.uri + // before JSON encoding, while stripClassFields keeps the rest of the + // service payload intact. out["data"] = stripClassFields(data) } else { fallback := map[string]any{} diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go index 46247905..533a1b4a 100644 --- a/internal/errors/pat_test.go +++ b/internal/errors/pat_test.go @@ -763,8 +763,40 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) } - if got, _ := data["authorizationUrl"].(string); got != rawURI { - t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted when data.uri is present") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted when data.uri is present") + } +} + +func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" + body := map[string]any{ + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "data": map[string]any{ + "flowId": "50dff7654b7444e88ced7489b07cce8d", + "authUrl": rawURI, + }, + } + + result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING") + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) + } + data, _ := parsed["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after backfilling data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") } } @@ -820,7 +852,7 @@ func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *tes } } -func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { +func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN" want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN" @@ -847,11 +879,14 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) } data, _ := parsed["data"].(map[string]any) - if got, _ := data["uri"].(string); got != rawURI { - t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) + if got, _ := data["uri"].(string); got != want { + t.Fatalf("data.uri = %q, want normalized %q", got, want) } - if got, _ := data["authorizationUrl"].(string); got != want { - t.Fatalf("data.authorizationUrl = %q, want %q", got, want) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after normalizing data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri") } } From 5a7c0748e39e62f02ca1296e2a9e513a59a94dbe Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 16 Jun 2026 23:19:24 +0800 Subject: [PATCH 25/61] feat: add recommend login product TUI --- docs/batch-auth-prd.json | 97 ++++++++++++---- internal/app/auth_command.go | 83 +++++++++++++- internal/pat/chmod.go | 211 +++++++++++++++++++++++++++++++++-- internal/pat/chmod_test.go | 70 ++++++++++++ 4 files changed, 428 insertions(+), 33 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index a3ddfcd8..bf1b2a45 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -41,13 +41,13 @@ "After dws auth login --recommend succeeds, DWS starts a PAT recommended batch plan and opens the authorization page.", "For PAT batch authorization commands through dws pat chmod without --yes, including multi-scope, product-line, and recommend modes, DWS opens the same authorization page instead of returning a local validation error.", "The server, not the CLI, selects grantable scopes. CLI must grant only selectedScopes from pat.batch_plan.", - "Recommended login must never auto-select destructive scopes such as delete, recall, remove, destroy, or drop.", + "Recommended login scope selection is controlled by prepub Diamond: open-source visible scopes are the enabled template set, recommendation is the low-risk subset, and the 22 medium-risk scopes are excluded from default login recommend.", "High-risk scopes remain pending and must not be silently granted." ], "nonGoals": [ "Do not trust CLI-provided orgId or uid.", "Do not add a second authorization UI path.", - "Do not make high-risk destructive scopes part of default login recommend." + "Do not make medium-risk or high-risk destructive scopes part of default login recommend." ], "userFlows": [ { @@ -56,7 +56,9 @@ "User runs dws auth login --recommend.", "CLI completes OAuth loopback or device login and saves token.", "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", - "PAT core loads the recommended set, filters destructive scopes server-side, and returns selectedScopes plus warnings/skipped scopes.", + "In an interactive terminal, CLI shows a Feishu-like TUI product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", + "CLI re-plans with the selected productCodes before starting the grant flow.", + "PAT core loads the Diamond-driven recommended set, excludes non-recommended medium-risk scopes from the default plan, and returns selectedScopes plus warnings/skipped scopes.", "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", "PAT core creates a single batch Device Code Flow and returns PAT_BATCH_AUTH_PENDING with flowId/userCode/uri.", "CLI opens uri, polls flow status, and retries the original grant after approval." @@ -163,9 +165,9 @@ { "id": "4", "title": "Implement PAT core recommended filtering and batch DCF", - "description": "PAT core owns selectedScopes, destructive filtering, batch Device Code Flow, selected approval, and idempotent writes.", + "description": "PAT core owns selectedScopes, Diamond-driven recommendation, batch Device Code Flow, selected approval, and idempotent writes.", "steps": [ - "Filter destructive recommended scopes server-side for auth_login_recommend.", + "Keep recommended scope selection config-driven: preauth Diamond recommends the low-risk subset and excludes the 22 medium-risk scopes.", "Create/reuse one batch flow for selected scopes.", "Approve only selectedScopes and reject out-of-flow selections.", "Fail closed on critical storage failures and leave flow retryable." @@ -207,7 +209,7 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "aa76f0101592ea9bebd69c2af80c38376f1e8da5", + "commit": "e82ccd3496719f73de1cf14f1062c771a1e69819", "prepubPackage": "blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", "prepubFunctionalVerification": "pending" }, @@ -229,17 +231,17 @@ "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "0b805ff866aaf255df86f49418fe4c6d4d47e8db", + "commit": "b9df9efe9d7ea78e52b0dbdd052965432485cf02", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "0b805ff866aaf255df86f49418fe4c6d4d47e8db", + "aoneNewestRevision": "b9df9efe9d7ea78e52b0dbdd052965432485cf02", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094916508, - "prepubPackage": "SUCCESS through build, prepub deploy, and prepub integration test on 2026-06-16 16:20:13+08:00; Aone prepub validation stage is still RUNNING.", - "prepubFunctionalVerification": "pending" + "prepubPipelineInstanceId": 3094928748, + "prepubPackage": "SUCCESS through code merge, build, prepub deploy, and prepub integration test on 2026-06-16 16:58:30+08:00; Aone prepub validation stage is WAITING. Latest branch head b9df9ef reverts a non-required recommendation-code experiment and is content-equivalent for the deployed batch authorization fix.", + "prepubFunctionalVerification": "login_recommend_approved_ok" }, { "repo": "lippi-open-app-dev", @@ -253,9 +255,9 @@ "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094919448, - "prepubPackage": "RUNNING on 2026-06-16 16:28:13+08:00 after latest master merge; code merge and build succeeded, prepub deploy was running when checked.", - "prepubFunctionalVerification": "pending" + "prepubPipelineInstanceId": 3094929764, + "prepubPackage": "Build and prepub deploy SUCCESS on 2026-06-16 17:12:43+08:00; Aone prepub integration-test task 3390591557 reported FAIL, while real prepub browser flow through BFF succeeded for login recommend approval.", + "prepubFunctionalVerification": "login_recommend_approved_ok" }, { "repo": "lippi-open-dev-front", @@ -272,9 +274,49 @@ "Every changed repo has a pushed branch or prepub release submission tied to the current change set.", "Prepub package/build succeeds for CLI, gateway, PAT core, BFF, and frontend.", "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", - "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, destructive-scope filtering, high-risk pending, and JSON no-wait pending response.", + "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, Diamond-driven low-risk recommendation, high-risk pending, and JSON no-wait pending response.", "Prepub UI screenshot matches visualDesignTarget." ], + "latestPrepubVerification": { + "verifiedAt": "2026-06-16T17:22:12+08:00", + "recommendationPolicy": { + "sourceOfTruth": "prepub Diamond plus product_scope_template DB", + "openWhitelistDataId": "com.dingtalk.pat.opensource-scope-whitelist", + "openWhitelistScopeCount": 173, + "preauthDataId": "com.dingtalk.pat.wukong-preauth-config", + "preauthVersion": "2026-06-16-dws-low-risk-151-preauth-v1", + "recommendedLowRiskScopeCount": 151, + "excludedMediumRiskScopeCount": 22, + "slsReceipt": "2026-06-16 17:19:26 prehost logs: OpenSourceScopeWhitelistConfigManager scopeCount=173; WukongPreAuthConfigManager recommendAllScopes=false scopeCount=151" + }, + "dryRun": { + "command": "go run ./cmd/main.go pat chmod --recommend --dry-run --agentCode dingmbw5n9ktkkbbjv3g --format json", + "result": "items=151, selected=43, skipped=108, risk_counts={LOW:151}, skipped_reasons={already granted:108}", + "localReceipt": "/tmp/dws-pat-chmod-recommend-dryrun-after-diamond.json" + }, + "loginRecommendApproval": { + "command": "go run ./cmd/main.go auth login --recommend --format json", + "flowId": "a4ef35755fb04c248b0d4319ce73651c", + "userCode": "E9CL-YJCV", + "authorizationUrl": "https://pre-open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Da4ef35755fb04c248b0d4319ce73651c%26userCode%3DE9CL-YJCV#/personalAuthorization?flowId=a4ef35755fb04c248b0d4319ce73651c&userCode=E9CL-YJCV", + "pageObservedText": "DingTalk-Workspace请求你的授权; 可授权的权限(已选43/43项); 同意并授权; 授权成功", + "slsReceipt": "DeviceCodeFlowService.grantApprovedScopesBatch approved permanent via batch insert, flowId=a4ef35755fb04c248b0d4319ce73651c, orgId=439446171, uid=2010955235, agentCode=dingmbw5n9ktkkbbjv3g, scopeCount=43" + }, + "databaseEvidence": { + "dmsGroup": "lippi_pat_core_product_logic", + "db": "product", + "agentCode": "dingmbw5n9ktkkbbjv3g", + "orgId": 439446171, + "uid": 2010955235, + "lowRiskRecommendedGranted": "151 rows, status=1, grant_scope=3", + "selectedIncrementGranted": "43 rows", + "activeMediumRiskGrants": "18 rows existed as historical grants; they were not part of this recommend plan because dry-run risk_counts had no MEDIUM rows", + "resultFiles": [ + "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172211_product.json", + "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172212_product.json" + ] + } + }, "latestDiagnostics": [ "PAT core Aone compile error at PatAuthCheckService.java:517 was caused by a two-argument tryConsumeOnceTokenFromTair call after the method signature changed to require effective risk level. Current feature and release-merge worktrees use RiskLevelEnum.MEDIUM.getCode() for the high-risk-to-medium DCF bypass path and compile locally.", "The Maven warning about requested profile lippi-pat-core is an Aone/Maven parameter warning: the root POM has no profile with id lippi-pat-core. It is not the cause of the Java compilation failure and does not justify a POM change in this slice.", @@ -307,7 +349,7 @@ "selectedScopes": "server-selected grantable scopes", "skippedScopes": "not grantable or filtered scopes", "pendingScopes": "high-risk scopes requiring separate confirmation", - "warnings": "destructive filter and partial selection warnings" + "warnings": "recommendation filter and partial selection warnings" } }, "pat.batch_grant": { @@ -390,21 +432,31 @@ "Do not expose orgId or uid as trusted client input." ], "visualDesignTarget": { - "sourceImage": "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-7792e4ca-7e4b-446b-8527-f35609d4df12.png", + "sourceImage": "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-2078e5f6-b389-4435-8ea5-0f0a94363462.png", + "mgdoneReference": "https://mgdone.alibaba-inc.com/file/187681481694834?file=187681481694834&page_id=74%3A36608&shareId=3b3db511-38e1-4f3d-a859-ab891ed85617", "page": "A white authorization card centered on a light #f5f6fa page, with the footer links below the card.", - "card": "Single white card with subtle #f2f2f6 border, soft shadow, 12px radius, desktop visual width about 660px and enough height to show account, permissions, manage tip, and two bottom buttons without scrolling for four product groups.", - "header": "Top centered CLI app icon, title DingtalkWorkSpace, and subtitle 请求以下钉钉账号进行授权.", - "accountBlock": "Light gray rounded account row below the header, with a blue square DingTalk avatar/tag at left and the current account name next to it.", - "permissionBlock": "Light gray rounded permission panel titled 可授权的权限. Product rows use a blue checked checkbox, bold product name, gray count such as 12 项, gray description text, and a right chevron for expandable details.", + "card": "Single white 480x680 desktop card with rgba(131,137,143,0.16) border, no shadow, and 12px radius.", + "header": "Top centered dynamic app icon only, 56x56 with 8px radius. Do not render the right-side target app icon or any blue/red debug frame. Title is DingtalkWorkSpace, subtitle is 请求以下钉钉账号进行授权.", + "accountBlock": "Light #fafafa rounded 424x60 account row below the header, with real org/user avatar at left and the current account name next to it.", + "permissionBlock": "Light #fafafa rounded 424x300 permission panel titled 可授权的权限. Product rows use a blue checked checkbox, bold product name, gray count such as 12 项, gray description text, and a right chevron for expandable details.", "selectionBehavior": "All recommended groups are checked by default. Product checkbox toggles the entire group; expanding a product reveals individual scope checkboxes.", "manageTip": "Under the permission panel, show the existing 个人数据授权管理 link text for later review or revoke.", "buttons": "Bottom two-button layout: left outlined white 拒绝 button, right primary blue 同意并授权 button, same height and rounded pill style.", "mobile": "On narrow screens the same hierarchy must remain visible without text overlap; buttons share the available width." }, + "terminalTuiTarget": { + "sourceImages": [ + "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-16c11a9e-7ae7-4703-aa66-4251f7b59f16.png", + "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" + ], + "loginRecommendSelector": "After login, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", + "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component.", + "nextStep": "After confirmation, CLI prints or opens the complete encoded authorization URL returned by the server and waits for browser approval." + }, "securityRules": [ "Gateway derives orgId and uid from trusted token/header only.", "Gateway allowlist must include openclaw and dws-cli for batch grant callers that are intended to write.", - "PAT core applies destructive-scope hard filtering for recommend=true and caller=auth_login_recommend.", + "PAT core consumes the Diamond recommendation set for recommend=true and caller=auth_login_recommend; prepub Diamond currently recommends 151 low-risk scopes and excludes the 22 medium-risk scopes.", "PAT core must use flow-stored scopes as the allowlist and reject any selectedScopes value outside the flow.", "PAT core must only clear pending keys owned by the current flow and leave the flow pending if approve write fails after precheck, so the same authorization page can retry safely.", "Critical approve/reject storage writes must fail closed with PAT_BATCH_PARTIAL_FAILURE instead of reporting success on partial persistence.", @@ -414,6 +466,7 @@ "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", + "On prepub, interactive dws auth login --recommend displays a default-all-selected product-domain TUI before opening the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", "On prepub, a recommended config containing a delete/recall/remove/destroy/drop scope returns warnings/skipped scopes and never grants that scope.", diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index a5d8075b..00a2f6b8 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -33,6 +33,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -145,8 +146,14 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if !cfg.Recommend { return nil } + opts := pat.LoginRecommendOptions{} + if !strings.EqualFold(strings.TrimSpace(format), "json") && isInteractiveTerminal() { + opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { + return selectLoginRecommendProducts(products) + } + } run := func(ctx context.Context) error { - return pat.RunLoginRecommendAuthorization(ctx, patCaller, cmd.ErrOrStderr()) + return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts) } err := run(cmd.Context()) if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { @@ -578,6 +585,80 @@ func authLoginDisplayExpiry(data *authpkg.TokenData) string { return "" } +func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) { + if len(products) == 0 { + return nil, nil + } + selected := make([]string, 0, len(products)) + options := make([]huh.Option[string], 0, len(products)) + for _, product := range products { + code := strings.TrimSpace(product.ProductCode) + if code == "" { + continue + } + selected = append(selected, code) + options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true)) + } + if len(options) == 0 { + return nil, nil + } + height := len(options) + if height > 15 { + height = 15 + } + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("选择要授权的业务域"). + Description("[空格]选择 回车确认;默认已选中全部产品线"). + Options(options...). + Height(height). + Value(&selected). + Validate(func(values []string) error { + if len(values) == 0 { + return fmt.Errorf("至少选择一个授权业务域") + } + return nil + }), + ), + ) + if err := form.Run(); err != nil { + return nil, fmt.Errorf("授权业务域选择中止: %w", err) + } + return selected, nil +} + +func loginRecommendProductLabel(product pat.LoginRecommendProduct) string { + name := strings.TrimSpace(product.ProductName) + if name == "" || name == product.ProductCode { + name = product.ProductCode + } + summary := strings.TrimSpace(product.Summary) + if summary != "" { + summary = " — " + clipRunes(summary, 34) + } + count := product.ScopeCount + if product.SelectedScopeCount > 0 && product.SelectedScopeCount != product.ScopeCount { + count = product.SelectedScopeCount + } + countText := "" + if count > 0 { + countText = fmt.Sprintf(" %d项", count) + } + return fmt.Sprintf("%-10s %s%s%s", product.ProductCode, name, countText, summary) +} + +func clipRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "..." +} + func clearCompatCache() { store := cacheStoreFromEnv() if store != nil { diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index b74d2835..aefe27ba 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -32,6 +32,23 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +type LoginRecommendOptions struct { + // ProductCodes limits the recommend plan to service-owned product domains. + ProductCodes []string + // ProductSelector lets interactive callers present the service-owned domain + // list before the final plan. It receives products extracted from the + // initial recommend plan and must return the product codes to grant. + ProductSelector func([]LoginRecommendProduct) ([]string, error) +} + +type LoginRecommendProduct struct { + ProductCode string + ProductName string + Summary string + ScopeCount int + SelectedScopeCount int +} + const ( sessionIDEnvDingtalk = "DINGTALK_SESSION_ID" sessionIDEnvDWS = "DWS_SESSION_ID" @@ -549,22 +566,38 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, // retry are handled by the shared PAT runner once PAT_BATCH_AUTH_PENDING is // classified as a PAT auth check. func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, output io.Writer) error { + return RunLoginRecommendAuthorizationWithOptions(ctx, c, output, LoginRecommendOptions{}) +} + +func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.ToolCaller, output io.Writer, opts LoginRecommendOptions) error { if c == nil { return fmt.Errorf("internal error: tool runtime not initialized") } - planArgs := buildBatchPlanArgs(nil, nil, true, grantTypePermanent, "", "", true) - planArgs["caller"] = patCallerAuthLoginRecommend - planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) - if err != nil { - return fmt.Errorf("pat login recommend plan failed: %w", err) - } - if err := classifyToolResultText(planResult); err != nil { - return err - } - scopes, err := extractSelectedScopesAllowEmpty(planResult) + productCodes := normalizeProductCodes(opts.ProductCodes) + planResult, scopes, err := planLoginRecommend(ctx, c, productCodes) if err != nil { return err } + if opts.ProductSelector != nil { + products, err := extractLoginRecommendProducts(planResult) + if err != nil { + return err + } + if len(products) > 0 { + selectedProducts, err := opts.ProductSelector(products) + if err != nil { + return err + } + productCodes = normalizeProductCodes(selectedProducts) + if len(productCodes) == 0 { + return fmt.Errorf("至少选择一个授权业务域") + } + _, scopes, err = planLoginRecommend(ctx, c, productCodes) + if err != nil { + return err + } + } + } if len(scopes) == 0 { if output != nil { _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") @@ -586,6 +619,23 @@ func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, o return handleToolResultForWriter(output, result, c) } +func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string) (*edition.ToolResult, []string, error) { + planArgs := buildBatchPlanArgs(nil, productCodes, true, grantTypePermanent, "", "", true) + planArgs["caller"] = patCallerAuthLoginRecommend + planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) + if err != nil { + return nil, nil, fmt.Errorf("pat login recommend plan failed: %w", err) + } + if err := classifyToolResultText(planResult); err != nil { + return planResult, nil, err + } + scopes, err := extractSelectedScopesAllowEmpty(planResult) + if err != nil { + return planResult, nil, err + } + return planResult, scopes, nil +} + func newBatchClientRequestID(prefix string) string { prefix = strings.TrimSpace(prefix) if prefix == "" { @@ -594,6 +644,147 @@ func newBatchClientRequestID(prefix string) string { return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) } +func extractLoginRecommendProducts(result *edition.ToolResult) ([]LoginRecommendProduct, 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.items") + } + selectedSet := map[string]bool{} + if rawSelected, _ := data["selectedScopes"].([]any); len(rawSelected) > 0 { + for _, raw := range rawSelected { + if scope, ok := raw.(string); ok && strings.TrimSpace(scope) != "" { + selectedSet[strings.TrimSpace(scope)] = true + } + } + } + + rawItems, _ := data["items"].([]any) + type productGroup struct { + item LoginRecommendProduct + summarySeen map[string]bool + summaryParts []string + } + groups := map[string]*productGroup{} + order := make([]string, 0) + for _, raw := range rawItems { + item, _ := raw.(map[string]any) + if item == nil { + continue + } + scope := stringField(item, "scope") + code := stringField(item, "productCode") + if code == "" { + code = productCodeFromScope(scope) + } + if code == "" { + continue + } + group := groups[code] + if group == nil { + group = &productGroup{ + item: LoginRecommendProduct{ + ProductCode: code, + ProductName: stringField(item, "productName"), + }, + summarySeen: map[string]bool{}, + } + if group.item.ProductName == "" { + group.item.ProductName = code + } + groups[code] = group + order = append(order, code) + } + group.item.ScopeCount++ + if selectedSet[scope] { + group.item.SelectedScopeCount++ + } + summary := stringField(item, "operationSummary") + if summary == "" { + summary = stringField(item, "displayName") + } + if summary != "" && !group.summarySeen[summary] && len(group.summaryParts) < 3 { + group.summarySeen[summary] = true + group.summaryParts = append(group.summaryParts, summary) + } + } + + if len(order) == 0 { + scopes, err := extractSelectedScopesAllowEmpty(result) + if err != nil { + return nil, err + } + for _, scope := range scopes { + code := productCodeFromScope(scope) + if code == "" { + continue + } + if groups[code] == nil { + groups[code] = &productGroup{ + item: LoginRecommendProduct{ + ProductCode: code, + ProductName: code, + ScopeCount: 0, + SelectedScopeCount: 0, + }, + } + order = append(order, code) + } + groups[code].item.ScopeCount++ + groups[code].item.SelectedScopeCount++ + } + } + + products := make([]LoginRecommendProduct, 0, len(order)) + for _, code := range order { + group := groups[code] + if group == nil { + continue + } + group.item.Summary = strings.Join(group.summaryParts, "、") + products = append(products, group.item) + } + return products, nil +} + +func normalizeProductCodes(codes []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(codes)) + for _, raw := range codes { + for _, part := range strings.Split(raw, ",") { + code := strings.TrimSpace(part) + if code == "" || seen[code] { + continue + } + seen[code] = true + out = append(out, code) + } + } + return out +} + +func productCodeFromScope(scope string) string { + scope = strings.TrimSpace(scope) + if scope == "" { + return "" + } + end := len(scope) + if idx := strings.Index(scope, "."); idx >= 0 && idx < end { + end = idx + } + if idx := strings.Index(scope, ":"); idx >= 0 && idx < end { + end = idx + } + return strings.TrimSpace(scope[:end]) +} + func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { text := firstToolResultText(result) if text == "" { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index db426f04..f0a4ed6e 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -759,6 +759,76 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) } } +func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看","operationSummary":"日历管理、日程创建"},{"scope":"doc.document:read","productCode":"doc","productName":"文档","displayName":"文档读取"}],"selectedScopes":["calendar.event:read","doc.document:read"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + var selectorProducts []LoginRecommendProduct + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ProductSelector: func(products []LoginRecommendProduct) ([]string, error) { + selectorProducts = products + return []string{"calendar"}, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(selectorProducts) != 2 { + t.Fatalf("selector products = %d, want 2", len(selectorProducts)) + } + if selectorProducts[0].ProductCode != "calendar" || selectorProducts[0].ProductName != "日历" { + t.Fatalf("first selector product = %+v, want calendar/日历", selectorProducts[0]) + } + if selectorProducts[0].Summary != "日历管理、日程创建" { + t.Fatalf("first selector summary = %q", selectorProducts[0].Summary) + } + if len(fake.calls) != 3 { + t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", 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, nil) { + t.Fatalf("initial plan productCodes = %#v, want empty", got) + } + if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected plan productCodes = %#v, want calendar", got) + } + if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend { + t.Fatalf("selected plan caller = %#v, want %q", got, patCallerAuthLoginRecommend) + } + if fake.calls[2].tool != patBatchGrantToolName { + t.Fatalf("third tool = %q, want %q", fake.calls[2].tool, patBatchGrantToolName) + } + if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) { + t.Fatalf("grant scopes = %#v, want selected calendar scope", got) + } +} + +func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{}); err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want one plan + grant", 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, nil) { + t.Fatalf("plan productCodes = %#v, want empty", 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{ From 690d0788e30d525d8a51225d2c651c2e7bb02da3 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 00:05:44 +0800 Subject: [PATCH 26/61] docs: refresh batch auth release evidence --- docs/batch-auth-prd.json | 41 ++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index bf1b2a45..33460c06 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -203,15 +203,15 @@ ] }, "prepubReleaseGate": { - "overallStatus": "not_passed_until_prepub_package_and_functional_verification_ok", + "overallStatus": "not_passed_until_all_current_heads_have_prepub_package_and_functional_verification_ok", "changedRepos": [ { "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "e82ccd3496719f73de1cf14f1062c771a1e69819", - "prepubPackage": "blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", - "prepubFunctionalVerification": "pending" + "commit": "5a7c0748e39e62f02ca1296e2a9e513a59a94dbe", + "prepubPackage": "pushed to GitHub branch; local go test ./internal/pat ./internal/app passed; blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", + "prepubFunctionalVerification": "pending_current_head_e2e" }, { "repo": "lippi-open-mcpserver-gw", @@ -231,17 +231,17 @@ "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "b9df9efe9d7ea78e52b0dbdd052965432485cf02", + "commit": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "b9df9efe9d7ea78e52b0dbdd052965432485cf02", + "aoneNewestRevision": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094928748, - "prepubPackage": "SUCCESS through code merge, build, prepub deploy, and prepub integration test on 2026-06-16 16:58:30+08:00; Aone prepub validation stage is WAITING. Latest branch head b9df9ef reverts a non-required recommendation-code experiment and is content-equivalent for the deployed batch authorization fix.", - "prepubFunctionalVerification": "login_recommend_approved_ok" + "prepubPackage": "CR 34656143 is INTG and now sees newestRevision 79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3; local Java 11 targeted regression passed, but current head prepub redeploy is pending because PAT deployment was intentionally held for user-side conflict handling.", + "prepubFunctionalVerification": "pending_current_head_redeploy" }, { "repo": "lippi-open-app-dev", @@ -263,11 +263,17 @@ "repo": "lippi-open-dev-front", "localPath": "/Users/xuan/lippi-open-dev-front", "branch": "codex/batch-auth-page-flow", - "commit": "356dcbab5d02c9f0225baabbd80a98b28d6f92b3", + "commit": "065178a7011174c14952c89992022968a8a49a7b", "aoneAppId": null, "aoneCrId": null, - "prepubPackage": "blocked_no_aone_app_found_for_gitlab_repo_dingding_lippi_open_dev_front_local_npm_build_passed; latest branch head 356dcbab5 tightens batch detection and reject terminal state, local npm run build passed with existing bundle-size warnings only.", - "prepubFunctionalVerification": "pending" + "prepubPackage": "O2 assets app does not support pub-env=pre; a1 o2 task create --iteration-id 16804012 --pub-env pre returned unsupported. Non-production daily assets build/deploy succeeded on task 149432667 for commit 065178a7011174c14952c89992022968a8a49a7b, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/. Local npm run build also passed with existing bundle-size warnings only.", + "prepubFunctionalVerification": "pending_reload_pre_open_dev_page_after_assets_propagation", + "o2AppId": 8780, + "o2IterationId": 16804012, + "o2TaskId": 149432667, + "o2FlowId": 114592128, + "o2ReleaseVersion": "5.219.0", + "o2PublishEnv": "daily" } ], "requiredEvidence": [ @@ -275,7 +281,8 @@ "Prepub package/build succeeds for CLI, gateway, PAT core, BFF, and frontend.", "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, Diamond-driven low-risk recommendation, high-risk pending, and JSON no-wait pending response.", - "Prepub UI screenshot matches visualDesignTarget." + "Prepub UI screenshot matches visualDesignTarget.", + "Frontend O2 assets app uses daily as the available non-production channel because prepub is unsupported for assets pub type." ], "latestPrepubVerification": { "verifiedAt": "2026-06-16T17:22:12+08:00", @@ -332,7 +339,12 @@ "Gateway latest branch head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9. It rejects pat.batch_grant calls that carry plan-only recommend/productCodes fields, normalizes non-empty selected scopes before forwarding to PAT core, and has local verification uv run python -m pytest test/test_pat_batch_bridge.py -q: 26 passed.", "Frontend latest branch head is 356dcbab5d02c9f0225baabbd80a98b28d6f92b3. It only renders the batch page when the server explicitly marks batchFlow/isBatchFlow/flowType=BATCH, and reject success falls back to FLOW_REJECTED so the terminal page shows 已拒绝授权 instead of 授权失败. Verification: npm run build passed with existing bundle-size warnings.", "lark-cli parity check on 2026-06-16: lark-cli auth login --help does not list --yes, and lark-cli auth login --yes --no-wait --json exits with Error: unknown flag: --yes. Lark recommended login therefore still requires Device Flow/browser authorization and does not provide a --yes bypass.", - "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only." + "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only.", + "2026-06-16 23:59+08:00 frontend review follow-up: src/app/personalAuthorization/index.jsx now matches requested batch copy: title 可授权的权限, product group count as N 项, footer link 个人数据授权管理. Commit 065178a7011174c14952c89992022968a8a49a7b pushed to codex/batch-auth-page-flow.", + "Frontend O2 release route confirmed: app lippi-open-dev-front id=8780, iteration 16804012 named pat页面授权, branch codex/batch-auth-page-flow. O2 iteration id 86573 from the provided Dbase URL maps to an old pmod iteration in a1 o2, so 16804012 is the correct O2 release target discovered from app 8780.", + "Frontend O2 non-production task 149432667 succeeded for commit 065178a7011174c14952c89992022968a8a49a7b, flow 114592128, version 5.219.0, pub_env daily. Attempting pub_env pre returned: assets does not support prepub.", + "Independent cross-repo review found no P0/P1 contract break in CLI -> gateway -> BFF -> PAT -> frontend for selectedScopes. The important contract is that gateway pat.batch_grant takes scopes, while CLI/BFF use selectedScopes only at their own API boundaries before converting/passing to PAT.", + "CLI recommend login TUI default-selects all product domains before plan/grant, matching the Lark-like pre-authorization selector requirement. Local verification passed with go test ./internal/pat ./internal/app." ] }, "serviceContract": { @@ -481,5 +493,6 @@ "qinzePuaReviewer": "Review security, idempotency, tests, and failure modes.", "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." - } + }, + "updatedAt": "2026-06-16T23:59:59+08:00" } From 42c71c9c54e3d8d5537cd04e8aaae02a1ce48087 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 00:08:51 +0800 Subject: [PATCH 27/61] docs: add frontend cdn verification --- docs/batch-auth-prd.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 33460c06..4005f9e5 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -344,7 +344,8 @@ "Frontend O2 release route confirmed: app lippi-open-dev-front id=8780, iteration 16804012 named pat页面授权, branch codex/batch-auth-page-flow. O2 iteration id 86573 from the provided Dbase URL maps to an old pmod iteration in a1 o2, so 16804012 is the correct O2 release target discovered from app 8780.", "Frontend O2 non-production task 149432667 succeeded for commit 065178a7011174c14952c89992022968a8a49a7b, flow 114592128, version 5.219.0, pub_env daily. Attempting pub_env pre returned: assets does not support prepub.", "Independent cross-repo review found no P0/P1 contract break in CLI -> gateway -> BFF -> PAT -> frontend for selectedScopes. The important contract is that gateway pat.batch_grant takes scopes, while CLI/BFF use selectedScopes only at their own API boundaries before converting/passing to PAT.", - "CLI recommend login TUI default-selects all product domains before plan/grant, matching the Lark-like pre-authorization selector requirement. Local verification passed with go test ./internal/pat ./internal/app." + "CLI recommend login TUI default-selects all product domains before plan/grant, matching the Lark-like pre-authorization selector requirement. Local verification passed with go test ./internal/pat ./internal/app.", + "Frontend CDN verification after O2 task 149432667: https://dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/bundle.js contains 个人数据授权管理 and 可授权的权限, and no longer contains 应用授权管理 or 可授权的权限(已选." ] }, "serviceContract": { From 9ee8ea552406d1804f062f2f9165a031b1f87ab3 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 00:34:23 +0800 Subject: [PATCH 28/61] fix: show recommend selector by default --- internal/app/auth_command.go | 17 +++++++++++- internal/app/auth_command_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 00a2f6b8..d6a2d9b9 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -147,7 +147,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return nil } opts := pat.LoginRecommendOptions{} - if !strings.EqualFold(strings.TrimSpace(format), "json") && isInteractiveTerminal() { + if authLoginShouldShowRecommendSelector(cmd, format) { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return selectLoginRecommendProducts(products) } @@ -628,6 +628,21 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin return selected, nil } +func authLoginShouldShowRecommendSelector(cmd *cobra.Command, format string) bool { + return authLoginShouldShowRecommendSelectorForTerminal(cmd, format, isInteractiveTerminal()) +} + +func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format string, interactive bool) bool { + if !interactive { + return false + } + if !strings.EqualFold(strings.TrimSpace(format), "json") { + return true + } + flags := cmd.Root().PersistentFlags() + return !flags.Changed("format") +} + func loginRecommendProductLabel(product pat.LoginRecommendProduct) string { name := strings.TrimSpace(product.ProductName) if name == "" || name == product.ProductCode { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index b6a1e85a..5117227f 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -26,6 +26,7 @@ import ( 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/internal/keychain" + "github.com/spf13/cobra" ) func TestAuthExportImportBase64RoundTrip(t *testing.T) { @@ -180,6 +181,49 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { } } +func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { + newRoot := func(t *testing.T) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "json", "") + return root + } + + t.Run("default json still shows selector for interactive login", func(t *testing.T) { + root := newRoot(t) + if !authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { + t.Fatal("default json format should still show recommend selector in an interactive terminal") + } + }) + + t.Run("explicit json keeps machine mode", func(t *testing.T) { + root := newRoot(t) + if err := root.PersistentFlags().Set("format", "json"); err != nil { + t.Fatalf("set format: %v", err) + } + if authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { + t.Fatal("explicit --format json must not show recommend selector") + } + }) + + t.Run("table shows selector", func(t *testing.T) { + root := newRoot(t) + if err := root.PersistentFlags().Set("format", "table"); err != nil { + t.Fatalf("set format: %v", err) + } + if !authLoginShouldShowRecommendSelectorForTerminal(root, "table", true) { + t.Fatal("table format should show recommend selector in an interactive terminal") + } + }) + + t.Run("non interactive skips selector", func(t *testing.T) { + root := newRoot(t) + if authLoginShouldShowRecommendSelectorForTerminal(root, "json", false) { + t.Fatal("non-interactive login should skip recommend selector") + } + }) +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { From d29aebeecdfb73783c8b1de05a4d8c7f32a1916b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 00:44:58 +0800 Subject: [PATCH 29/61] docs: record current batch auth gate status --- docs/batch-auth-prd.json | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 4005f9e5..5f1c96eb 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -203,15 +203,15 @@ ] }, "prepubReleaseGate": { - "overallStatus": "not_passed_until_all_current_heads_have_prepub_package_and_functional_verification_ok", + "overallStatus": "not_passed_until_gateway_pat_current_heads_redeployed_and_bff_integration_test_resolved", "changedRepos": [ { "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "5a7c0748e39e62f02ca1296e2a9e513a59a94dbe", - "prepubPackage": "pushed to GitHub branch; local go test ./internal/pat ./internal/app passed; blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo", - "prepubFunctionalVerification": "pending_current_head_e2e" + "commit": "9ee8ea552406d1804f062f2f9165a031b1f87ab3", + "prepubPackage": "pushed to GitHub branch and rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_selector_contract_ok; current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -224,8 +224,8 @@ "aoneCrStatusLabel": "发布中", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094877547, - "prepubPackage": "pipeline_reached_final_prepub_validation_waiting_state", - "prepubFunctionalVerification": "pending" + "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3094877547 had build/deploy/integration success and deploy orders 155284562/155284566 succeeded, but that deploy completed before current head a0873a6 was committed; current head is not proven deployed. Final task 3390336920 is WAITING: CR34656146,CR34302328,CR34364191,CR34598300 待验证.", + "prepubFunctionalVerification": "pending_current_head_redeploy_or_equivalent_receipt" }, { "repo": "lippi-pat-core-wukong-capability", @@ -240,7 +240,7 @@ "aoneNewestRevision": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094928748, - "prepubPackage": "CR 34656143 is INTG and now sees newestRevision 79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3; local Java 11 targeted regression passed, but current head prepub redeploy is pending because PAT deployment was intentionally held for user-side conflict handling.", + "prepubPackage": "Aone CR 34656143 latest pipeline 66 instance 3094948452 had build/deploy/integration success and deploy order 155318893 succeeded, but that deploy completed before current head 79a8fa5 was committed; current head is not proven deployed. Final task 3390679518 is WAITING: CR34656143 待验证.", "prepubFunctionalVerification": "pending_current_head_redeploy" }, { @@ -256,22 +256,22 @@ "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3094929764, - "prepubPackage": "Build and prepub deploy SUCCESS on 2026-06-16 17:12:43+08:00; Aone prepub integration-test task 3390591557 reported FAIL, while real prepub browser flow through BFF succeeded for login recommend approval.", - "prepubFunctionalVerification": "login_recommend_approved_ok" + "prepubPackage": "Latest Aone pipeline 66 instance 3095006435 built and deployed current head 9ac576c to prepub/non-prod; deploy orders 155345552 and 155345553 succeeded. Pipeline final status remains FAIL because integration test task 3390939831 failed; validation task 3390939832 remains INIT.", + "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" }, { "repo": "lippi-open-dev-front", "localPath": "/Users/xuan/lippi-open-dev-front", "branch": "codex/batch-auth-page-flow", - "commit": "065178a7011174c14952c89992022968a8a49a7b", + "commit": "f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4", "aoneAppId": null, "aoneCrId": null, - "prepubPackage": "O2 assets app does not support pub-env=pre; a1 o2 task create --iteration-id 16804012 --pub-env pre returned unsupported. Non-production daily assets build/deploy succeeded on task 149432667 for commit 065178a7011174c14952c89992022968a8a49a7b, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/. Local npm run build also passed with existing bundle-size warnings only.", - "prepubFunctionalVerification": "pending_reload_pre_open_dev_page_after_assets_propagation", + "prepubPackage": "O2 assets app still does not support pub-env=pre. Non-production daily assets build/deploy succeeded on task 149432767 for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, build id 53073038, asset count 203, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/. Local npm run build passed with existing bundle-size warnings only.", + "prepubFunctionalVerification": "frontend_daily_cdn_copy_and_account_avatar_priority_ok; full_e2e_pending_server_current_heads", "o2AppId": 8780, "o2IterationId": 16804012, - "o2TaskId": 149432667, - "o2FlowId": 114592128, + "o2TaskId": 149432767, + "o2FlowId": 114592657, "o2ReleaseVersion": "5.219.0", "o2PublishEnv": "daily" } @@ -345,7 +345,12 @@ "Frontend O2 non-production task 149432667 succeeded for commit 065178a7011174c14952c89992022968a8a49a7b, flow 114592128, version 5.219.0, pub_env daily. Attempting pub_env pre returned: assets does not support prepub.", "Independent cross-repo review found no P0/P1 contract break in CLI -> gateway -> BFF -> PAT -> frontend for selectedScopes. The important contract is that gateway pat.batch_grant takes scopes, while CLI/BFF use selectedScopes only at their own API boundaries before converting/passing to PAT.", "CLI recommend login TUI default-selects all product domains before plan/grant, matching the Lark-like pre-authorization selector requirement. Local verification passed with go test ./internal/pat ./internal/app.", - "Frontend CDN verification after O2 task 149432667: https://dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/bundle.js contains 个人数据授权管理 and 可授权的权限, and no longer contains 应用授权管理 or 可授权的权限(已选." + "Frontend CDN verification after O2 task 149432667: https://dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/bundle.js contains 个人数据授权管理 and 可授权的权限, and no longer contains 应用授权管理 or 可授权的权限(已选.", + "2026-06-17 CLI fix: Bacon review found plain dws auth login --recommend skipped the Lark-like product selector because global --format defaults to json. Commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3 changes auth login recommend selector gating so default json still shows the TUI in an interactive terminal, while explicit --format json and non-interactive shells skip it. Verification: go test ./internal/app ./internal/pat passed.", + "2026-06-17 local default dws binary refreshed: /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from dingtalk-workspace-cli commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3; dws version --format json reports version dev, edition open.", + "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", + "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", + "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is deployed, but Aone integration-test task 3390939831 is FAIL. Full prepub e2e remains blocked on gateway/PAT current-head redeploy plus BFF integration-test resolution or accepted exception." ] }, "serviceContract": { @@ -495,5 +500,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-16T23:59:59+08:00" + "updatedAt": "2026-06-17T00:42:00+08:00" } From 70b9f39715566a8306f8d437cc62ad2f12934449 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 00:58:27 +0800 Subject: [PATCH 30/61] fix: use human recommend flow by default --- internal/app/auth_command.go | 23 ++++++++++++++++++----- internal/app/auth_command_test.go | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index d6a2d9b9..69120884 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -142,16 +142,21 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { w := cmd.OutOrStdout() format, _ := cmd.Root().PersistentFlags().GetString("format") + recommendHumanMode := authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) runRecommend := func() error { if !cfg.Recommend { return nil } opts := pat.LoginRecommendOptions{} - if authLoginShouldShowRecommendSelector(cmd, format) { + if recommendHumanMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return selectLoginRecommendProducts(products) } } + retryFormat := format + if recommendHumanMode { + retryFormat = "table" + } run := func(ctx context.Context) error { return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts) } @@ -159,7 +164,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { return runDirectPATAuthCheck( cmd.Context(), - &GlobalFlags{Format: format}, + &GlobalFlags{Format: retryFormat}, patErr, run, cmd.ErrOrStderr(), @@ -169,7 +174,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } // Check if JSON output is requested - if strings.EqualFold(strings.TrimSpace(format), "json") { + if strings.EqualFold(strings.TrimSpace(format), "json") && !recommendHumanMode { if err := runRecommend(); err != nil { return err } @@ -629,11 +634,19 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin } func authLoginShouldShowRecommendSelector(cmd *cobra.Command, format string) bool { - return authLoginShouldShowRecommendSelectorForTerminal(cmd, format, isInteractiveTerminal()) + return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, isInteractiveTerminal()) } func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format string, interactive bool) bool { - if !interactive { + return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, interactive) +} + +func authLoginShouldUseRecommendHumanMode(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, recommend, isInteractiveTerminal()) +} + +func authLoginShouldUseRecommendHumanModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + if !recommend || !interactive { return false } if !strings.EqualFold(strings.TrimSpace(format), "json") { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 5117227f..b3829382 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -194,6 +194,9 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { if !authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { t.Fatal("default json format should still show recommend selector in an interactive terminal") } + if !authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, true) { + t.Fatal("default json format should use human recommend flow in an interactive terminal") + } }) t.Run("explicit json keeps machine mode", func(t *testing.T) { @@ -204,6 +207,9 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { if authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { t.Fatal("explicit --format json must not show recommend selector") } + if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, true) { + t.Fatal("explicit --format json must keep machine-readable recommend flow") + } }) t.Run("table shows selector", func(t *testing.T) { @@ -214,6 +220,9 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { if !authLoginShouldShowRecommendSelectorForTerminal(root, "table", true) { t.Fatal("table format should show recommend selector in an interactive terminal") } + if !authLoginShouldUseRecommendHumanModeForTerminal(root, "table", true, true) { + t.Fatal("table format should use human recommend flow in an interactive terminal") + } }) t.Run("non interactive skips selector", func(t *testing.T) { @@ -221,6 +230,16 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { if authLoginShouldShowRecommendSelectorForTerminal(root, "json", false) { t.Fatal("non-interactive login should skip recommend selector") } + if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, false) { + t.Fatal("non-interactive login should keep machine-readable recommend flow") + } + }) + + t.Run("without recommend keeps normal login output contract", func(t *testing.T) { + root := newRoot(t) + if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", false, true) { + t.Fatal("login without --recommend should not switch default json to human mode") + } }) } From a613728fbd41184d26f4a51126c4537da400a1f5 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 01:03:04 +0800 Subject: [PATCH 31/61] docs: record recommend tui human flow --- docs/batch-auth-prd.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 5f1c96eb..519d4c4c 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -209,9 +209,9 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "9ee8ea552406d1804f062f2f9165a031b1f87ab3", + "commit": "70b9f39715566a8306f8d437cc62ad2f12934449", "prepubPackage": "pushed to GitHub branch and rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_selector_contract_ok; current_head_e2e_pending_server_redeploy" + "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; explicit --format json and non-interactive shells keep structured JSON. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -348,6 +348,7 @@ "Frontend CDN verification after O2 task 149432667: https://dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/bundle.js contains 个人数据授权管理 and 可授权的权限, and no longer contains 应用授权管理 or 可授权的权限(已选.", "2026-06-17 CLI fix: Bacon review found plain dws auth login --recommend skipped the Lark-like product selector because global --format defaults to json. Commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3 changes auth login recommend selector gating so default json still shows the TUI in an interactive terminal, while explicit --format json and non-interactive shells skip it. Verification: go test ./internal/app ./internal/pat passed.", "2026-06-17 local default dws binary refreshed: /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from dingtalk-workspace-cli commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3; dws version --format json reports version dev, edition open.", + "2026-06-17 CLI follow-up: commit 70b9f39715566a8306f8d437cc62ad2f12934449 changes interactive dws auth login --recommend to use a human recommend flow when --format is not explicitly set. This prevents the PAT authorization wait path from falling back to machine JSON after the TUI. Default interactive behavior is now: login success summary, all-selected product-domain TUI, server re-plan, complete authorization URL print/open, browser approval polling. Explicit --format json and non-interactive shells remain structured/no-block. Verification: go test ./internal/app ./internal/pat passed, and /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from this commit.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is deployed, but Aone integration-test task 3390939831 is FAIL. Full prepub e2e remains blocked on gateway/PAT current-head redeploy plus BFF integration-test resolution or accepted exception." @@ -500,5 +501,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T00:42:00+08:00" + "updatedAt": "2026-06-17T00:58:00+08:00" } From 5e1983b3df4a35f2e694972ecfc4207132c79671 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 15:06:14 +0800 Subject: [PATCH 32/61] fix: align recommend tui interaction --- internal/app/auth_command.go | 14 +++----------- internal/app/auth_command_test.go | 13 +++++++++++++ internal/app/skill_setup.go | 9 ++++++++- internal/app/skill_setup_test.go | 16 ++++++++++++++++ internal/app/upgrade_embedded_guard_test.go | 2 +- test/unit/pat_stderr_contract_test.go | 1 + 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 69120884..bf5d2290 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -615,7 +615,7 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin huh.NewGroup( huh.NewMultiSelect[string](). Title("选择要授权的业务域"). - Description("[空格]选择 回车确认;默认已选中全部产品线"). + Description("空格选择 回车确认"). Options(options...). Height(height). Value(&selected). @@ -663,17 +663,9 @@ func loginRecommendProductLabel(product pat.LoginRecommendProduct) string { } summary := strings.TrimSpace(product.Summary) if summary != "" { - summary = " — " + clipRunes(summary, 34) + summary = " - " + clipRunes(summary, 42) } - count := product.ScopeCount - if product.SelectedScopeCount > 0 && product.SelectedScopeCount != product.ScopeCount { - count = product.SelectedScopeCount - } - countText := "" - if count > 0 { - countText = fmt.Sprintf(" %d项", count) - } - return fmt.Sprintf("%-10s %s%s%s", product.ProductCode, name, countText, summary) + return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary) } func clipRunes(value string, limit int) string { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index b3829382..351da262 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -26,6 +26,7 @@ import ( 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/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/spf13/cobra" ) @@ -243,6 +244,18 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { }) } +func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) { + label := loginRecommendProductLabel(pat.LoginRecommendProduct{ + ProductCode: "approval", + ProductName: "审批", + Summary: "审批实例,审批模板,审批任务管理", + ScopeCount: 12, + }) + if label != "approval 审批 - 审批实例,审批模板,审批任务管理" { + t.Fatalf("label = %q", label) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 4a43f195..0e51d8fc 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -639,7 +639,14 @@ func copyFileContent(src, dst string, mode os.FileMode) error { } func isInteractiveTerminal() bool { - fi, err := os.Stdin.Stat() + return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr) +} + +func isCharDevice(file *os.File) bool { + if file == nil { + return false + } + fi, err := file.Stat() if err != nil { return false } diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 6d28c9f9..26368851 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -47,6 +47,22 @@ func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) { } } +func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) { + if isCharDevice(nil) { + t.Fatal("nil file must not be treated as interactive") + } + + file, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + if isCharDevice(file) { + t.Fatal("regular files must not be treated as interactive terminals") + } +} + func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) { tmp := t.TempDir() monoDir := filepath.Join(tmp, "skills", "mono") diff --git a/internal/app/upgrade_embedded_guard_test.go b/internal/app/upgrade_embedded_guard_test.go index 9ec5084b..e3096e8c 100644 --- a/internal/app/upgrade_embedded_guard_test.go +++ b/internal/app/upgrade_embedded_guard_test.go @@ -11,7 +11,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) -func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) { +func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) { prev := edition.Get() edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"}) t.Cleanup(func() { edition.Override(prev) }) diff --git a/test/unit/pat_stderr_contract_test.go b/test/unit/pat_stderr_contract_test.go index 7e6f3df9..e162dcab 100644 --- a/test/unit/pat_stderr_contract_test.go +++ b/test/unit/pat_stderr_contract_test.go @@ -248,6 +248,7 @@ func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) { "PAT_MEDIUM_RISK_NO_PERMISSION", "PAT_HIGH_RISK_NO_PERMISSION", "PAT_SCOPE_AUTH_REQUIRED", + "PAT_BATCH_AUTH_PENDING", "AGENT_CODE_NOT_EXISTS", } From da08e84e802d19bc1fc8d570c66a56caf70c7c21 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 15:13:26 +0800 Subject: [PATCH 33/61] docs: record mgdone tui evidence --- docs/batch-auth-prd.json | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 519d4c4c..95b53d10 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -209,9 +209,9 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "70b9f39715566a8306f8d437cc62ad2f12934449", - "prepubPackage": "pushed to GitHub branch and rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; explicit --format json and non-interactive shells keep structured JSON. current_head_e2e_pending_server_redeploy" + "commit": "5e1983b3df4a35f2e694972ecfc4207132c79671", + "prepubPackage": "pushed to GitHub branch and rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; TUI label matches MGDone terminal target as productCode + productName + summary; explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -255,8 +255,8 @@ "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094929764, - "prepubPackage": "Latest Aone pipeline 66 instance 3095006435 built and deployed current head 9ac576c to prepub/non-prod; deploy orders 155345552 and 155345553 succeeded. Pipeline final status remains FAIL because integration test task 3390939831 failed; validation task 3390939832 remains INIT.", + "prepubPipelineInstanceId": 3095011879, + "prepubPackage": "Latest Aone pipeline 66 instance 3095011879 had code merge/build/prepub deploy SUCCESS, with deploy-order 155348765 completed at 2026-06-17T01:05:26+08:00. However deploy-order 155348765 still records CR 34656145 revision f4222f3b2acba1899446d338c4b3788b8a58b29f, not current head 9ac576c77d142b5f4ec318c8d7b8ecf472ea6341, so current head is not proven deployed. Pipeline final status remains FAIL because integration test task 3390965943 failed; validation task 3390965944 remains INIT.", "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" }, { @@ -349,9 +349,11 @@ "2026-06-17 CLI fix: Bacon review found plain dws auth login --recommend skipped the Lark-like product selector because global --format defaults to json. Commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3 changes auth login recommend selector gating so default json still shows the TUI in an interactive terminal, while explicit --format json and non-interactive shells skip it. Verification: go test ./internal/app ./internal/pat passed.", "2026-06-17 local default dws binary refreshed: /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from dingtalk-workspace-cli commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3; dws version --format json reports version dev, edition open.", "2026-06-17 CLI follow-up: commit 70b9f39715566a8306f8d437cc62ad2f12934449 changes interactive dws auth login --recommend to use a human recommend flow when --format is not explicitly set. This prevents the PAT authorization wait path from falling back to machine JSON after the TUI. Default interactive behavior is now: login success summary, all-selected product-domain TUI, server re-plan, complete authorization URL print/open, browser approval polling. Explicit --format json and non-interactive shells remain structured/no-block. Verification: go test ./internal/app ./internal/pat passed, and /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from this commit.", + "2026-06-17 CLI TUI design follow-up: commit 5e1983b3df4a35f2e694972ecfc4207132c79671 aligns the recommend selector with the MGDone terminal target: title 选择要授权的业务域, description 空格选择 回车确认, default-selected all product domains, and product labels rendered as productCode + productName + summary, e.g. approval 审批 - 审批实例,审批模板,审批任务管理. It also tightens interactive detection to require stdin/stdout/stderr all be TTY so piped systems stay in structured mode. Verification passed: go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", + "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", - "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is deployed, but Aone integration-test task 3390939831 is FAIL. Full prepub e2e remains blocked on gateway/PAT current-head redeploy plus BFF integration-test resolution or accepted exception." + "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception." ] }, "serviceContract": { @@ -469,7 +471,8 @@ "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" ], "loginRecommendSelector": "After login, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", - "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component.", + "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", + "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", "nextStep": "After confirmation, CLI prints or opens the complete encoded authorization URL returned by the server and waits for browser approval." }, "securityRules": [ @@ -501,5 +504,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T00:58:00+08:00" + "updatedAt": "2026-06-17T15:10:32+08:00" } From 3ec35e30e828b088a8e9415d76804f56feed6354 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 15:50:42 +0800 Subject: [PATCH 34/61] fix: respect yes for recommend login grants --- internal/app/auth_command.go | 10 ++++++++-- internal/app/auth_command_test.go | 29 +++++++++++++++++++++++++++++ internal/pat/chmod.go | 9 +++++++-- internal/pat/chmod_test.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index bf5d2290..2064399d 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -42,6 +42,7 @@ type authLoginConfig struct { Force bool Device bool Recommend bool + Yes bool } func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { @@ -142,12 +143,12 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { w := cmd.OutOrStdout() format, _ := cmd.Root().PersistentFlags().GetString("format") - recommendHumanMode := authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) + recommendHumanMode := !cfg.Yes && authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) runRecommend := func() error { if !cfg.Recommend { return nil } - opts := pat.LoginRecommendOptions{} + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes} if recommendHumanMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return selectLoginRecommendProducts(products) @@ -703,11 +704,16 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") } + yes := false + if cmd.Root() != nil { + yes, _ = cmd.Root().PersistentFlags().GetBool("yes") + } return authLoginConfig{ Token: strings.TrimSpace(token), Force: force, Device: device, Recommend: recommend, + Yes: yes, }, nil } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 351da262..49dc33aa 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -256,6 +256,35 @@ func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) { } } +func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + root.AddCommand(login) + + if err := root.PersistentFlags().Set("yes", "true"); err != nil { + t.Fatalf("set yes: %v", err) + } + if err := login.Flags().Set("recommend", "true"); err != nil { + t.Fatalf("set recommend: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if !cfg.Recommend { + t.Fatal("Recommend = false, want true") + } + if !cfg.Yes { + t.Fatal("Yes = false, want true") + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index aefe27ba..68de5b45 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -35,6 +35,9 @@ import ( type LoginRecommendOptions struct { // ProductCodes limits the recommend plan to service-owned product domains. ProductCodes []string + // Confirmed grants the server-selected recommend scopes directly. When + // false, recommend authorization starts the batch confirmation page. + Confirmed bool // ProductSelector lets interactive callers present the service-owned domain // list before the final plan. It receives products extracted from the // initial recommend plan and must return the product codes to grant. @@ -607,11 +610,13 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To grantArgs := map[string]any{ "scopes": scopes, "grantType": grantTypePermanent, - "startFlow": true, - "noWait": true, "caller": patCallerAuthLoginRecommend, "clientRequestId": newBatchClientRequestID("auth-login-recommend"), } + if !opts.Confirmed { + grantArgs["startFlow"] = true + grantArgs["noWait"] = true + } result, err := callPATBatchToolWithIdentityFallback(ctx, c, "", "", patBatchGrantToolName, grantArgs) if err != nil { return fmt.Errorf("pat login recommend grant failed: %w", err) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index f0a4ed6e..3eeee555 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -827,6 +827,37 @@ func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) } + if got := fake.calls[1].args["startFlow"]; got != true { + t.Fatalf("startFlow = %#v, want true for unconfirmed recommend login", got) + } + if got := fake.calls[1].args["noWait"]; got != true { + t.Fatalf("noWait = %#v, want true for unconfirmed recommend login", got) + } +} + +func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{Confirmed: true}); err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want one plan + grant", len(fake.calls)) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if _, ok := fake.calls[1].args["startFlow"]; ok { + t.Fatalf("startFlow should be omitted for confirmed recommend login") + } + if _, ok := fake.calls[1].args["noWait"]; ok { + t.Fatalf("noWait should be omitted for confirmed recommend login") + } + if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend { + t.Fatalf("caller = %#v, want %q", got, patCallerAuthLoginRecommend) + } } func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) { From a31a29e0ab990870567fe0fc441371f68a40a204 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 16:02:25 +0800 Subject: [PATCH 35/61] docs: record recommend yes gate --- docs/batch-auth-prd.json | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 95b53d10..48c597f4 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -20,7 +20,7 @@ "scopeCatalog": "lark-cli auth scopes", "scopeCheck": "lark-cli auth check --scope ", "qrCode": "lark-cli auth qrcode ", - "yesFlag": "lark-cli auth login has no --yes flag; lark-cli auth login --yes --no-wait --json returns Error: unknown flag: --yes. Therefore --yes must not be treated as an auth-login popup bypass." + "yesFlag": "lark-cli auth login has no --yes flag; lark-cli auth login --yes --no-wait --json returns Error: unknown flag: --yes. DWS intentionally differs because --yes is an inherited batch-auto-confirm flag: dws auth login --recommend --yes must skip the browser confirmation flow and grant the service-selected scopes directly." }, "ralphCli": { "model": "PRD-driven long-running development with explicit task ledger, progress notes, verification gates, and failure analysis.", @@ -115,9 +115,9 @@ }, { "lark": "auth login --yes", - "dws": "not supported for dws auth login; --yes belongs only to explicit batch grant commands such as dws pat chmod", - "serverContract": "auth login --recommend must still open or return the authorization page. For chmod-style batch commands, --yes may skip local CLI confirmation and attempt direct grant, but server-side pending/high-risk/page-required responses still win.", - "status": "verified on 2026-06-16 with local lark-cli: unknown flag --yes" + "dws": "supported only as dws auth login --recommend --yes; inherited --yes skips local TUI and web confirmation and directly grants the PAT-core selected safe recommend scopes", + "serverContract": "auth login --recommend without --yes must still open or return the authorization page. With --yes, the CLI omits startFlow/noWait and relies on PAT core to grant only service-selected safe scopes; server-side pending/high-risk/page-required responses still win.", + "status": "verified on 2026-06-16 with local lark-cli: unknown flag --yes; DWS --yes semantics are product-specific and intentionally bypass the web confirmation page for recommend direct grant" }, { "lark": "auth scopes and auth check", @@ -203,15 +203,15 @@ ] }, "prepubReleaseGate": { - "overallStatus": "not_passed_until_gateway_pat_current_heads_redeployed_and_bff_integration_test_resolved", + "overallStatus": "not_passed_until_gateway_pat_bff_current_heads_are_deployed_and_prepub_functional_verification_passes", "changedRepos": [ { "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "5e1983b3df4a35f2e694972ecfc4207132c79671", - "prepubPackage": "pushed to GitHub branch and rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; TUI label matches MGDone terminal target as productCode + productName + summary; explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. current_head_e2e_pending_server_redeploy" + "commit": "3ec35e30e828b088a8e9415d76804f56feed6354", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 3ec35e30e828b088a8e9415d76804f56feed6354; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; TUI label matches MGDone terminal target as productCode + productName + summary; explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct grant behavior. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -223,8 +223,8 @@ "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094877547, - "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3094877547 had build/deploy/integration success and deploy orders 155284562/155284566 succeeded, but that deploy completed before current head a0873a6 was committed; current head is not proven deployed. Final task 3390336920 is WAITING: CR34656146,CR34302328,CR34364191,CR34598300 待验证.", + "prepubPipelineInstanceId": 3095046153, + "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095046153 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391110450. Deploy orders 155362867/155362869 succeeded, but deployed branchRevision is ffaa951 while CR newestRevision/current head is a0873a6, so current head is not proven active in prepub.", "prepubFunctionalVerification": "pending_current_head_redeploy_or_equivalent_receipt" }, { @@ -239,8 +239,8 @@ "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", "aoneNewestRevision": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3094928748, - "prepubPackage": "Aone CR 34656143 latest pipeline 66 instance 3094948452 had build/deploy/integration success and deploy order 155318893 succeeded, but that deploy completed before current head 79a8fa5 was committed; current head is not proven deployed. Final task 3390679518 is WAITING: CR34656143 待验证.", + "prepubPipelineInstanceId": 3095097702, + "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095097702 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391355313. Deploy order 155387876 succeeded, but deployed branchRevision is b38e29e while CR newestRevision/current head is 79a8fa5, so current head is not proven active in prepub.", "prepubFunctionalVerification": "pending_current_head_redeploy" }, { @@ -255,8 +255,8 @@ "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095011879, - "prepubPackage": "Latest Aone pipeline 66 instance 3095011879 had code merge/build/prepub deploy SUCCESS, with deploy-order 155348765 completed at 2026-06-17T01:05:26+08:00. However deploy-order 155348765 still records CR 34656145 revision f4222f3b2acba1899446d338c4b3788b8a58b29f, not current head 9ac576c77d142b5f4ec318c8d7b8ecf472ea6341, so current head is not proven deployed. Pipeline final status remains FAIL because integration test task 3390965943 failed; validation task 3390965944 remains INIT.", + "prepubPipelineInstanceId": 3095097708, + "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is still RUNNING/WAITING at code merge task 3391355335; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head 9ac576c is not proven active in prepub.", "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" }, { @@ -350,10 +350,12 @@ "2026-06-17 local default dws binary refreshed: /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from dingtalk-workspace-cli commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3; dws version --format json reports version dev, edition open.", "2026-06-17 CLI follow-up: commit 70b9f39715566a8306f8d437cc62ad2f12934449 changes interactive dws auth login --recommend to use a human recommend flow when --format is not explicitly set. This prevents the PAT authorization wait path from falling back to machine JSON after the TUI. Default interactive behavior is now: login success summary, all-selected product-domain TUI, server re-plan, complete authorization URL print/open, browser approval polling. Explicit --format json and non-interactive shells remain structured/no-block. Verification: go test ./internal/app ./internal/pat passed, and /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from this commit.", "2026-06-17 CLI TUI design follow-up: commit 5e1983b3df4a35f2e694972ecfc4207132c79671 aligns the recommend selector with the MGDone terminal target: title 选择要授权的业务域, description 空格选择 回车确认, default-selected all product domains, and product labels rendered as productCode + productName + summary, e.g. approval 审批 - 审批实例,审批模板,审批任务管理. It also tightens interactive detection to require stdin/stdout/stderr all be TTY so piped systems stay in structured mode. Verification passed: go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", + "2026-06-17 CLI --yes follow-up: commit 3ec35e30e828b088a8e9415d76804f56feed6354 fixes dws auth login --recommend --yes to behave like batch auto-confirm: resolveAuthLoginConfig reads inherited --yes, recommend human TUI is skipped, and RunLoginRecommendAuthorizationWithOptions calls pat.batch_grant without startFlow/noWait so no browser authorization page is created. No --yes keeps the previous TUI plus batch authorization page flow. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", - "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception." + "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception.", + "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun." ] }, "serviceContract": { @@ -473,7 +475,7 @@ "loginRecommendSelector": "After login, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", - "nextStep": "After confirmation, CLI prints or opens the complete encoded authorization URL returned by the server and waits for browser approval." + "nextStep": "Without --yes, after product confirmation the CLI prints/opens the complete encoded authorization URL returned by the server and waits for browser approval. With --yes, the CLI skips the product TUI and web authorization page and directly grants the service-selected safe scopes." }, "securityRules": [ "Gateway derives orgId and uid from trusted token/header only.", @@ -489,6 +491,7 @@ "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", "On prepub, interactive dws auth login --recommend displays a default-all-selected product-domain TUI before opening the authorization URL.", + "On prepub, dws auth login --recommend --yes does not open the authorization page and grants the service-selected recommended scopes directly.", "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", "On prepub, a recommended config containing a delete/recall/remove/destroy/drop scope returns warnings/skipped scopes and never grants that scope.", @@ -504,5 +507,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T15:10:32+08:00" + "updatedAt": "2026-06-17T15:46:15+08:00" } From 2264743e78f71d3664b21d90e3a6a36f383beed7 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 16:47:16 +0800 Subject: [PATCH 36/61] fix: model recommend login tui chain --- internal/app/auth_command.go | 124 +++++++++++++++++++++++++++++++- internal/auth/device_flow.go | 48 +++++++------ internal/auth/endpoints.go | 5 ++ internal/auth/oauth_provider.go | 41 ++++++----- internal/pat/chmod.go | 19 +++-- internal/pat/chmod_test.go | 41 +++++++++++ 6 files changed, 234 insertions(+), 44 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 2064399d..21c2c3da 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -45,6 +45,14 @@ type authLoginConfig struct { Yes bool } +type authLoginGuideAction string + +const ( + authLoginGuideDirectCLI authLoginGuideAction = "direct_cli" + authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app" + authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials" +) + func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", @@ -105,6 +113,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } configDir := defaultConfigDir() var tokenData *authpkg.TokenData + format, _ := cmd.Root().PersistentFlags().GetString("format") + recommendHumanMode := !cfg.Yes && authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) + recommendScopeMode := pat.LoginRecommendScopeRecommended + + if recommendHumanMode { + action, err := selectAuthLoginGuideAction() + if err != nil { + return err + } + if err := applyAuthLoginGuideAction(cmd, configDir, action); err != nil { + return err + } + recommendScopeMode, err = selectLoginRecommendScopeMode() + if err != nil { + return err + } + } switch { case strings.TrimSpace(cfg.Token) != "": @@ -142,13 +167,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() - format, _ := cmd.Root().PersistentFlags().GetString("format") - recommendHumanMode := !cfg.Yes && authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) runRecommend := func() error { if !cfg.Recommend { return nil } - opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes} + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} if recommendHumanMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return selectLoginRecommendProducts(products) @@ -229,6 +252,101 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return cmd } +func selectAuthLoginGuideAction() (authLoginGuideAction, error) { + choice := authLoginGuideDirectCLI + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[authLoginGuideAction](). + Title("选择操作"). + Options( + huh.NewOption("直接使用CLI", authLoginGuideDirectCLI), + huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp), + huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials), + ). + Value(&choice), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("使用引导选择中止: %w", err) + } + return choice, nil +} + +func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error { + switch action { + case authLoginGuideDirectCLI: + return nil + case authLoginGuideConfigureAgentApp: + fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录") + return nil + case authLoginGuideManualCredentials: + clientID, clientSecret, err := promptAuthLoginManualCredentials() + if err != nil { + return err + } + authpkg.SetClientID(clientID) + authpkg.SetClientSecret(clientSecret) + if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{ + ClientID: clientID, + ClientSecret: authpkg.PlainSecret(clientSecret), + }); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err)) + } + return nil + default: + return fmt.Errorf("未知操作: %s", action) + } +} + +func promptAuthLoginManualCredentials() (string, string, error) { + var clientID, clientSecret string + nonEmpty := func(label string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s 不能为空", label) + } + return nil + } + } + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("输入 AppKey"). + Value(&clientID). + Validate(nonEmpty("AppKey")), + huh.NewInput(). + Title("输入 AppSecret"). + EchoMode(huh.EchoModePassword). + Value(&clientSecret). + Validate(nonEmpty("AppSecret")), + ), + ) + if err := form.Run(); err != nil { + return "", "", fmt.Errorf("应用凭证输入中止: %w", err) + } + return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil +} + +func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { + choice := pat.LoginRecommendScopeRecommended + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[pat.LoginRecommendScopeMode](). + Title("选择授权范围"). + Description("空格选择 回车确认"). + Options( + huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended), + huh.NewOption("全部授权", pat.LoginRecommendScopeAll), + ). + Value(&choice), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("授权范围选择中止: %w", err) + } + return choice, nil +} + func newAuthLogoutCommand() *cobra.Command { return &cobra.Command{ Use: "logout", diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 031d8b7a..579a7664 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -146,13 +146,14 @@ type serviceResult struct { } // resetCredentialState clears any stale credential state inherited from -// previous login methods (OAuth, PAT, etc.) so that device flow always -// starts fresh by fetching clientID from MCP. +// previous login methods (OAuth, PAT, etc.) before device flow falls back to +// MCP-managed credentials. // // This is a defensive measure: no matter what a prior login wrote to -// app.json or runtime globals, device flow will re-fetch from MCP and -// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses -// the MCP proxy path (which doesn't require clientSecret). +// app.json, device flow will re-fetch from MCP and set the correct +// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path +// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret +// overrides intentionally skip this reset. func (p *DeviceFlowProvider) resetCredentialState() { p.clientID = "" clientMu.Lock() @@ -161,22 +162,29 @@ func (p *DeviceFlowProvider) resetCredentialState() { } func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { - // Defensive reset: clear any stale credential state from previous login - // methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP. - // This ensures --device login works regardless of what app.json contains. - p.resetCredentialState() + if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok { + p.clientID = runtimeClientID + clientMu.Lock() + clientIDFromMCP = false + clientMu.Unlock() + } else { + // Defensive reset: clear any stale credential state from previous login + // methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This + // ensures --device login works regardless of what app.json contains. + p.resetCredentialState() - if p.logger != nil { - p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") - } - mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) - if mcpErr != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) - } - p.clientID = mcpClientID - SetClientIDFromMCP(mcpClientID) - if p.logger != nil { - p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + if p.logger != nil { + p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } } const maxAttempts = 3 diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 24d5204a..92d80b58 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -290,6 +290,11 @@ func getRuntimeCredentials() (clientID, clientSecret string) { return runtimeClientID, runtimeClientSecret } +func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) { + clientID, clientSecret = getRuntimeCredentials() + return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != "" +} + // getDefaultConfigDir returns the default configuration directory. // Priority: DWS_CONFIG_DIR env var > ~/.dws func getDefaultConfigDir() string { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 26707552..aa88da53 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -56,8 +56,8 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider { } // resetCredentialState clears any stale credential state inherited from -// previous login methods so that OAuth flow always starts fresh by -// fetching clientID from MCP. +// previous login methods before the OAuth flow falls back to MCP-managed +// credentials. Complete runtime AppKey/AppSecret overrides skip this reset. func (p *OAuthProvider) resetCredentialState() { p.clientID = "" clientMu.Lock() @@ -109,22 +109,29 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Fall through: full browser OAuth flow. - // Defensive reset: clear any stale credential state from previous login - // methods so we always re-fetch clientID from MCP. This ensures - // --force login works regardless of what app.json contains. - p.resetCredentialState() + if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok { + p.clientID = runtimeClientID + clientMu.Lock() + clientIDFromMCP = false + clientMu.Unlock() + } else { + // Defensive reset: clear any stale credential state from previous login + // methods so we can re-fetch clientID from MCP. This ensures --force + // login works regardless of what app.json contains. + p.resetCredentialState() - if p.logger != nil { - p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") - } - mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) - if mcpErr != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) - } - p.clientID = mcpClientID - SetClientIDFromMCP(mcpClientID) - if p.logger != nil { - p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + if p.logger != nil { + p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } } // Find a free port for the callback server. diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 68de5b45..b4ae9bb4 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -38,12 +38,22 @@ type LoginRecommendOptions struct { // Confirmed grants the server-selected recommend scopes directly. When // false, recommend authorization starts the batch confirmation page. Confirmed bool + // ScopeMode controls whether the plan uses the safe recommended set or + // all scopes under the selected product domains. Empty means recommended. + ScopeMode LoginRecommendScopeMode // ProductSelector lets interactive callers present the service-owned domain // list before the final plan. It receives products extracted from the // initial recommend plan and must return the product codes to grant. ProductSelector func([]LoginRecommendProduct) ([]string, error) } +type LoginRecommendScopeMode string + +const ( + LoginRecommendScopeRecommended LoginRecommendScopeMode = "recommend" + LoginRecommendScopeAll LoginRecommendScopeMode = "all" +) + type LoginRecommendProduct struct { ProductCode string ProductName string @@ -576,8 +586,9 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To if c == nil { return fmt.Errorf("internal error: tool runtime not initialized") } + recommendPlan := opts.ScopeMode != LoginRecommendScopeAll productCodes := normalizeProductCodes(opts.ProductCodes) - planResult, scopes, err := planLoginRecommend(ctx, c, productCodes) + planResult, scopes, err := planLoginRecommend(ctx, c, productCodes, recommendPlan) if err != nil { return err } @@ -595,7 +606,7 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To if len(productCodes) == 0 { return fmt.Errorf("至少选择一个授权业务域") } - _, scopes, err = planLoginRecommend(ctx, c, productCodes) + _, scopes, err = planLoginRecommend(ctx, c, productCodes, recommendPlan) if err != nil { return err } @@ -624,8 +635,8 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To return handleToolResultForWriter(output, result, c) } -func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string) (*edition.ToolResult, []string, error) { - planArgs := buildBatchPlanArgs(nil, productCodes, true, grantTypePermanent, "", "", true) +func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, error) { + planArgs := buildBatchPlanArgs(nil, productCodes, recommend, grantTypePermanent, "", "", true) planArgs["caller"] = patCallerAuthLoginRecommend planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) if err != nil { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 3eeee555..1da98c2b 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -793,9 +793,15 @@ func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) { t.Fatalf("initial plan productCodes = %#v, want empty", got) } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("initial plan recommend = %#v, want true", got) + } if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { t.Fatalf("selected plan productCodes = %#v, want calendar", got) } + if got := fake.calls[1].args["recommend"]; got != true { + t.Fatalf("selected plan recommend = %#v, want true", got) + } if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend { t.Fatalf("selected plan caller = %#v, want %q", got, patCallerAuthLoginRecommend) } @@ -835,6 +841,38 @@ func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) } } +func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeAll, + ProductSelector: func(products []LoginRecommendProduct) ([]string, error) { + return []string{"calendar"}, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 3 { + t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls)) + } + if got := fake.calls[0].args["recommend"]; got != false { + t.Fatalf("initial plan recommend = %#v, want false for all scope mode", got) + } + if got := fake.calls[1].args["recommend"]; got != false { + t.Fatalf("selected plan recommend = %#v, want false for all scope mode", got) + } + if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected plan productCodes = %#v, want calendar", got) + } + if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "calendar.event:write"}) { + t.Fatalf("grant scopes = %#v, want all selected calendar scopes", got) + } +} + func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) { fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, @@ -849,6 +887,9 @@ func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) { if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("plan recommend = %#v, want true for confirmed recommend login", got) + } if _, ok := fake.calls[1].args["startFlow"]; ok { t.Fatalf("startFlow should be omitted for confirmed recommend login") } From e9d50a659f68adfee179f39b5f9e8ba097191f30 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 16:54:17 +0800 Subject: [PATCH 37/61] docs: correct recommend tui chain --- docs/batch-auth-prd.json | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 48c597f4..0ee304c3 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -209,9 +209,9 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "3ec35e30e828b088a8e9415d76804f56feed6354", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 3ec35e30e828b088a8e9415d76804f56feed6354; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend uses human TUI mode with all product domains selected before grant; TUI label matches MGDone terminal target as productCode + productName + summary; explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct grant behavior. current_head_e2e_pending_server_redeploy" + "commit": "2264743e78f71d3664b21d90e3a6a36f383beed7", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 2264743e78f71d3664b21d90e3a6a36f383beed7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend now follows the terminal usage chain: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 replans selected product domains with recommend=false. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -224,7 +224,7 @@ "aoneCrStatusLabel": "发布中", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3095046153, - "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095046153 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391110450. Deploy orders 155362867/155362869 succeeded, but deployed branchRevision is ffaa951 while CR newestRevision/current head is a0873a6, so current head is not proven active in prepub.", + "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095046153 has code merge/build/prepub deploy/prepub integration SUCCESS, but prepub verification task 3391110450 is CANCEL. Deploy orders 155362867/155362869 succeeded, but deployed branchRevision is ffaa951 while CR newestRevision/current head is a0873a6, so current head is not proven active in prepub.", "prepubFunctionalVerification": "pending_current_head_redeploy_or_equivalent_receipt" }, { @@ -256,7 +256,7 @@ "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3095097708, - "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is still RUNNING/WAITING at code merge task 3391355335; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head 9ac576c is not proven active in prepub.", + "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 because Aone reports CONFLICT. The conflicting change is 34571941 feature/robot-openapi-pop at db24ed483eaa89a59fd34f763cfb2ee4adf45c06 merging into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code at e7d0add08308110250c26356a4ee511624bd8e5e; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head 9ac576c is not proven active in prepub.", "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" }, { @@ -351,11 +351,13 @@ "2026-06-17 CLI follow-up: commit 70b9f39715566a8306f8d437cc62ad2f12934449 changes interactive dws auth login --recommend to use a human recommend flow when --format is not explicitly set. This prevents the PAT authorization wait path from falling back to machine JSON after the TUI. Default interactive behavior is now: login success summary, all-selected product-domain TUI, server re-plan, complete authorization URL print/open, browser approval polling. Explicit --format json and non-interactive shells remain structured/no-block. Verification: go test ./internal/app ./internal/pat passed, and /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from this commit.", "2026-06-17 CLI TUI design follow-up: commit 5e1983b3df4a35f2e694972ecfc4207132c79671 aligns the recommend selector with the MGDone terminal target: title 选择要授权的业务域, description 空格选择 回车确认, default-selected all product domains, and product labels rendered as productCode + productName + summary, e.g. approval 审批 - 审批实例,审批模板,审批任务管理. It also tightens interactive detection to require stdin/stdout/stderr all be TTY so piped systems stay in structured mode. Verification passed: go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI --yes follow-up: commit 3ec35e30e828b088a8e9415d76804f56feed6354 fixes dws auth login --recommend --yes to behave like batch auto-confirm: resolveAuthLoginConfig reads inherited --yes, recommend human TUI is skipped, and RunLoginRecommendAuthorizationWithOptions calls pat.batch_grant without startFlow/noWait so no browser authorization page is created. No --yes keeps the previous TUI plus batch authorization page flow. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", + "2026-06-17 CLI TUI chain correction: commit 2264743e78f71d3664b21d90e3a6a36f383beed7 changes TUI from a single product selector into the intended terminal usage chain from the MGDone screenshots. Interactive dws auth login --recommend now shows 选择操作 (直接使用CLI / 一键配置智能体应用 / 手动输入应用凭证), then 选择授权范围 (推荐授权 / 全部授权), then the default-all-selected 选择要授权的业务域 product-domain multiselect. 推荐授权 keeps recommend=true; 全部授权 replans with recommend=false for selected product domains. Complete runtime AppKey/AppSecret overrides now skip MCP clientId fetch so manual credentials are real rather than decorative. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/auth -run TestDeviceFlow|TestOAuth|TestCredential|TestReset|TestAppConfig|TestClient; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception.", - "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun." + "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", + "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143." ] }, "serviceContract": { @@ -469,10 +471,14 @@ }, "terminalTuiTarget": { "sourceImages": [ + "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-82ad41c5-7e1c-46b8-938c-ae31945d61de.png", + "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-18bbc325-ced5-4dfe-855e-514755d4e01f.png", "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-16c11a9e-7ae7-4703-aa66-4251f7b59f16.png", "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" ], - "loginRecommendSelector": "After login, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", + "usageGuide": "Interactive dws auth login --recommend first presents 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues the normal login path; manual credentials collect AppKey/AppSecret and use them as runtime OAuth credentials.", + "authorizationScopeSelector": "Before product-domain selection, the TUI presents 授权范围选择 with 推荐授权 and 全部授权. 推荐授权 is the default safe path and calls pat.batch_plan with recommend=true. 全部授权 replans the selected product domains with recommend=false so PAT core/server risk handling decides pending/grant behavior.", + "loginRecommendSelector": "After the usage and scope guides, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", "nextStep": "Without --yes, after product confirmation the CLI prints/opens the complete encoded authorization URL returned by the server and waits for browser approval. With --yes, the CLI skips the product TUI and web authorization page and directly grants the service-selected safe scopes." @@ -490,7 +496,7 @@ "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", - "On prepub, interactive dws auth login --recommend displays a default-all-selected product-domain TUI before opening the authorization URL.", + "On prepub, interactive dws auth login --recommend displays the terminal usage chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", "On prepub, dws auth login --recommend --yes does not open the authorization page and grants the service-selected recommended scopes directly.", "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", @@ -507,5 +513,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T15:46:15+08:00" + "updatedAt": "2026-06-17T16:48:17+08:00" } From 537719c677a9c79d8c45115c7bbbb29fff8dfe1f Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 17:51:24 +0800 Subject: [PATCH 38/61] fix: run recommend tui after login --- internal/app/auth_command.go | 41 +++++++------ internal/app/auth_command_test.go | 97 +++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 21c2c3da..eb6f09e1 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -117,20 +117,6 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { recommendHumanMode := !cfg.Yes && authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) recommendScopeMode := pat.LoginRecommendScopeRecommended - if recommendHumanMode { - action, err := selectAuthLoginGuideAction() - if err != nil { - return err - } - if err := applyAuthLoginGuideAction(cmd, configDir, action); err != nil { - return err - } - recommendScopeMode, err = selectLoginRecommendScopeMode() - if err != nil { - return err - } - } - switch { case strings.TrimSpace(cfg.Token) != "": tokenData = &authpkg.TokenData{ @@ -171,10 +157,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if !cfg.Recommend { return nil } + if recommendHumanMode { + action, err := authLoginGuideActionSelector() + if err != nil { + return err + } + if err := authLoginGuideActionApplier(cmd, configDir, action); err != nil { + return err + } + recommendScopeMode, err = loginRecommendScopeModeSelector() + if err != nil { + return err + } + } opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} if recommendHumanMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { - return selectLoginRecommendProducts(products) + return loginRecommendProductSelector(products) } } retryFormat := format @@ -252,6 +251,14 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return cmd } +var ( + authLoginGuideActionSelector = selectAuthLoginGuideAction + authLoginGuideActionApplier = applyAuthLoginGuideAction + loginRecommendScopeModeSelector = selectLoginRecommendScopeMode + loginRecommendProductSelector = selectLoginRecommendProducts + authLoginInteractiveTerminal = isInteractiveTerminal +) + func selectAuthLoginGuideAction() (authLoginGuideAction, error) { choice := authLoginGuideDirectCLI form := huh.NewForm( @@ -753,7 +760,7 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin } func authLoginShouldShowRecommendSelector(cmd *cobra.Command, format string) bool { - return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, isInteractiveTerminal()) + return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, authLoginInteractiveTerminal()) } func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format string, interactive bool) bool { @@ -761,7 +768,7 @@ func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format } func authLoginShouldUseRecommendHumanMode(cmd *cobra.Command, format string, recommend bool) bool { - return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, recommend, isInteractiveTerminal()) + return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) } func authLoginShouldUseRecommendHumanModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 49dc33aa..4924ad45 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -15,6 +15,7 @@ package app import ( "bytes" + "context" "errors" "net/http" "os" @@ -27,6 +28,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -285,8 +287,103 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } +func TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + + var sawTokenBeforeTUI bool + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before TUI error = %v", err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before TUI = %q, want login-token", token.AccessToken) + } + sawTokenBeforeTUI = true + return authLoginGuideDirectCLI, nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + if !sawTokenBeforeTUI { + t.Fatal("scope selector ran before login token was observed") + } + return pat.LoginRecommendScopeRecommended, nil + } + loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { + if !sawTokenBeforeTUI { + t.Fatal("product selector ran before login token was observed") + } + if len(products) != 1 || products[0].ProductCode != "calendar" { + t.Fatalf("selector products = %+v, want calendar", products) + } + return []string{"calendar"}, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token", "--recommend"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String()) + } + if !sawTokenBeforeTUI { + t.Fatal("recommend TUI selector was not called") + } + if len(fake.tools) != 3 { + t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +type authLoginRecommendSequenceCaller struct { + responses []string + tools []string +} + +func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, _ map[string]any) (*edition.ToolResult, error) { + f.tools = append(f.tools, toolName) + response := `{"success":true,"data":{}}` + if len(f.responses) > 0 { + response = f.responses[0] + f.responses = f.responses[1:] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil +} + +func (f *authLoginRecommendSequenceCaller) Format() string { return "table" } + +func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false } From 7720e3ec9b9c1eddd2656d6687b1478c724dfe9b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 17:56:36 +0800 Subject: [PATCH 39/61] docs: clarify post-login recommend tui --- docs/batch-auth-prd.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 0ee304c3..69fb87d1 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -54,9 +54,10 @@ "name": "loginRecommend", "steps": [ "User runs dws auth login --recommend.", - "CLI completes OAuth loopback or device login and saves token.", - "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", - "In an interactive terminal, CLI shows a Feishu-like TUI product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", + "CLI completes OAuth loopback or device login, lets the user choose the DingTalk organization through the normal login flow, and saves the token.", + "After the login identity and organization are settled, interactive CLI shows the Feishu-like terminal authorization guide: 选择操作, then 授权范围选择.", + "CLI calls pat.batch_plan with recommend=true by default, or recommend=false when the user selected 全部授权, grantType=permanent, caller=auth_login_recommend, dryRun=true.", + "CLI then shows the product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", "CLI re-plans with the selected productCodes before starting the grant flow.", "PAT core loads the Diamond-driven recommended set, excludes non-recommended medium-risk scopes from the default plan, and returns selectedScopes plus warnings/skipped scopes.", "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", @@ -352,6 +353,7 @@ "2026-06-17 CLI TUI design follow-up: commit 5e1983b3df4a35f2e694972ecfc4207132c79671 aligns the recommend selector with the MGDone terminal target: title 选择要授权的业务域, description 空格选择 回车确认, default-selected all product domains, and product labels rendered as productCode + productName + summary, e.g. approval 审批 - 审批实例,审批模板,审批任务管理. It also tightens interactive detection to require stdin/stdout/stderr all be TTY so piped systems stay in structured mode. Verification passed: go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI --yes follow-up: commit 3ec35e30e828b088a8e9415d76804f56feed6354 fixes dws auth login --recommend --yes to behave like batch auto-confirm: resolveAuthLoginConfig reads inherited --yes, recommend human TUI is skipped, and RunLoginRecommendAuthorizationWithOptions calls pat.batch_grant without startFlow/noWait so no browser authorization page is created. No --yes keeps the previous TUI plus batch authorization page flow. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI TUI chain correction: commit 2264743e78f71d3664b21d90e3a6a36f383beed7 changes TUI from a single product selector into the intended terminal usage chain from the MGDone screenshots. Interactive dws auth login --recommend now shows 选择操作 (直接使用CLI / 一键配置智能体应用 / 手动输入应用凭证), then 选择授权范围 (推荐授权 / 全部授权), then the default-all-selected 选择要授权的业务域 product-domain multiselect. 推荐授权 keeps recommend=true; 全部授权 replans with recommend=false for selected product domains. Complete runtime AppKey/AppSecret overrides now skip MCP clientId fetch so manual credentials are real rather than decorative. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/auth -run TestDeviceFlow|TestOAuth|TestCredential|TestReset|TestAppConfig|TestClient; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", + "2026-06-17 CLI TUI sequence correction: commit 537719c677a9c79d8c45115c7bbbb29fff8dfe1f moves the terminal authorization guide into runRecommend, so dws auth login --recommend first completes DingTalk login, organization selection, and token persistence, then starts 选择操作 -> 选择授权范围 -> default-all-selected 选择要授权的业务域. A new app-layer regression test asserts the token is already readable before any TUI selector runs. Verification passed: go test ./internal/app -run TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved|TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; go build -o /Users/xuan/bin/dws ./cmd/main.go; /usr/local/bin/dws version --format json reports version dev, edition open.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", @@ -476,9 +478,9 @@ "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-16c11a9e-7ae7-4703-aa66-4251f7b59f16.png", "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" ], - "usageGuide": "Interactive dws auth login --recommend first presents 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues the normal login path; manual credentials collect AppKey/AppSecret and use them as runtime OAuth credentials.", - "authorizationScopeSelector": "Before product-domain selection, the TUI presents 授权范围选择 with 推荐授权 and 全部授权. 推荐授权 is the default safe path and calls pat.batch_plan with recommend=true. 全部授权 replans the selected product domains with recommend=false so PAT core/server risk handling decides pending/grant behavior.", - "loginRecommendSelector": "After the usage and scope guides, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", + "usageGuide": "Interactive dws auth login --recommend starts the terminal authorization guide only after OAuth/device login, DingTalk organization selection, login summary, and token persistence are complete. The first guide screen is 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues directly to authorization setup; manual credentials collect AppKey/AppSecret for subsequent runtime credential use and do not replace the already selected login organization.", + "authorizationScopeSelector": "After the usage guide, the TUI presents 授权范围选择 with 推荐授权 and 全部授权. 推荐授权 is the default safe path and calls pat.batch_plan with recommend=true. 全部授权 replans the selected product domains with recommend=false so PAT core/server risk handling decides pending/grant behavior.", + "loginRecommendSelector": "After the login is already complete and the usage/scope guides have run, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", "nextStep": "Without --yes, after product confirmation the CLI prints/opens the complete encoded authorization URL returned by the server and waits for browser approval. With --yes, the CLI skips the product TUI and web authorization page and directly grants the service-selected safe scopes." @@ -496,7 +498,7 @@ "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", - "On prepub, interactive dws auth login --recommend displays the terminal usage chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", + "On prepub, interactive dws auth login --recommend first completes DingTalk login and organization selection, then displays the terminal authorization chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", "On prepub, dws auth login --recommend --yes does not open the authorization page and grants the service-selected recommended scopes directly.", "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", @@ -513,5 +515,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T16:48:17+08:00" + "updatedAt": "2026-06-17T17:52:22+08:00" } From d2fd7e00b59e3dc734f5f402b9bf92f7aa4826bb Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 18:47:51 +0800 Subject: [PATCH 40/61] fix: skip tui for recommend login --- docs/batch-auth-prd.json | 33 ++++++--- internal/app/auth_command.go | 55 +++++++++----- internal/app/auth_command_test.go | 118 +++++++++++++++++++++++------- 3 files changed, 151 insertions(+), 55 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 69fb87d1..c019714d 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -55,16 +55,26 @@ "steps": [ "User runs dws auth login --recommend.", "CLI completes OAuth loopback or device login, lets the user choose the DingTalk organization through the normal login flow, and saves the token.", - "After the login identity and organization are settled, interactive CLI shows the Feishu-like terminal authorization guide: 选择操作, then 授权范围选择.", - "CLI calls pat.batch_plan with recommend=true by default, or recommend=false when the user selected 全部授权, grantType=permanent, caller=auth_login_recommend, dryRun=true.", - "CLI then shows the product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", - "CLI re-plans with the selected productCodes before starting the grant flow.", + "CLI does not show the product-domain TUI for --recommend.", + "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", "PAT core loads the Diamond-driven recommended set, excludes non-recommended medium-risk scopes from the default plan, and returns selectedScopes plus warnings/skipped scopes.", "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", "PAT core creates a single batch Device Code Flow and returns PAT_BATCH_AUTH_PENDING with flowId/userCode/uri.", "CLI opens uri, polls flow status, and retries the original grant after approval." ] }, + { + "name": "loginInteractiveTuiWithoutRecommend", + "steps": [ + "User runs dws auth login without --recommend in an interactive terminal.", + "CLI completes OAuth loopback or device login, lets the user choose the DingTalk organization through the normal login flow, and saves the token.", + "After the login identity and organization are settled, CLI shows the Feishu-like terminal authorization guide: 选择操作, then 授权范围选择.", + "CLI calls pat.batch_plan with recommend=true when the user selected 推荐授权, or recommend=false when the user selected 全部授权.", + "CLI shows the product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", + "CLI re-plans with the selected productCodes before starting the grant flow.", + "Without --yes, CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, then opens/polls the returned authorization URL." + ] + }, { "name": "batchChmodWithoutYes", "steps": [ @@ -212,7 +222,7 @@ "branch": "codex/dws-auth-login-recommend", "commit": "2264743e78f71d3664b21d90e3a6a36f383beed7", "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 2264743e78f71d3664b21d90e3a6a36f383beed7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: interactive default dws auth login --recommend now follows the terminal usage chain: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 replans selected product domains with recommend=false. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" + "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend now skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend now follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 replans selected product domains with recommend=false. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -354,6 +364,7 @@ "2026-06-17 CLI --yes follow-up: commit 3ec35e30e828b088a8e9415d76804f56feed6354 fixes dws auth login --recommend --yes to behave like batch auto-confirm: resolveAuthLoginConfig reads inherited --yes, recommend human TUI is skipped, and RunLoginRecommendAuthorizationWithOptions calls pat.batch_grant without startFlow/noWait so no browser authorization page is created. No --yes keeps the previous TUI plus batch authorization page flow. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI TUI chain correction: commit 2264743e78f71d3664b21d90e3a6a36f383beed7 changes TUI from a single product selector into the intended terminal usage chain from the MGDone screenshots. Interactive dws auth login --recommend now shows 选择操作 (直接使用CLI / 一键配置智能体应用 / 手动输入应用凭证), then 选择授权范围 (推荐授权 / 全部授权), then the default-all-selected 选择要授权的业务域 product-domain multiselect. 推荐授权 keeps recommend=true; 全部授权 replans with recommend=false for selected product domains. Complete runtime AppKey/AppSecret overrides now skip MCP clientId fetch so manual credentials are real rather than decorative. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/auth -run TestDeviceFlow|TestOAuth|TestCredential|TestReset|TestAppConfig|TestClient; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI TUI sequence correction: commit 537719c677a9c79d8c45115c7bbbb29fff8dfe1f moves the terminal authorization guide into runRecommend, so dws auth login --recommend first completes DingTalk login, organization selection, and token persistence, then starts 选择操作 -> 选择授权范围 -> default-all-selected 选择要授权的业务域. A new app-layer regression test asserts the token is already readable before any TUI selector runs. Verification passed: go test ./internal/app -run TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved|TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; go build -o /Users/xuan/bin/dws ./cmd/main.go; /usr/local/bin/dws version --format json reports version dev, edition open.", + "2026-06-17 CLI TUI condition correction: dws auth login --recommend no longer shows 选择操作, 授权范围选择, or product-domain TUI. It directly calls pat.batch_plan recommend=true and grants the server-selected recommended scopes. The post-login TUI now belongs only to interactive dws auth login without --recommend, after login/org selection/token persistence. Verification target: TestAuthLoginRecommendSkipsPostLoginTUI and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", @@ -478,12 +489,12 @@ "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-16c11a9e-7ae7-4703-aa66-4251f7b59f16.png", "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" ], - "usageGuide": "Interactive dws auth login --recommend starts the terminal authorization guide only after OAuth/device login, DingTalk organization selection, login summary, and token persistence are complete. The first guide screen is 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues directly to authorization setup; manual credentials collect AppKey/AppSecret for subsequent runtime credential use and do not replace the already selected login organization.", + "usageGuide": "Interactive dws auth login without --recommend starts the terminal authorization guide only after OAuth/device login, DingTalk organization selection, login summary, and token persistence are complete. dws auth login --recommend skips this TUI entirely and uses the server recommended set. The first guide screen for the non-recommend TUI is 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues directly to authorization setup; manual credentials collect AppKey/AppSecret for subsequent runtime credential use and do not replace the already selected login organization.", "authorizationScopeSelector": "After the usage guide, the TUI presents 授权范围选择 with 推荐授权 and 全部授权. 推荐授权 is the default safe path and calls pat.batch_plan with recommend=true. 全部授权 replans the selected product domains with recommend=false so PAT core/server risk handling decides pending/grant behavior.", - "loginRecommendSelector": "After the login is already complete and the usage/scope guides have run, interactive dws auth login --recommend presents 选择要授权的业务域 with all product domains selected by default.", + "loginRecommendSelector": "After the login is already complete and the usage/scope guides have run, interactive dws auth login without --recommend presents 选择要授权的业务域 with all product domains selected by default. The --recommend flag does not show this selector.", "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", - "nextStep": "Without --yes, after product confirmation the CLI prints/opens the complete encoded authorization URL returned by the server and waits for browser approval. With --yes, the CLI skips the product TUI and web authorization page and directly grants the service-selected safe scopes." + "nextStep": "Without --yes, --recommend directly prints/opens the complete encoded authorization URL returned by the server for the recommended scope set. In the non-recommend TUI path, the CLI prints/opens that URL after product confirmation. With --yes, the CLI skips TUI and web authorization page and directly grants the service-selected safe scopes when a direct-grant path is available." }, "securityRules": [ "Gateway derives orgId and uid from trusted token/header only.", @@ -497,8 +508,8 @@ "acceptanceCriteria": [ "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", - "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", - "On prepub, interactive dws auth login --recommend first completes DingTalk login and organization selection, then displays the terminal authorization chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", + "On prepub, dws auth login --recommend opens the batch authorization URL after successful login without showing terminal product-domain selection.", + "On prepub, interactive dws auth login without --recommend first completes DingTalk login and organization selection, then displays the terminal authorization chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", "On prepub, dws auth login --recommend --yes does not open the authorization page and grants the service-selected recommended scopes directly.", "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", @@ -515,5 +526,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T17:52:22+08:00" + "updatedAt": "2026-06-17T18:37:31+08:00" } diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index eb6f09e1..e83a5ea8 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -114,8 +114,8 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") - recommendHumanMode := !cfg.Yes && authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend) - recommendScopeMode := pat.LoginRecommendScopeRecommended + postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend) + humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, cfg.Recommend || postLoginTUIMode) switch { case strings.TrimSpace(cfg.Token) != "": @@ -153,11 +153,12 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() - runRecommend := func() error { - if !cfg.Recommend { + runPostLoginAuthorization := func() error { + if !cfg.Recommend && !postLoginTUIMode { return nil } - if recommendHumanMode { + recommendScopeMode := pat.LoginRecommendScopeRecommended + if postLoginTUIMode { action, err := authLoginGuideActionSelector() if err != nil { return err @@ -171,13 +172,13 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } } opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} - if recommendHumanMode { + if postLoginTUIMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return loginRecommendProductSelector(products) } } retryFormat := format - if recommendHumanMode { + if humanAuthMode { retryFormat = "table" } run := func(ctx context.Context) error { @@ -197,8 +198,8 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } // Check if JSON output is requested - if strings.EqualFold(strings.TrimSpace(format), "json") && !recommendHumanMode { - if err := runRecommend(); err != nil { + if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode { + if err := runPostLoginAuthorization(); err != nil { return err } return writeAuthLoginJSON(w, tokenData, cfg.Force) @@ -226,7 +227,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } } fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n") - return runRecommend() + return runPostLoginAuthorization() }, } cmd.Flags().String("token", "", "Access token") @@ -759,20 +760,38 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin return selected, nil } -func authLoginShouldShowRecommendSelector(cmd *cobra.Command, format string) bool { - return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, authLoginInteractiveTerminal()) +func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) } -func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format string, interactive bool) bool { - return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, interactive) +func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive) } -func authLoginShouldUseRecommendHumanMode(cmd *cobra.Command, format string, recommend bool) bool { - return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) +func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) } -func authLoginShouldUseRecommendHumanModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { - if !recommend || !interactive { +func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + if recommend || !interactive { + return false + } + return authLoginAllowsInteractiveDefault(cmd, format) +} + +func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool { + return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal()) +} + +func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool { + if !hasAuthorizationFlow || !interactive { + return false + } + return authLoginAllowsInteractiveDefault(cmd, format) +} + +func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool { + if cmd == nil || cmd.Root() == nil { return false } if !strings.EqualFold(strings.TrimSpace(format), "json") { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 4924ad45..3debf263 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -184,7 +184,7 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { } } -func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { +func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { newRoot := func(t *testing.T) *cobra.Command { t.Helper() root := &cobra.Command{Use: "dws"} @@ -192,13 +192,23 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { return root } - t.Run("default json still shows selector for interactive login", func(t *testing.T) { + t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) { root := newRoot(t) - if !authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { - t.Fatal("default json format should still show recommend selector in an interactive terminal") + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) { + t.Fatal("--recommend must not show the post-login product TUI") } - if !authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, true) { - t.Fatal("default json format should use human recommend flow in an interactive terminal") + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("default interactive --recommend should still use human authorization flow") + } + }) + + t.Run("without recommend shows tui for interactive default login", func(t *testing.T) { + root := newRoot(t) + if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("default interactive login without --recommend should show post-login TUI") + } + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("default interactive post-login TUI flow should use human authorization flow") } }) @@ -207,41 +217,41 @@ func TestAuthLoginRecommendSelectorRespectsExplicitFormat(t *testing.T) { if err := root.PersistentFlags().Set("format", "json"); err != nil { t.Fatalf("set format: %v", err) } - if authLoginShouldShowRecommendSelectorForTerminal(root, "json", true) { - t.Fatal("explicit --format json must not show recommend selector") + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("explicit --format json must not show post-login TUI") } - if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, true) { - t.Fatal("explicit --format json must keep machine-readable recommend flow") + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("explicit --format json must keep machine-readable authorization flow") } }) - t.Run("table shows selector", func(t *testing.T) { + t.Run("table without recommend shows tui", func(t *testing.T) { root := newRoot(t) if err := root.PersistentFlags().Set("format", "table"); err != nil { t.Fatalf("set format: %v", err) } - if !authLoginShouldShowRecommendSelectorForTerminal(root, "table", true) { - t.Fatal("table format should show recommend selector in an interactive terminal") + if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { + t.Fatal("table format should show post-login TUI without --recommend") } - if !authLoginShouldUseRecommendHumanModeForTerminal(root, "table", true, true) { - t.Fatal("table format should use human recommend flow in an interactive terminal") + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) { + t.Fatal("table format should use human authorization flow in an interactive terminal") } }) t.Run("non interactive skips selector", func(t *testing.T) { root := newRoot(t) - if authLoginShouldShowRecommendSelectorForTerminal(root, "json", false) { - t.Fatal("non-interactive login should skip recommend selector") + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) { + t.Fatal("non-interactive login should skip post-login TUI") } - if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", true, false) { - t.Fatal("non-interactive login should keep machine-readable recommend flow") + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) { + t.Fatal("non-interactive login should keep machine-readable authorization flow") } }) - t.Run("without recommend keeps normal login output contract", func(t *testing.T) { + t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) { root := newRoot(t) - if authLoginShouldUseRecommendHumanModeForTerminal(root, "json", false, true) { - t.Fatal("login without --recommend should not switch default json to human mode") + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) { + t.Fatal("login without a post-login authorization flow should not switch default json to human mode") } }) } @@ -287,7 +297,63 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } -func TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { +func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("--recommend must not call the post-login guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("--recommend must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + t.Fatal("--recommend must not call the scope-mode TUI") + return "", nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("--recommend must not call the product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token", "--recommend"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String()) + } + if len(fake.tools) != 2 { + t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) + } +} + +func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) configDir := t.TempDir() @@ -347,13 +413,13 @@ func TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) - cmd.SetArgs([]string{"--token", "login-token", "--recommend"}) + cmd.SetArgs([]string{"--token", "login-token"}) if err := cmd.Execute(); err != nil { - t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String()) + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } if !sawTokenBeforeTUI { - t.Fatal("recommend TUI selector was not called") + t.Fatal("post-login TUI selector was not called") } if len(fake.tools) != 3 { t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.tools)) From dcf132a7e3223288a7fc82882560ab642fb63899 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 19:21:54 +0800 Subject: [PATCH 41/61] fix: discover products before all-scope plan --- docs/batch-auth-prd.json | 3 +- internal/app/auth_command_test.go | 52 +++++++++++++++++++++++++++++-- internal/pat/chmod.go | 13 +++++++- internal/pat/chmod_test.go | 7 +++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index c019714d..3149c692 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -365,6 +365,7 @@ "2026-06-17 CLI TUI chain correction: commit 2264743e78f71d3664b21d90e3a6a36f383beed7 changes TUI from a single product selector into the intended terminal usage chain from the MGDone screenshots. Interactive dws auth login --recommend now shows 选择操作 (直接使用CLI / 一键配置智能体应用 / 手动输入应用凭证), then 选择授权范围 (推荐授权 / 全部授权), then the default-all-selected 选择要授权的业务域 product-domain multiselect. 推荐授权 keeps recommend=true; 全部授权 replans with recommend=false for selected product domains. Complete runtime AppKey/AppSecret overrides now skip MCP clientId fetch so manual credentials are real rather than decorative. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/auth -run TestDeviceFlow|TestOAuth|TestCredential|TestReset|TestAppConfig|TestClient; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", "2026-06-17 CLI TUI sequence correction: commit 537719c677a9c79d8c45115c7bbbb29fff8dfe1f moves the terminal authorization guide into runRecommend, so dws auth login --recommend first completes DingTalk login, organization selection, and token persistence, then starts 选择操作 -> 选择授权范围 -> default-all-selected 选择要授权的业务域. A new app-layer regression test asserts the token is already readable before any TUI selector runs. Verification passed: go test ./internal/app -run TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved|TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; go build -o /Users/xuan/bin/dws ./cmd/main.go; /usr/local/bin/dws version --format json reports version dev, edition open.", "2026-06-17 CLI TUI condition correction: dws auth login --recommend no longer shows 选择操作, 授权范围选择, or product-domain TUI. It directly calls pat.batch_plan recommend=true and grants the server-selected recommended scopes. The post-login TUI now belongs only to interactive dws auth login without --recommend, after login/org selection/token persistence. Verification target: TestAuthLoginRecommendSkipsPostLoginTUI and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", + "2026-06-17 CLI all-scope TUI bugfix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED errors were caused by the no --recommend TUI path selecting 全部授权 before product selection, which sent an initial pat.batch_plan with recommend=false and empty productCodes. The fix makes the first TUI discovery plan always use recommend=true when no productCodes are known, then applies recommend=false only on the second plan after selected productCodes are available. Regression coverage: TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", @@ -526,5 +527,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T18:37:31+08:00" + "updatedAt": "2026-06-17T19:15:53+08:00" } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 3debf263..24add7f8 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -351,6 +351,9 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) } + if got := fake.args[0]["recommend"]; got != true { + t.Fatalf("--recommend plan recommend = %#v, want true", got) + } } func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { @@ -392,7 +395,7 @@ func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { if !sawTokenBeforeTUI { t.Fatal("scope selector ran before login token was observed") } - return pat.LoginRecommendScopeRecommended, nil + return pat.LoginRecommendScopeAll, nil } loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { if !sawTokenBeforeTUI { @@ -427,6 +430,15 @@ func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) } + if got := fake.args[0]["recommend"]; got != true { + t.Fatalf("initial product discovery plan recommend = %#v, want true", got) + } + if got := fake.args[1]["recommend"]; got != false { + t.Fatalf("selected all-scope plan recommend = %#v, want false", got) + } + if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got) + } } type roundTripFunc func(*http.Request) (*http.Response, error) @@ -438,10 +450,16 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { type authLoginRecommendSequenceCaller struct { responses []string tools []string + args []map[string]any } -func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, _ map[string]any) (*edition.ToolResult, error) { +func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { f.tools = append(f.tools, toolName) + copiedArgs := make(map[string]any, len(args)) + for key, value := range args { + copiedArgs[key] = value + } + f.args = append(f.args, copiedArgs) response := `{"success":true,"data":{}}` if len(f.responses) > 0 { response = f.responses[0] @@ -453,3 +471,33 @@ func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, func (f *authLoginRecommendSequenceCaller) Format() string { return "table" } func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false } + +func stringSliceArgEqual(got any, want []string) bool { + if got == nil { + return len(want) == 0 + } + switch values := got.(type) { + case []string: + if len(values) != len(want) { + return false + } + for i := range values { + if values[i] != want[i] { + return false + } + } + return true + case []any: + if len(values) != len(want) { + return false + } + for i := range values { + if values[i] != want[i] { + return false + } + } + return true + default: + return false + } +} diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index b4ae9bb4..031491e6 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -588,7 +588,15 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To } recommendPlan := opts.ScopeMode != LoginRecommendScopeAll productCodes := normalizeProductCodes(opts.ProductCodes) - planResult, scopes, err := planLoginRecommend(ctx, c, productCodes, recommendPlan) + initialRecommendPlan := recommendPlan + if opts.ProductSelector != nil && len(productCodes) == 0 { + // The first plan only discovers the service-owned product-domain list + // for the TUI. The server contract rejects an empty all-scope request + // (recommend=false without productCodes), so all-scope mode is applied + // only after the user selects concrete productCodes. + initialRecommendPlan = true + } + planResult, scopes, err := planLoginRecommend(ctx, c, productCodes, initialRecommendPlan) if err != nil { return err } @@ -611,6 +619,9 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To return err } } + if len(products) == 0 && !recommendPlan { + return fmt.Errorf("全部授权需要服务端返回可选授权业务域") + } } if len(scopes) == 0 { if output != nil { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 1da98c2b..cb5e3b57 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -859,8 +859,11 @@ func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing if len(fake.calls) != 3 { t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls)) } - if got := fake.calls[0].args["recommend"]; got != false { - t.Fatalf("initial plan recommend = %#v, want false for all scope mode", got) + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("initial discovery plan recommend = %#v, want true before product selection", got) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) { + t.Fatalf("initial discovery plan productCodes = %#v, want empty", got) } if got := fake.calls[1].args["recommend"]; got != false { t.Fatalf("selected plan recommend = %#v, want false for all scope mode", got) From b1a88106e5fbfb6fe7fc39c80057beb2443c8cc8 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 19:49:50 +0800 Subject: [PATCH 42/61] docs: record current cli recommend verification --- docs/batch-auth-prd.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 3149c692..93ceb444 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -220,9 +220,9 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "2264743e78f71d3664b21d90e3a6a36f383beed7", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 2264743e78f71d3664b21d90e3a6a36f383beed7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth and go test ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend now skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend now follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 replans selected product domains with recommend=false. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" + "commit": "dcf132a7e3223288a7fc82882560ab642fb63899", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit dcf132a7e3223288a7fc82882560ab642fb63899; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend now skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend now follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_recommend_plan_ok: /usr/local/bin/dws auth login --recommend --format json completed without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" }, { "repo": "lippi-open-mcpserver-gw", @@ -366,6 +366,7 @@ "2026-06-17 CLI TUI sequence correction: commit 537719c677a9c79d8c45115c7bbbb29fff8dfe1f moves the terminal authorization guide into runRecommend, so dws auth login --recommend first completes DingTalk login, organization selection, and token persistence, then starts 选择操作 -> 选择授权范围 -> default-all-selected 选择要授权的业务域. A new app-layer regression test asserts the token is already readable before any TUI selector runs. Verification passed: go test ./internal/app -run TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved|TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; go build -o /Users/xuan/bin/dws ./cmd/main.go; /usr/local/bin/dws version --format json reports version dev, edition open.", "2026-06-17 CLI TUI condition correction: dws auth login --recommend no longer shows 选择操作, 授权范围选择, or product-domain TUI. It directly calls pat.batch_plan recommend=true and grants the server-selected recommended scopes. The post-login TUI now belongs only to interactive dws auth login without --recommend, after login/org selection/token persistence. Verification target: TestAuthLoginRecommendSkipsPostLoginTUI and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", "2026-06-17 CLI all-scope TUI bugfix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED errors were caused by the no --recommend TUI path selecting 全部授权 before product selection, which sent an initial pat.batch_plan with recommend=false and empty productCodes. The fix makes the first TUI discovery plan always use recommend=true when no productCodes are known, then applies recommend=false only on the second plan after selected productCodes are available. Regression coverage: TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", + "2026-06-17 CLI current-head verification: commit dcf132a7e3223288a7fc82882560ab642fb63899 is pushed to origin/codex/dws-auth-login-recommend and /usr/local/bin/dws points to /Users/xuan/bin/dws rebuilt from that source. Verification passed: go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit; /usr/local/bin/dws auth login --recommend --format json completed without PAT_BATCH_AUTH_SCOPE_REQUIRED and printed 推荐权限已全部授权或没有可授权项 for the current account. The earlier diagnostics that described --recommend showing a TUI are superseded; the final rule is --recommend direct plan/grant, and the terminal TUI is only for interactive dws auth login without --recommend.", "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", From 710844382e1a652e5294a233018fd3c93a1df9a0 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 20:34:49 +0800 Subject: [PATCH 43/61] docs: record bff selected scopes hardening --- docs/batch-auth-prd.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 93ceb444..f7865a7c 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -258,16 +258,16 @@ "repo": "lippi-open-app-dev", "localPath": "/Users/xuan/lippi-open-app-dev", "branch": "codex/batch-auth-bff-selected-scopes", - "commit": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", + "commit": "df53f856c31be3abee220b738022f861cbf78b93", "aoneAppId": 154450, "aoneCrId": 34656145, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", - "aoneNewestRevision": "9ac576c77d142b5f4ec318c8d7b8ecf472ea6341", + "aoneNewestRevision": "df53f856c31be3abee220b738022f861cbf78b93", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3095097708, - "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 because Aone reports CONFLICT. The conflicting change is 34571941 feature/robot-openapi-pop at db24ed483eaa89a59fd34f763cfb2ee4adf45c06 merging into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code at e7d0add08308110250c26356a4ee511624bd8e5e; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head 9ac576c is not proven active in prepub.", + "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 because Aone reports CONFLICT. The conflicting change is 34571941 feature/robot-openapi-pop at db24ed483eaa89a59fd34f763cfb2ee4adf45c06 merging into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code at e7d0add08308110250c26356a4ee511624bd8e5e; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head df53f856c31be3abee220b738022f861cbf78b93 is not proven active in prepub.", "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" }, { @@ -346,7 +346,7 @@ "PAT core Aone pipeline 66 instance 3094916508 reached SUCCESS for code merge, build, prepub deploy, and prepub integration test; prepub validation remained RUNNING when checked.", "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml.", - "BFF latest branch head is 9ac576c77d142b5f4ec318c8d7b8ecf472ea6341 after merging latest master. Aone CR 34656145 is INTG and pipeline 66 instance 3094919448 had code merge and build SUCCESS, with prepub deploy RUNNING at 2026-06-16 16:28:13+08:00.", + "BFF latest branch head is df53f856c31be3abee220b738022f861cbf78b93. It rejects malformed HTTP selectedScopes before HSF: missing field remains legacy-compatible and forwards null, but present null/non-array/non-string/blank entries return INVALID_PARAM_TYPE and skip PatAuthService#handleFlowAction. Verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -Dtest=com.dingtalk.open.appdev.http.DWSPatAuthRpcServiceTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml. The first Java 22 attempt failed on old aspectjweaver Zip64/PowerMock compatibility, so Java 11 remains the recorded build runtime.", "Gateway latest branch head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9. It rejects pat.batch_grant calls that carry plan-only recommend/productCodes fields, normalizes non-empty selected scopes before forwarding to PAT core, and has local verification uv run python -m pytest test/test_pat_batch_bridge.py -q: 26 passed.", "Frontend latest branch head is 356dcbab5d02c9f0225baabbd80a98b28d6f92b3. It only renders the batch page when the server explicitly marks batchFlow/isBatchFlow/flowType=BATCH, and reject success falls back to FLOW_REJECTED so the terminal page shows 已拒绝授权 instead of 授权失败. Verification: npm run build passed with existing bundle-size warnings.", "lark-cli parity check on 2026-06-16: lark-cli auth login --help does not list --yes, and lark-cli auth login --yes --no-wait --json exits with Error: unknown flag: --yes. Lark recommended login therefore still requires Device Flow/browser authorization and does not provide a --yes bypass.", @@ -372,7 +372,8 @@ "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception.", "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", - "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143." + "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", + "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest." ] }, "serviceContract": { @@ -447,7 +448,8 @@ "selectedScopes": "required for the new batch page when action=approve; must be a non-empty subset of flow.scopes" }, "compatibility": { - "selectedScopesNull": "legacy clients approve all flow-stored scopes", + "selectedScopesMissing": "legacy clients that omit selectedScopes approve all flow-stored scopes through PAT core's DTO null compatibility", + "selectedScopesPresentNullOrMalformed": "BFF returns INVALID_PARAM_TYPE and does not call PAT core", "selectedScopesEmpty": "INVALID_PARAMETER", "selectedScopesOutsideFlow": "INVALID_PARAMETER" }, From 6a392e611c44778e02956520c8fa1330f274f76f Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 21:25:45 +0800 Subject: [PATCH 44/61] docs: record prepub deployment recheck --- docs/batch-auth-prd.json | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index f7865a7c..82109cb0 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -214,15 +214,15 @@ ] }, "prepubReleaseGate": { - "overallStatus": "not_passed_until_gateway_pat_bff_current_heads_are_deployed_and_prepub_functional_verification_passes", + "overallStatus": "not_passed_until_prepub_validation_and_full_batch_page_e2e_pass", "changedRepos": [ { "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "dcf132a7e3223288a7fc82882560ab642fb63899", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit dcf132a7e3223288a7fc82882560ab642fb63899; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend now skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend now follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_recommend_plan_ok: /usr/local/bin/dws auth login --recommend --format json completed without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior. current_head_e2e_pending_server_redeploy" + "commit": "710844382e1a652e5294a233018fd3c93a1df9a0", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 710844382e1a652e5294a233018fd3c93a1df9a0; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. prepub_smoke_ok_2026-06-17_21: /usr/local/bin/dws auth login --recommend --no-browser --format json completed against current prepub services without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." }, { "repo": "lippi-open-mcpserver-gw", @@ -234,9 +234,9 @@ "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095046153, - "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095046153 has code merge/build/prepub deploy/prepub integration SUCCESS, but prepub verification task 3391110450 is CANCEL. Deploy orders 155362867/155362869 succeeded, but deployed branchRevision is ffaa951 while CR newestRevision/current head is a0873a6, so current head is not proven active in prepub.", - "prepubFunctionalVerification": "pending_current_head_redeploy_or_equivalent_receipt" + "prepubPipelineInstanceId": 3095163678, + "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095163678 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391672208. Deploy orders 155420965/155420967 succeeded at 2026-06-17 19:43-19:46+08:00; host batch checks for order 155420965 show 2/2 success and host lippi-open-mcpserver-gw011089179070.pre.na610 success. CR newestRevision/current head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9.", + "prepubFunctionalVerification": "prepub_deploy_and_integration_success; full_batch_auth_e2e_pending" }, { "repo": "lippi-pat-core-wukong-capability", @@ -251,8 +251,8 @@ "aoneNewestRevision": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", "prepubPipelineId": 66, "prepubPipelineInstanceId": 3095097702, - "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095097702 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391355313. Deploy order 155387876 succeeded, but deployed branchRevision is b38e29e while CR newestRevision/current head is 79a8fa5, so current head is not proven active in prepub.", - "prepubFunctionalVerification": "pending_current_head_redeploy" + "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095097702 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391355313. Deploy order 155387876 succeeded at 2026-06-17 15:17-15:19+08:00; host batch checks show 1/1 success on lippi-pat-core033006251249.pre.na620. CR newestRevision/current head is 79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3.", + "prepubFunctionalVerification": "prepub_deploy_and_integration_success; full_batch_auth_e2e_pending" }, { "repo": "lippi-open-app-dev", @@ -261,14 +261,14 @@ "commit": "df53f856c31be3abee220b738022f861cbf78b93", "aoneAppId": 154450, "aoneCrId": 34656145, - "aoneCrStatus": "INTG", - "aoneCrStatusLabel": "发布中", + "aoneCrStatus": "PREINTG", + "aoneCrStatusLabel": "待发布", "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", "aoneNewestRevision": "df53f856c31be3abee220b738022f861cbf78b93", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095097708, - "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 because Aone reports CONFLICT. The conflicting change is 34571941 feature/robot-openapi-pop at db24ed483eaa89a59fd34f763cfb2ee4adf45c06 merging into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code at e7d0add08308110250c26356a4ee511624bd8e5e; build, prepub deploy, integration, and validation are INIT. Latest successful prepub deploy remains old instance 3095018664/orders 155350849/155350851 with deployed branchRevision f4222f3, so current head df53f856c31be3abee220b738022f861cbf78b93 is not proven active in prepub.", - "prepubFunctionalVerification": "pending_bff_integration_test_resolution_or_accepted_exception" + "prepubPipelineInstanceId": 3095177121, + "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095177121 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391737868. The previous code-merge CONFLICT is no longer active. Deploy orders 155428214/155428215 succeeded at 2026-06-17 20:50-21:01+08:00 for 预发环境 and 新加坡预发; task 3391737867 prepub integration completed SUCCESS at 2026-06-17 21:12:24+08:00. CR newestRevision/current head is df53f856c31be3abee220b738022f861cbf78b93.", + "prepubFunctionalVerification": "prepub_deploy_and_integration_success; selectedScopes_fail_closed_local_tests_passed; full_batch_auth_e2e_pending" }, { "repo": "lippi-open-dev-front", @@ -333,6 +333,12 @@ "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172211_product.json", "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172212_product.json" ] + }, + "currentSmoke": { + "verifiedAt": "2026-06-17T21:12:51+08:00", + "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json", + "result": "exit 0; printed 推荐权限已全部授权或没有可授权项 and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED", + "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095177121 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" } }, "latestDiagnostics": [ @@ -373,7 +379,8 @@ "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception.", "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", - "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest." + "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", + "2026-06-17 21:12+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and BFF instance 3095177121 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155428214/155428215. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED." ] }, "serviceContract": { @@ -530,5 +537,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T19:15:53+08:00" + "updatedAt": "2026-06-17T21:12:51+08:00" } From 1926bd17b924814e54df2cf22c932ab5344a993a Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 21:48:12 +0800 Subject: [PATCH 45/61] docs: update latest bff prepub instance --- docs/batch-auth-prd.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 82109cb0..c2e6c78a 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -266,8 +266,8 @@ "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", "aoneNewestRevision": "df53f856c31be3abee220b738022f861cbf78b93", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095177121, - "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095177121 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391737868. The previous code-merge CONFLICT is no longer active. Deploy orders 155428214/155428215 succeeded at 2026-06-17 20:50-21:01+08:00 for 预发环境 and 新加坡预发; task 3391737867 prepub integration completed SUCCESS at 2026-06-17 21:12:24+08:00. CR newestRevision/current head is df53f856c31be3abee220b738022f861cbf78b93.", + "prepubPipelineInstanceId": 3095182503, + "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095182503 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391762849. The previous code-merge CONFLICT is no longer active. Deploy orders 155430894/155430896 succeeded at 2026-06-17 21:21-21:32+08:00 for 预发环境 and 新加坡预发; task 3391762848 prepub integration completed SUCCESS at 2026-06-17 21:44:04+08:00. CR newestRevision/current head is df53f856c31be3abee220b738022f861cbf78b93.", "prepubFunctionalVerification": "prepub_deploy_and_integration_success; selectedScopes_fail_closed_local_tests_passed; full_batch_auth_e2e_pending" }, { @@ -335,10 +335,10 @@ ] }, "currentSmoke": { - "verifiedAt": "2026-06-17T21:12:51+08:00", + "verifiedAt": "2026-06-17T21:45:11+08:00", "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json", "result": "exit 0; printed 推荐权限已全部授权或没有可授权项 and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED", - "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095177121 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" + "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" } }, "latestDiagnostics": [ @@ -380,7 +380,7 @@ "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", - "2026-06-17 21:12+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and BFF instance 3095177121 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155428214/155428215. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED." + "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED." ] }, "serviceContract": { @@ -537,5 +537,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T21:12:51+08:00" + "updatedAt": "2026-06-17T21:45:11+08:00" } From d09fca4b9b29c9a85746894956d469a6dfda7ae7 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 22:16:58 +0800 Subject: [PATCH 46/61] fix: guard empty login recommend all-scope plan --- internal/pat/chmod.go | 4 ++++ internal/pat/chmod_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 031491e6..bf9a8d2f 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -647,6 +647,10 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To } func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, error) { + productCodes = normalizeProductCodes(productCodes) + if !recommend && len(productCodes) == 0 { + return nil, nil, fmt.Errorf("全部授权需要至少一个授权业务域") + } planArgs := buildBatchPlanArgs(nil, productCodes, recommend, grantTypePermanent, "", "", true) planArgs["caller"] = patCallerAuthLoginRecommend planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index cb5e3b57..9966fab6 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -876,6 +876,22 @@ func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing } } +func TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan(t *testing.T) { + fake := &sequenceToolCaller{} + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeAll, + }) + if err == nil { + t.Fatal("RunLoginRecommendAuthorizationWithOptions error = nil, want product-domain validation error") + } + if !strings.Contains(err.Error(), "至少一个授权业务域") { + t.Fatalf("error = %v, want product-domain validation error", err) + } + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want no empty all-scope plan call", len(fake.calls)) + } +} + func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) { fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, From 6946dd1e35918de7caee9066d0ad20d7e564d1d7 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 22:21:38 +0800 Subject: [PATCH 47/61] docs: record empty plan guard verification --- docs/batch-auth-prd.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index c2e6c78a..800bb935 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -220,9 +220,9 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "710844382e1a652e5294a233018fd3c93a1df9a0", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit 710844382e1a652e5294a233018fd3c93a1df9a0; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws from current source; local go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit passed. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. prepub_smoke_ok_2026-06-17_21: /usr/local/bin/dws auth login --recommend --no-browser --format json completed against current prepub services without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." + "commit": "d09fca4b9b29c9a85746894956d469a6dfda7ae7", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit d09fca4b9b29c9a85746894956d469a6dfda7ae7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Local go test ./internal/app ./internal/pat passed after the empty all-scope plan guard. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false only after selected productCodes are available; a local guard now rejects recommend=false with empty productCodes before any PAT call to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. prepub_smoke_ok_2026-06-17_22: /usr/local/bin/dws and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json both completed against current prepub services without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." }, { "repo": "lippi-open-mcpserver-gw", @@ -335,9 +335,9 @@ ] }, "currentSmoke": { - "verifiedAt": "2026-06-17T21:45:11+08:00", - "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json", - "result": "exit 0; printed 推荐权限已全部授权或没有可授权项 and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED", + "verifiedAt": "2026-06-17T22:17:48+08:00", + "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", + "result": "both dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151", "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" } }, @@ -380,7 +380,8 @@ "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", - "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED." + "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED.", + "2026-06-17 22:17+08:00 CLI empty-plan hardening: commit d09fca4b9b29c9a85746894956d469a6dfda7ae7 adds a local guard in planLoginRecommend so recommend=false with empty productCodes fails before any pat.batch_plan call. This prevents all-scope/TUI edge paths from reaching PAT as an empty plan and surfacing PAT_BATCH_AUTH_SCOPE_REQUIRED. Regression coverage: TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan plus go test ./internal/pat ./internal/app. Local default binaries were rebuilt and unified: /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws all have sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Smoke passed for both dws auth login --recommend paths and dws pat chmod --recommend --dry-run with 151 all-granted recommended items." ] }, "serviceContract": { From 98804801c9553912b88cbb72e4af9158ac32ab34 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 17 Jun 2026 23:17:40 +0800 Subject: [PATCH 48/61] docs: record recommend empty-plan recheck --- docs/batch-auth-prd.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 800bb935..12b6449d 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -335,9 +335,9 @@ ] }, "currentSmoke": { - "verifiedAt": "2026-06-17T22:17:48+08:00", - "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", - "result": "both dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151", + "verifiedAt": "2026-06-17T23:10:00+08:00", + "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json --verbose; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", + "result": "both rebuilt dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151. /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws were rebuilt from current source and share sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f", "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" } }, @@ -381,7 +381,8 @@ "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED.", - "2026-06-17 22:17+08:00 CLI empty-plan hardening: commit d09fca4b9b29c9a85746894956d469a6dfda7ae7 adds a local guard in planLoginRecommend so recommend=false with empty productCodes fails before any pat.batch_plan call. This prevents all-scope/TUI edge paths from reaching PAT as an empty plan and surfacing PAT_BATCH_AUTH_SCOPE_REQUIRED. Regression coverage: TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan plus go test ./internal/pat ./internal/app. Local default binaries were rebuilt and unified: /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws all have sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Smoke passed for both dws auth login --recommend paths and dws pat chmod --recommend --dry-run with 151 all-granted recommended items." + "2026-06-17 22:17+08:00 CLI empty-plan hardening: commit d09fca4b9b29c9a85746894956d469a6dfda7ae7 adds a local guard in planLoginRecommend so recommend=false with empty productCodes fails before any pat.batch_plan call. This prevents all-scope/TUI edge paths from reaching PAT as an empty plan and surfacing PAT_BATCH_AUTH_SCOPE_REQUIRED. Regression coverage: TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan plus go test ./internal/pat ./internal/app. Local default binaries were rebuilt and unified: /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws all have sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Smoke passed for both dws auth login --recommend paths and dws pat chmod --recommend --dry-run with 151 all-granted recommended items.", + "2026-06-17 23:10+08:00 repeated PAT_BATCH_AUTH_SCOPE_REQUIRED report rechecked. Current local default dws paths were rebuilt again from branch codex/dws-auth-login-recommend, now sharing sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f. /usr/local/bin/dws auth login --recommend --no-browser --format json --verbose and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose both exited 0 and returned 推荐权限已全部授权或没有可授权项. SLS receipt: lippi-pat-core/lippi-pat-core-logging had 0 PAT_BATCH_AUTH_SCOPE_REQUIRED or requires-scopes hits in the recent window; lippi-open-mcpserver-gw-log prepub auth_login_recommend plan entries carried caller=auth_login_recommend, productCodes=[], recommend=true, scopes=null. A separate production mcp-gw direct tools/call with arguments={} had no trusted identity and did not enter PAT-core, so it is not the current dws auth login --recommend path." ] }, "serviceContract": { From a2e8700bebffa82680f8818eb2f567e48f4a5220 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 18 Jun 2026 00:48:01 +0800 Subject: [PATCH 49/61] docs: record caller-only recommend fallback --- docs/batch-auth-prd.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 12b6449d..0a7a6d59 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -339,7 +339,10 @@ "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json --verbose; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", "result": "both rebuilt dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151. /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws were rebuilt from current source and share sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f", "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" - } + }, + "notes": [ + "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Full prepub redeploy and e2e remain pending for these new compatibility commits." + ] }, "latestDiagnostics": [ "PAT core Aone compile error at PatAuthCheckService.java:517 was caused by a two-argument tryConsumeOnceTokenFromTair call after the method signature changed to require effective risk level. Current feature and release-merge worktrees use RiskLevelEnum.MEDIUM.getCode() for the high-risk-to-medium DCF bypass path and compile locally.", @@ -400,6 +403,9 @@ "skippedScopes": "not grantable or filtered scopes", "pendingScopes": "high-risk scopes requiring separate confirmation", "warnings": "recommendation filter and partial selection warnings" + }, + "compatibility": { + "loginRecommendCallerDefault": "If an older caller sends caller=auth_login_recommend without scopes/productCodes/recommend, gateway and PAT core default the plan to recommend=true. Explicit recommend=false with empty productCodes is still invalid so all-scope mode cannot silently downgrade into recommend mode." } }, "pat.batch_grant": { @@ -539,5 +545,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-17T21:45:11+08:00" + "updatedAt": "2026-06-18T00:42:00+08:00" } From a040b57be722bf8c2cfa983b41c5145a300b54ff Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 18 Jun 2026 01:44:31 +0800 Subject: [PATCH 50/61] fix: stop auth login post-approval tui retry --- docs/batch-auth-prd.json | 5 +-- internal/app/auth_command.go | 3 +- internal/app/pat_auth_retry.go | 54 +++++++++++++++++++++++++++-- internal/app/pat_auth_retry_test.go | 19 ++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 0a7a6d59..83b77da3 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -341,7 +341,8 @@ "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" }, "notes": [ - "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Full prepub redeploy and e2e remain pending for these new compatibility commits." + "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Full prepub redeploy and e2e remain pending for these new compatibility commits.", + "2026-06-18 CLI interaction fix: auth login post-login authorization now waits for the PAT batch authorization page to complete and then returns; it no longer runs the original TUI/plan/grant function again after APPROVED, so the terminal will not show 授权完成,正在重试... followed by a second 选择要授权的业务域. Verification: env GOCACHE=/tmp/go-cache go test ./internal/app -run 'TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry|TestRunDirectPATAuthCheck_ApprovedRetriesCallback|TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat|TestAuthLoginRecommendSkipsPostLoginTUI' => ok; env GOCACHE=/tmp/go-cache go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit => ok. Local default binaries /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws rebuilt from current source with sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75." ] }, "latestDiagnostics": [ @@ -545,5 +546,5 @@ "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." }, - "updatedAt": "2026-06-18T00:42:00+08:00" + "updatedAt": "2026-06-18T01:05:00+08:00" } diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e83a5ea8..9a97cc7c 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -186,11 +186,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } err := run(cmd.Context()) if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { - return runDirectPATAuthCheck( + return runDirectPATAuthCheckWaitOnly( cmd.Context(), &GlobalFlags{Format: retryFormat}, patErr, - run, cmd.ErrOrStderr(), ) } diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 1e9200f2..497ce3ab 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -411,11 +411,36 @@ func runDirectPATAuthCheck( if retry == nil { return patErr } + return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true) +} + +func runDirectPATAuthCheckWaitOnly( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + output io.Writer, +) error { + return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false) +} + +func runDirectPATAuthCheckWithMode( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + retry func(context.Context) error, + output io.Writer, + retryAfterApproval bool, +) error { + if retryAfterApproval && retry == nil { + return patErr + } runner := &runtimeRunner{ globalFlags: globalFlags, fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) { - if err := retry(retryCtx); err != nil { - return executor.Result{}, err + if retry != nil { + if err := retry(retryCtx); err != nil { + return executor.Result{}, err + } } invocation.Implemented = true return executor.Result{ @@ -432,6 +457,9 @@ func runDirectPATAuthCheck( CanonicalProduct: defaultPATProductID, Tool: "pat.batch_grant", CanonicalPath: "pat.batch_grant", + Params: map[string]any{ + "retryAfterApproval": retryAfterApproval, + }, }, patErr, defaultConfigDir(), output) return err } @@ -595,6 +623,16 @@ func handlePatAuthCheck( // Clear token cache so the new credentials take effect. ResetRuntimeTokenCache() + if shouldSkipPATRetryAfterApproval(invocation) { + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "ok": true, + }, + }, nil + } + // Workaround: brief delay to let server-side authorization state propagate // before retrying. Without this the retry may use stale credentials. slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s") @@ -639,6 +677,18 @@ func handlePatAuthCheck( } } +func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool { + if invocation.Params == nil { + return false + } + value, ok := invocation.Params["retryAfterApproval"] + if !ok { + return false + } + retry, ok := value.(bool) + return ok && !retry +} + func enrichPATErrorForHostControl(raw string) string { if strings.TrimSpace(raw) == "" { return raw diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 5c037ced..6184356c 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -657,6 +657,25 @@ func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { } } +func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, _ := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-direct", "test-client-id")} + var out bytes.Buffer + err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) + if err != nil { + t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err) + } + if strings.Contains(out.String(), "授权完成,正在重试") { + t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String()) + } + if !strings.Contains(out.String(), "授权成功") { + t.Fatalf("wait-only auth should still report success, output:\n%s", out.String()) + } +} + func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") configDir := t.TempDir() From f0552dd20e6a722add6ae0af366abd9ec8e753b7 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 18 Jun 2026 02:53:00 +0800 Subject: [PATCH 51/61] docs: record latest auth login prepub evidence --- docs/batch-auth-prd.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 83b77da3..2fb1da7a 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -220,39 +220,39 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "d09fca4b9b29c9a85746894956d469a6dfda7ae7", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit d09fca4b9b29c9a85746894956d469a6dfda7ae7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Local go test ./internal/app ./internal/pat passed after the empty all-scope plan guard. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false only after selected productCodes are available; a local guard now rejects recommend=false with empty productCodes before any PAT call to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. prepub_smoke_ok_2026-06-17_22: /usr/local/bin/dws and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json both completed against current prepub services without PAT_BATCH_AUTH_SCOPE_REQUIRED and returned 推荐权限已全部授权或没有可授权项 for the current account. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." + "commit": "a040b57be722bf8c2cfa983b41c5145a300b54ff", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit a040b57be722bf8c2cfa983b41c5145a300b54ff; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75. Local focused and broad Go regressions passed after the auth-login wait-only fix. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false only after selected productCodes are available; a local guard rejects recommend=false with empty productCodes before any PAT call to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_wait_only_contract_ok: after the auth-login batch authorization page is approved, CLI prints the final success result and returns; it no longer prints 授权完成,正在重试... and no longer re-enters the post-login TUI flow. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." }, { "repo": "lippi-open-mcpserver-gw", "localPath": "/Users/xuan/lippi-open-mcpserver-gw", "branch": "codex/pat-batch-auth-service-gate-20260616", - "commit": "a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9", + "commit": "89f6ea8c16cf95678e999d082e3fe94a578d0b6a", "aoneAppId": 296391, "aoneCrId": 34656146, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095163678, - "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095163678 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391672208. Deploy orders 155420965/155420967 succeeded at 2026-06-17 19:43-19:46+08:00; host batch checks for order 155420965 show 2/2 success and host lippi-open-mcpserver-gw011089179070.pre.na610 success. CR newestRevision/current head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9.", - "prepubFunctionalVerification": "prepub_deploy_and_integration_success; full_batch_auth_e2e_pending" + "prepubPipelineInstanceId": 3095206675, + "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095206675 has code merge SUCCESS tasks 3391854522/3391854523, build+scan SUCCESS tasks 3391854524/3391854525, prepub deploy SUCCESS task 3391854526 completed 2026-06-18T02:11:34+08:00, and prepub integration SUCCESS task 3391854527. It is RUNNING/WAITING in prepub verification task 3391854528. CR newestRevision/current pushed head is 89f6ea8c16cf95678e999d082e3fe94a578d0b6a.", + "prepubFunctionalVerification": "prepub_deploy_and_integration_success_for_login_recommend_default_compat; full_batch_auth_e2e_pending" }, { "repo": "lippi-pat-core-wukong-capability", "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", "branch": "codex/batch-auth-page-flow", - "commit": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", + "commit": "dab26d3474ded776e7e1abb6419e75bdb43e75bf", "aoneAppId": 312546, "aoneCrId": 34656143, "aoneCrStatus": "INTG", "aoneCrStatusLabel": "发布中", "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3", + "aoneNewestRevision": "dab26d3474ded776e7e1abb6419e75bdb43e75bf", "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095097702, - "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095097702 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391355313. Deploy order 155387876 succeeded at 2026-06-17 15:17-15:19+08:00; host batch checks show 1/1 success on lippi-pat-core033006251249.pre.na620. CR newestRevision/current head is 79a8fa5a9e5a4cc20f14efe567680a4b2ab897e3.", - "prepubFunctionalVerification": "prepub_deploy_and_integration_success; full_batch_auth_e2e_pending" + "prepubPipelineInstanceId": 3095206676, + "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095206676 has code merge SUCCESS tasks 3391854529/3391854530, build+scan SUCCESS tasks 3391854531/3391854532, prepub deploy SUCCESS task 3391854533 completed 2026-06-18T02:10:33+08:00, and prepub integration SUCCESS task 3391854534. It is RUNNING/WAITING in prepub verification task 3391854535. CR newestRevision/current pushed head is dab26d3474ded776e7e1abb6419e75bdb43e75bf.", + "prepubFunctionalVerification": "prepub_deploy_and_integration_success_for_login_recommend_default_compat; full_batch_auth_e2e_pending" }, { "repo": "lippi-open-app-dev", @@ -338,10 +338,10 @@ "verifiedAt": "2026-06-17T23:10:00+08:00", "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json --verbose; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", "result": "both rebuilt dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151. /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws were rebuilt from current source and share sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f", - "serverPrepubState": "gateway instance 3095163678, PAT instance 3095097702, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" + "serverPrepubState": "gateway instance 3095206675, PAT instance 3095206676, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" }, "notes": [ - "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Full prepub redeploy and e2e remain pending for these new compatibility commits.", + "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Prepub redeploy evidence: gateway Aone pipeline 66 instance 3095206675 and PAT core instance 3095206676 both reached code merge/build/prepub deploy/prepub integration SUCCESS and are waiting only at manual prepub verification.", "2026-06-18 CLI interaction fix: auth login post-login authorization now waits for the PAT batch authorization page to complete and then returns; it no longer runs the original TUI/plan/grant function again after APPROVED, so the terminal will not show 授权完成,正在重试... followed by a second 选择要授权的业务域. Verification: env GOCACHE=/tmp/go-cache go test ./internal/app -run 'TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry|TestRunDirectPATAuthCheck_ApprovedRetriesCallback|TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat|TestAuthLoginRecommendSkipsPostLoginTUI' => ok; env GOCACHE=/tmp/go-cache go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit => ok. Local default binaries /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws rebuilt from current source with sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75." ] }, From 7915a5a4e1b496429def2026a64707a5f4bcc747 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 18 Jun 2026 17:18:46 +0800 Subject: [PATCH 52/61] docs: refresh batch auth release evidence --- docs/batch-auth-prd.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json index 2fb1da7a..d92e664d 100644 --- a/docs/batch-auth-prd.json +++ b/docs/batch-auth-prd.json @@ -220,8 +220,8 @@ "repo": "dingtalk-workspace-cli", "localPath": "/Users/xuan/dingtalk-workspace-cli", "branch": "codex/dws-auth-login-recommend", - "commit": "a040b57be722bf8c2cfa983b41c5145a300b54ff", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit a040b57be722bf8c2cfa983b41c5145a300b54ff; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75. Local focused and broad Go regressions passed after the auth-login wait-only fix. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", + "commit": "f0552dd20e6a722add6ae0af366abd9ec8e753b7", + "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit f0552dd20e6a722add6ae0af366abd9ec8e753b7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75 before the latest docs-only evidence commit. Local focused and broad Go regressions passed after the auth-login wait-only fix. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false only after selected productCodes are available; a local guard rejects recommend=false with empty productCodes before any PAT call to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_wait_only_contract_ok: after the auth-login batch authorization page is approved, CLI prints the final success result and returns; it no longer prints 授权完成,正在重试... and no longer re-enters the post-login TUI flow. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." }, { @@ -274,11 +274,11 @@ "repo": "lippi-open-dev-front", "localPath": "/Users/xuan/lippi-open-dev-front", "branch": "codex/batch-auth-page-flow", - "commit": "f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4", + "commit": "aafa687c3ce23a9292c7890d9af40a18ce07fb2a", "aoneAppId": null, "aoneCrId": null, - "prepubPackage": "O2 assets app still does not support pub-env=pre. Non-production daily assets build/deploy succeeded on task 149432767 for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, build id 53073038, asset count 203, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/. Local npm run build passed with existing bundle-size warnings only.", - "prepubFunctionalVerification": "frontend_daily_cdn_copy_and_account_avatar_priority_ok; full_e2e_pending_server_current_heads", + "prepubPackage": "O2 assets app still does not support pub-env=pre. Latest branch head aafa687c3ce23a9292c7890d9af40a18ce07fb2a is pushed and local npm run build passed with existing bundle-size warnings only. The latest recorded non-production daily assets build/deploy is still task 149432767 for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, build id 53073038, asset count 203, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/; a new O2/daily package for aafa687c3ce23a9292c7890d9af40a18ce07fb2a is still pending.", + "prepubFunctionalVerification": "frontend_local_build_ok_for_account_subtitle_centering_and_batch_description_fix; latest_daily_cdn_package_pending; full_e2e_pending_server_current_heads", "o2AppId": 8780, "o2IterationId": 16804012, "o2TaskId": 149432767, @@ -386,7 +386,9 @@ "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED.", "2026-06-17 22:17+08:00 CLI empty-plan hardening: commit d09fca4b9b29c9a85746894956d469a6dfda7ae7 adds a local guard in planLoginRecommend so recommend=false with empty productCodes fails before any pat.batch_plan call. This prevents all-scope/TUI edge paths from reaching PAT as an empty plan and surfacing PAT_BATCH_AUTH_SCOPE_REQUIRED. Regression coverage: TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan plus go test ./internal/pat ./internal/app. Local default binaries were rebuilt and unified: /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws all have sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Smoke passed for both dws auth login --recommend paths and dws pat chmod --recommend --dry-run with 151 all-granted recommended items.", - "2026-06-17 23:10+08:00 repeated PAT_BATCH_AUTH_SCOPE_REQUIRED report rechecked. Current local default dws paths were rebuilt again from branch codex/dws-auth-login-recommend, now sharing sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f. /usr/local/bin/dws auth login --recommend --no-browser --format json --verbose and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose both exited 0 and returned 推荐权限已全部授权或没有可授权项. SLS receipt: lippi-pat-core/lippi-pat-core-logging had 0 PAT_BATCH_AUTH_SCOPE_REQUIRED or requires-scopes hits in the recent window; lippi-open-mcpserver-gw-log prepub auth_login_recommend plan entries carried caller=auth_login_recommend, productCodes=[], recommend=true, scopes=null. A separate production mcp-gw direct tools/call with arguments={} had no trusted identity and did not enter PAT-core, so it is not the current dws auth login --recommend path." + "2026-06-17 23:10+08:00 repeated PAT_BATCH_AUTH_SCOPE_REQUIRED report rechecked. Current local default dws paths were rebuilt again from branch codex/dws-auth-login-recommend, now sharing sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f. /usr/local/bin/dws auth login --recommend --no-browser --format json --verbose and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose both exited 0 and returned 推荐权限已全部授权或没有可授权项. SLS receipt: lippi-pat-core/lippi-pat-core-logging had 0 PAT_BATCH_AUTH_SCOPE_REQUIRED or requires-scopes hits in the recent window; lippi-open-mcpserver-gw-log prepub auth_login_recommend plan entries carried caller=auth_login_recommend, productCodes=[], recommend=true, scopes=null. A separate production mcp-gw direct tools/call with arguments={} had no trusted identity and did not enter PAT-core, so it is not the current dws auth login --recommend path.", + "2026-06-18 03:20+08:00 Git remote sync audit: lippi-open-mcpserver-gw commit 89f6ea8c16cf95678e999d082e3fe94a578d0b6a and lippi-pat-core-wukong-capability commit dab26d3474ded776e7e1abb6419e75bdb43e75bf are present on their GitLab HTTPS branches. SSH remote push/fetch fails with Permission denied (publickey), so local origin tracking refs were refreshed via HTTPS fetch; both branches now match origin and only unrelated untracked local files remain.", + "2026-06-18 03:23+08:00 frontend source-of-truth audit: the batch product-row description was previously derived from DeviceCodeFlowDetailResult.ScopeInfo.operationSummary. BFF /dingtalk-workspace-cli/oauth/device/detail only forwards PAT queryFlowDetail, and PAT DeviceCodeFlowService#resolveOperationSummary falls back to product_scope_template.description when flowData.operationSummary is absent; therefore text such as 按DWS v0.2.55授权项清单同步默认策略:sheet delete-dimension comes from PAT template description, not frontend hardcoding. Frontend commit aafa687c3ce23a9292c7890d9af40a18ce07fb2a changes grouped rows to prefer productSummary/productDescription/productDesc and otherwise fall back to the first two scope display names, keeps scope-level operationSummary out of the product row, centers 请求以下钉钉账号进行授权, and makes product-row descriptions single-line ellipsis with stable row height. Verification: git diff --check passed and npm run build passed with existing bundle-size warnings only." ] }, "serviceContract": { From 86086437d80e2b86817ada94ab33aaac5e44df72 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 23 Jun 2026 21:57:17 +0800 Subject: [PATCH 53/61] fix(auth): default login to recommended cli authorization --- internal/app/auth_command.go | 173 ++++++++++++++++++++++-------- internal/app/auth_command_test.go | 101 +++++++++-------- 2 files changed, 187 insertions(+), 87 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 9a97cc7c..dd9dac8d 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -34,6 +34,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -53,6 +54,14 @@ const ( authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials" ) +var ( + authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"} + authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"} + authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"} + authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"} + authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"} +) + func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", @@ -100,11 +109,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 示例: - dws auth login # 本机扫码登录 (loopback 流) + dws auth login --recommend # 推荐:本机扫码登录并授权推荐 CLI 权限 + dws auth login # 同 --recommend,兼容保留的短写法 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 强制重新登录 (忽略缓存 token) - dws auth login --token xxx # 使用指定 token - dws auth login --recommend # 登录成功后打开推荐权限批量授权页`, + dws auth login --token xxx # 使用指定 token`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := resolveAuthLoginConfig(cmd) @@ -114,8 +123,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") - postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend) - humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, cfg.Recommend || postLoginTUIMode) + defaultRecommendMode := !cfg.Yes && authLoginShouldUseDefaultRecommendAuthorization(cmd, format, cfg.Recommend) + recommendAuthMode := cfg.Recommend || defaultRecommendMode + humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode) switch { case strings.TrimSpace(cfg.Token) != "": @@ -154,29 +164,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { w := cmd.OutOrStdout() runPostLoginAuthorization := func() error { - if !cfg.Recommend && !postLoginTUIMode { + if !recommendAuthMode { return nil } - recommendScopeMode := pat.LoginRecommendScopeRecommended - if postLoginTUIMode { - action, err := authLoginGuideActionSelector() - if err != nil { - return err - } - if err := authLoginGuideActionApplier(cmd, configDir, action); err != nil { - return err - } - recommendScopeMode, err = loginRecommendScopeModeSelector() - if err != nil { - return err - } - } - opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} - if postLoginTUIMode { - opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { - return loginRecommendProductSelector(products) - } - } + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: pat.LoginRecommendScopeRecommended} retryFormat := format if humanAuthMode { retryFormat = "table" @@ -207,32 +198,32 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { // Default table output fmt.Fprintln(w) if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force { - fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n") + fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录")) } else { - fmt.Fprintf(w, "[OK] 登录成功!\n") + fmt.Fprintln(w, authLoginStatusLine("登录成功!")) } if tokenData != nil { if tokenData.CorpName != "" { - fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName) + fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName)) } if tokenData.CorpID != "" { - fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID) + fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID)) } if tokenData.UserName != "" { - fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName) + fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName)) } if expiry := authLoginDisplayExpiry(tokenData); expiry != "" { - fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry) + fmt.Fprintln(w, authLoginInfoLine("有效期", expiry)) } } - fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n") + fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录")) return runPostLoginAuthorization() }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)") - cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限") + cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限(交互登录默认开启)") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") @@ -272,7 +263,7 @@ func selectAuthLoginGuideAction() (authLoginGuideAction, error) { ). Value(&choice), ), - ) + ).WithTheme(authLoginHuhTheme()) if err := form.Run(); err != nil { return "", fmt.Errorf("使用引导选择中止: %w", err) } @@ -327,7 +318,7 @@ func promptAuthLoginManualCredentials() (string, string, error) { Value(&clientSecret). Validate(nonEmpty("AppSecret")), ), - ) + ).WithTheme(authLoginHuhTheme()) if err := form.Run(); err != nil { return "", "", fmt.Errorf("应用凭证输入中止: %w", err) } @@ -347,7 +338,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { ). Value(&choice), ), - ) + ).WithTheme(authLoginHuhTheme()) if err := form.Run(); err != nil { return "", fmt.Errorf("授权范围选择中止: %w", err) } @@ -393,7 +384,7 @@ func newAuthLogoutCommand() *cobra.Command { w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 已清除所有认证信息") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, @@ -461,7 +452,7 @@ func newAuthStatusCommand() *cobra.Command { } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "运行 dws auth login 进行登录") + fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录") } } return nil @@ -501,7 +492,7 @@ func newAuthExportCommand() *cobra.Command { )) } if !authpkg.PortableAuthSourceReady() { - return apperrors.NewValidation("尚未登录,请先运行 dws auth login") + return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend") } var bundle bytes.Buffer @@ -675,7 +666,7 @@ func newAuthResetCommand() *cobra.Command { w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 认证信息已重置") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, @@ -752,13 +743,98 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin return nil }), ), - ) + ).WithTheme(authLoginHuhTheme()) if err := form.Run(); err != nil { return nil, fmt.Errorf("授权业务域选择中止: %w", err) } return selected, nil } +func authLoginHuhTheme() *huh.Theme { + t := huh.ThemeBase() + + t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk) + t.FieldSeparator = lipgloss.NewStyle().SetString("\n") + + t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue) + t.Focused.Card = t.Focused.Base + t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) + t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1) + t.Focused.Description = authLoginMutedStyle() + t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) + t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) + t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true) + t.Focused.MultiSelectSelector = t.Focused.SelectSelector + t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) + t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) + t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) + t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue) + t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted) + t.Focused.FocusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}). + Background(authLoginBrandBlue). + Padding(0, 2). + Bold(true) + t.Focused.BlurredButton = lipgloss.NewStyle(). + Foreground(authLoginInk). + Background(authLoginLine). + Padding(0, 2) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue) + t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.TextInput.Placeholder = authLoginMutedStyle() + t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue) + t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine) + t.Blurred.Card = t.Blurred.Base + t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk) + t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1) + t.Blurred.Description = authLoginMutedStyle() + t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") + t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector + t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk) + t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) + t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted) + t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted) + t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + + t.Help.ShortKey = authLoginMutedStyle() + t.Help.ShortDesc = authLoginMutedStyle() + t.Help.ShortSeparator = authLoginMutedStyle() + t.Help.FullKey = authLoginMutedStyle() + t.Help.FullDesc = authLoginMutedStyle() + t.Help.FullSeparator = authLoginMutedStyle() + t.Help.Ellipsis = authLoginMutedStyle() + + return t +} + +func authLoginStatusLine(message string) string { + return fmt.Sprintf("%s %s", + lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"), + lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message), + ) +} + +func authLoginInfoLine(key, value string) string { + label := authLoginMutedStyle().Width(14).Render(key + ":") + return fmt.Sprintf("%s %s", label, value) +} + +func authLoginMutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(authLoginMuted) +} + func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool { return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) } @@ -772,6 +848,19 @@ func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recom } func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + return false +} + +func authLoginShouldUseDefaultRecommendAuthorization(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUseDefaultRecommendAuthorizationForTerminal( + cmd, + format, + recommend, + authLoginInteractiveTerminal(), + ) +} + +func authLoginShouldUseDefaultRecommendAuthorizationForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { if recommend || !interactive { return false } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 24add7f8..d520f3a7 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -202,13 +202,16 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { } }) - t.Run("without recommend shows tui for interactive default login", func(t *testing.T) { + t.Run("without recommend defaults to recommend auth without tui", func(t *testing.T) { root := newRoot(t) - if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { - t.Fatal("default interactive login without --recommend should show post-login TUI") + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("default interactive login must not show post-login TUI") + } + if !authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, true) { + t.Fatal("default interactive login should use recommend authorization") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { - t.Fatal("default interactive post-login TUI flow should use human authorization flow") + t.Fatal("default interactive recommend authorization should use human authorization flow") } }) @@ -220,18 +223,24 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { t.Fatal("explicit --format json must not show post-login TUI") } + if authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, true) { + t.Fatal("explicit --format json must not default to recommend authorization") + } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { t.Fatal("explicit --format json must keep machine-readable authorization flow") } }) - t.Run("table without recommend shows tui", func(t *testing.T) { + t.Run("table without recommend defaults to recommend auth", func(t *testing.T) { root := newRoot(t) if err := root.PersistentFlags().Set("format", "table"); err != nil { t.Fatalf("set format: %v", err) } - if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { - t.Fatal("table format should show post-login TUI without --recommend") + if authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { + t.Fatal("table format must not show post-login TUI without --recommend") + } + if !authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "table", false, true) { + t.Fatal("table format should default to recommend authorization") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) { t.Fatal("table format should use human authorization flow in an interactive terminal") @@ -243,6 +252,9 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) { t.Fatal("non-interactive login should skip post-login TUI") } + if authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, false) { + t.Fatal("non-interactive login should not default to recommend authorization") + } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) { t.Fatal("non-interactive login should keep machine-readable authorization flow") } @@ -356,7 +368,7 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { } } -func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { +func TestAuthLoginDefaultRecommendRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) configDir := t.TempDir() @@ -376,41 +388,36 @@ func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { }) authLoginInteractiveTerminal = func() bool { return true } - var sawTokenBeforeTUI bool + var sawTokenBeforePlan bool authLoginGuideActionSelector = func() (authLoginGuideAction, error) { - token, err := authpkg.LoadTokenData(configDir) - if err != nil { - t.Fatalf("LoadTokenData before TUI error = %v", err) - } - if token.AccessToken != "login-token" { - t.Fatalf("AccessToken before TUI = %q, want login-token", token.AccessToken) - } - sawTokenBeforeTUI = true - return authLoginGuideDirectCLI, nil + t.Fatal("default recommend must not call the post-login guide selector") + return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default recommend must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { - if !sawTokenBeforeTUI { - t.Fatal("scope selector ran before login token was observed") - } - return pat.LoginRecommendScopeAll, nil + t.Fatal("default recommend must not call the scope-mode TUI") + return "", nil } loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { - if !sawTokenBeforeTUI { - t.Fatal("product selector ran before login token was observed") - } - if len(products) != 1 || products[0].ProductCode != "calendar" { - t.Fatalf("selector products = %+v, want calendar", products) - } - return []string{"calendar"}, nil + t.Fatal("default recommend must not call the product-domain TUI") + return nil, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ - `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }, beforeCall: func(toolName string) { + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before %s error = %v", toolName, err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken) + } + sawTokenBeforePlan = true }} cmd := newAuthLoginCommand(fake) var out bytes.Buffer @@ -421,23 +428,23 @@ func TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } - if !sawTokenBeforeTUI { - t.Fatal("post-login TUI selector was not called") + if !sawTokenBeforePlan { + t.Fatal("recommend authorization plan was not called after token save") } - if len(fake.tools) != 3 { - t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.tools)) + if len(fake.tools) != 2 { + t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools)) } - if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { - t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) } if got := fake.args[0]["recommend"]; got != true { - t.Fatalf("initial product discovery plan recommend = %#v, want true", got) + t.Fatalf("default recommend plan recommend = %#v, want true", got) } - if got := fake.args[1]["recommend"]; got != false { - t.Fatalf("selected all-scope plan recommend = %#v, want false", got) - } - if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { - t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got) + if got := fake.args[0]["productCodes"]; got != nil { + codes, ok := got.([]string) + if !ok || len(codes) != 0 { + t.Fatalf("default recommend plan productCodes = %#v, want no concrete product codes", got) + } } } @@ -448,12 +455,16 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { } type authLoginRecommendSequenceCaller struct { - responses []string - tools []string - args []map[string]any + responses []string + tools []string + args []map[string]any + beforeCall func(toolName string) } func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { + if f.beforeCall != nil { + f.beforeCall(toolName) + } f.tools = append(f.tools, toolName) copiedArgs := make(map[string]any, len(args)) for key, value := range args { From 1ac863641893cb2f48a696fb6adc7bbccd404b98 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Tue, 23 Jun 2026 23:09:11 +0800 Subject: [PATCH 54/61] fix(auth): restore login authorization selectors --- internal/app/auth_command.go | 43 ++++++++------- internal/app/auth_command_test.go | 88 ++++++++++++++++++------------- 2 files changed, 74 insertions(+), 57 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index dd9dac8d..6b51173a 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -109,8 +109,8 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 示例: - dws auth login --recommend # 推荐:本机扫码登录并授权推荐 CLI 权限 - dws auth login # 同 --recommend,兼容保留的短写法 + dws auth login # 本机登录后选择推荐/全部权限与授权业务域 + dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 强制重新登录 (忽略缓存 token) dws auth login --token xxx # 使用指定 token`, @@ -123,8 +123,8 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") - defaultRecommendMode := !cfg.Yes && authLoginShouldUseDefaultRecommendAuthorization(cmd, format, cfg.Recommend) - recommendAuthMode := cfg.Recommend || defaultRecommendMode + postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend) + recommendAuthMode := cfg.Recommend || postLoginTUIMode humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode) switch { @@ -167,7 +167,20 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if !recommendAuthMode { return nil } - opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: pat.LoginRecommendScopeRecommended} + recommendScopeMode := pat.LoginRecommendScopeRecommended + if postLoginTUIMode { + var err error + recommendScopeMode, err = loginRecommendScopeModeSelector() + if err != nil { + return err + } + } + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} + if postLoginTUIMode { + opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { + return loginRecommendProductSelector(products) + } + } retryFormat := format if humanAuthMode { retryFormat = "table" @@ -196,6 +209,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } // Default table output + if err := runPostLoginAuthorization(); err != nil { + return err + } fmt.Fprintln(w) if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force { fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录")) @@ -217,13 +233,13 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } } fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录")) - return runPostLoginAuthorization() + return nil }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)") - cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限(交互登录默认开启)") + cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") @@ -848,19 +864,6 @@ func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recom } func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { - return false -} - -func authLoginShouldUseDefaultRecommendAuthorization(cmd *cobra.Command, format string, recommend bool) bool { - return authLoginShouldUseDefaultRecommendAuthorizationForTerminal( - cmd, - format, - recommend, - authLoginInteractiveTerminal(), - ) -} - -func authLoginShouldUseDefaultRecommendAuthorizationForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { if recommend || !interactive { return false } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index d520f3a7..55ae530b 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -202,16 +202,13 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { } }) - t.Run("without recommend defaults to recommend auth without tui", func(t *testing.T) { + t.Run("without recommend shows two-step authorization tui", func(t *testing.T) { root := newRoot(t) - if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { - t.Fatal("default interactive login must not show post-login TUI") - } - if !authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, true) { - t.Fatal("default interactive login should use recommend authorization") + if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("default interactive login should show post-login authorization TUI") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { - t.Fatal("default interactive recommend authorization should use human authorization flow") + t.Fatal("default interactive post-login authorization should use human authorization flow") } }) @@ -223,24 +220,18 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { t.Fatal("explicit --format json must not show post-login TUI") } - if authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, true) { - t.Fatal("explicit --format json must not default to recommend authorization") - } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { t.Fatal("explicit --format json must keep machine-readable authorization flow") } }) - t.Run("table without recommend defaults to recommend auth", func(t *testing.T) { + t.Run("table without recommend shows authorization tui", func(t *testing.T) { root := newRoot(t) if err := root.PersistentFlags().Set("format", "table"); err != nil { t.Fatalf("set format: %v", err) } - if authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { - t.Fatal("table format must not show post-login TUI without --recommend") - } - if !authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "table", false, true) { - t.Fatal("table format should default to recommend authorization") + if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { + t.Fatal("table format should show post-login TUI without --recommend") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) { t.Fatal("table format should use human authorization flow in an interactive terminal") @@ -252,9 +243,6 @@ func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) { t.Fatal("non-interactive login should skip post-login TUI") } - if authLoginShouldUseDefaultRecommendAuthorizationForTerminal(root, "json", false, false) { - t.Fatal("non-interactive login should not default to recommend authorization") - } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) { t.Fatal("non-interactive login should keep machine-readable authorization flow") } @@ -368,7 +356,7 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { } } -func TestAuthLoginDefaultRecommendRunsAfterLoginTokenSaved(t *testing.T) { +func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) configDir := t.TempDir() @@ -388,25 +376,45 @@ func TestAuthLoginDefaultRecommendRunsAfterLoginTokenSaved(t *testing.T) { }) authLoginInteractiveTerminal = func() bool { return true } + var sawTokenBeforeScopeTUI bool + var sawTokenBeforeProductTUI bool var sawTokenBeforePlan bool authLoginGuideActionSelector = func() (authLoginGuideAction, error) { - t.Fatal("default recommend must not call the post-login guide selector") + t.Fatal("default login must not call the operation guide selector") return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { - t.Fatal("default recommend must not apply a post-login guide action") + t.Fatal("default login must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { - t.Fatal("default recommend must not call the scope-mode TUI") - return "", nil + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before scope TUI error = %v", err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken) + } + sawTokenBeforeScopeTUI = true + return pat.LoginRecommendScopeAll, nil } loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { - t.Fatal("default recommend must not call the product-domain TUI") - return nil, nil + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before product TUI error = %v", err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken) + } + sawTokenBeforeProductTUI = true + if len(products) != 1 || products[0].ProductCode != "calendar" { + t.Fatalf("selector products = %+v, want calendar", products) + } + return []string{"calendar"}, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, }, beforeCall: func(toolName string) { @@ -428,23 +436,29 @@ func TestAuthLoginDefaultRecommendRunsAfterLoginTokenSaved(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } + if !sawTokenBeforeScopeTUI { + t.Fatal("scope-mode TUI was not called after token save") + } + if !sawTokenBeforeProductTUI { + t.Fatal("product-domain TUI was not called after token save") + } if !sawTokenBeforePlan { - t.Fatal("recommend authorization plan was not called after token save") + t.Fatal("authorization plan was not called after token save") } - if len(fake.tools) != 2 { - t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools)) + if len(fake.tools) != 3 { + t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools)) } - if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { - t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) } if got := fake.args[0]["recommend"]; got != true { - t.Fatalf("default recommend plan recommend = %#v, want true", got) + t.Fatalf("discovery plan recommend = %#v, want true", got) } - if got := fake.args[0]["productCodes"]; got != nil { - codes, ok := got.([]string) - if !ok || len(codes) != 0 { - t.Fatalf("default recommend plan productCodes = %#v, want no concrete product codes", got) - } + if got := fake.args[1]["recommend"]; got != false { + t.Fatalf("selected all-scope plan recommend = %#v, want false", got) + } + if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got) } } From ef5c05a2e642bf6139c809a55756270c039da88e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 24 Jun 2026 00:04:32 +0800 Subject: [PATCH 55/61] fix(auth): avoid duplicate pat authorization link output --- internal/app/pat_auth_retry.go | 2 +- internal/app/pat_auth_retry_test.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 497ce3ab..925c3c33 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -571,7 +571,7 @@ func handlePatAuthCheck( if patData.Data.URI != "" { authURL := apperrors.PATAuthorizationURL(patData.Data.URI) fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) - fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL) + fmt.Fprintln(output) if openBrowser { _ = openPATAuthorizationURI(authURL) } diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 6184356c..8262d6f4 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -1140,8 +1140,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") { t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String()) } - if !strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=https://example.com/pat") { - t.Fatalf("expected PAT_AUTHORIZATION_URL export line, got %q", buf.String()) + if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String()) } } @@ -1359,8 +1359,8 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) { if opened != rawURI { t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI) } - if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) { - t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } @@ -1405,8 +1405,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test if opened != wantURL { t.Fatalf("opened url = %q, want normalized %q", opened, wantURL) } - if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) { - t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } From 75468dca1ed73c2f2357bada1bc08e4aba4c4dfb Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 24 Jun 2026 09:41:03 +0800 Subject: [PATCH 56/61] fix(auth): hide raw pat url after browser open --- internal/app/pat_auth_retry.go | 12 +++++++++--- internal/app/pat_auth_retry_test.go | 14 ++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 925c3c33..1218eb7f 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -570,11 +570,17 @@ func handlePatAuthCheck( } if patData.Data.URI != "" { authURL := apperrors.PATAuthorizationURL(patData.Data.URI) - fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) - fmt.Fprintln(output) if openBrowser { - _ = openPATAuthorizationURI(authURL) + if err := openPATAuthorizationURI(authURL); err != nil { + fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) + fmt.Fprintf(output, " %s 自动打开浏览器失败: %v\n", dim("ℹ"), err) + } else { + fmt.Fprintf(output, " %s 已打开授权页面\n", dim("🔗")) + } + } else { + fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) } + fmt.Fprintln(output) } // Poll the device flow status until user authorizes, rejects, or timeout. diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 8262d6f4..3a771a95 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -1359,8 +1359,11 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) { if opened != rawURI { t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI) } - if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { - t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") || strings.Contains(got, rawURI) { + t.Fatalf("human-readable PAT output should not emit raw authorization URL after opening browser:\n%s", got) + } + if got := buf.String(); !strings.Contains(got, "已打开授权页面") { + t.Fatalf("human-readable PAT output should confirm browser open without a raw URL:\n%s", got) } } @@ -1405,8 +1408,11 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test if opened != wantURL { t.Fatalf("opened url = %q, want normalized %q", opened, wantURL) } - if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { - t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") || strings.Contains(got, wantURL) { + t.Fatalf("human-readable PAT output should not emit raw authorization URL after opening browser:\n%s", got) + } + if got := buf.String(); !strings.Contains(got, "已打开授权页面") { + t.Fatalf("human-readable PAT output should confirm browser open without a raw URL:\n%s", got) } } From df0c0f75453dbea5db16cf7dda2c84e141cd0a5b Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 24 Jun 2026 10:26:05 +0800 Subject: [PATCH 57/61] revert(auth): restore pat authorization link output --- internal/app/pat_auth_retry.go | 14 ++++---------- internal/app/pat_auth_retry_test.go | 14 ++++---------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 1218eb7f..925c3c33 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -570,17 +570,11 @@ func handlePatAuthCheck( } if patData.Data.URI != "" { authURL := apperrors.PATAuthorizationURL(patData.Data.URI) - if openBrowser { - if err := openPATAuthorizationURI(authURL); err != nil { - fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) - fmt.Fprintf(output, " %s 自动打开浏览器失败: %v\n", dim("ℹ"), err) - } else { - fmt.Fprintf(output, " %s 已打开授权页面\n", dim("🔗")) - } - } else { - fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) - } + fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) fmt.Fprintln(output) + if openBrowser { + _ = openPATAuthorizationURI(authURL) + } } // Poll the device flow status until user authorizes, rejects, or timeout. diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 3a771a95..8262d6f4 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -1359,11 +1359,8 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) { if opened != rawURI { t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI) } - if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") || strings.Contains(got, rawURI) { - t.Fatalf("human-readable PAT output should not emit raw authorization URL after opening browser:\n%s", got) - } - if got := buf.String(); !strings.Contains(got, "已打开授权页面") { - t.Fatalf("human-readable PAT output should confirm browser open without a raw URL:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } @@ -1408,11 +1405,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test if opened != wantURL { t.Fatalf("opened url = %q, want normalized %q", opened, wantURL) } - if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") || strings.Contains(got, wantURL) { - t.Fatalf("human-readable PAT output should not emit raw authorization URL after opening browser:\n%s", got) - } - if got := buf.String(); !strings.Contains(got, "已打开授权页面") { - t.Fatalf("human-readable PAT output should confirm browser open without a raw URL:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } From b0e7b58e95fbc1979afad80d93b6ec57f3e09e0e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 24 Jun 2026 21:32:49 +0800 Subject: [PATCH 58/61] feat(tui): apply DingTalk auth console styling --- internal/app/doctor_command.go | 27 +++-- internal/app/pat_auth_retry.go | 136 +++++++++++---------- internal/app/pat_auth_retry_test.go | 54 ++++++++- internal/app/root_help.go | 36 ++++-- internal/app/upgrade.go | 16 +-- internal/auth/device_flow.go | 50 +++----- internal/errors/errors.go | 37 +++--- internal/output/formatter.go | 120 +++++++++++-------- internal/output/pretty.go | 77 ++++++------ internal/tui/theme.go | 175 ++++++++++++++++++++++++++++ internal/tui/theme_test.go | 30 +++++ 11 files changed, 524 insertions(+), 234 deletions(-) create mode 100644 internal/tui/theme.go create mode 100644 internal/tui/theme_test.go diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index 4b3d56fd..b139d379 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -24,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" "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/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -112,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error { return output.WriteJSON(w, result) } - fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail) + fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail))) + fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail) if fail > 0 { return fmt.Errorf("诊断发现 %d 项失败", fail) } @@ -123,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error { func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查登录状态... ") + fmt.Fprint(w, tui.Dim("检查登录状态... ")) } configDir := defaultConfigDir() @@ -186,7 +188,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult { if !jsonOut { - fmt.Fprint(w, "检查网络连通性... ") + fmt.Fprint(w, tui.Dim("检查网络连通性... ")) } baseURL := config.GetMCPBaseURL() @@ -228,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout func doctorCheckCache(w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查缓存状态... ") + fmt.Fprint(w, tui.Dim("检查缓存状态... ")) } store := cacheStoreFromEnv() @@ -300,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult { func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult { if !jsonOut { - fmt.Fprint(w, "检查版本更新... ") + fmt.Fprint(w, tui.Dim("检查版本更新... ")) } currentVer := version @@ -348,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR func printCheckResult(w io.Writer, r checkResult) { icon := statusIcon(r.Status) - fmt.Fprintf(w, "%s %s\n", icon, r.Message) + message := r.Message + switch r.Status { + case statusPass: + message = tui.Success(message) + case statusWarn: + message = tui.Warning(message) + case statusFail: + message = tui.Danger(message) + } + fmt.Fprintf(w, "%s %s\n", icon, message) if r.Hint != "" { - fmt.Fprintf(w, " %s\n", r.Hint) + fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint)) } } @@ -385,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) { func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查性能报告... ") + fmt.Fprint(w, tui.Dim("检查性能报告... ")) } report, err := LoadLatestReport() diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 925c3c33..8377c826 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -34,7 +34,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" - "github.com/fatih/color" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" ) const ( @@ -51,6 +51,10 @@ const ( var openBrowserFunc = tryOpenBrowser +type patSuppressBrowserOpenKeyType struct{} + +var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{} + // PatScopeError holds information about a missing PAT scope. type PatScopeError struct { OriginalError string @@ -153,36 +157,31 @@ func extractPatScopeError(err error) *PatScopeError { // PrintPatAuthError prints a human-readable PAT authorization error. func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) { - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - fmt.Fprintln(w) fmt.Fprintf(w, "{\n") - fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false") - fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity) - fmt.Fprintf(w, " %s: {\n", bold("\"error\"")) - fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType) - fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message) - fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint) + fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false") + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity) + fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\"")) + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType) + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message) + fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint) fmt.Fprintf(w, " }\n") fmt.Fprintf(w, "}\n") fmt.Fprintln(w) // Print authorization instructions - fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权")) + fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权")) fmt.Fprintln(w) - fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权")) + fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权")) if scopeErr.MissingScope != "" { - fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope))) + fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope))) } else { - fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login")) + fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login")) } fmt.Fprintln(w) - fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ")) + fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ")) fmt.Fprintln(w) } @@ -206,7 +205,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool { return wantsStructuredPATOutput(rr) } -func currentPATOpenBrowser(configDir string) bool { +func currentPATOpenBrowser(ctx context.Context, configDir string) bool { + if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed { + return false + } return pat.EffectiveOpenBrowser(configDir) } @@ -253,12 +255,6 @@ func patAuthorizationURIFromData(data map[string]any) string { // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { - bold := color.New(color.Bold).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - red := color.New(color.FgRed).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - timeout := PatAuthRetryTimeout deadline := time.Now().Add(timeout) pollTicker := time.NewTicker(PatAuthPollInterval) @@ -266,21 +262,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr start := time.Now() fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权...")) - fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ")) - fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权...")) + fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ")) + fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout) fmt.Fprintln(output) pollCount := 0 for { select { case <-ctx.Done(): - fmt.Fprintf(output, "%s 操作已取消\n", red("✗")) + fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error")) return false case <-time.After(time.Until(deadline)): - fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout) - fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ")) + fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout) + fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ")) return false case <-pollTicker.C: @@ -293,7 +289,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr if err == nil && tokenData != nil { if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", - green("✓"), bold("授权成功!"), elapsed, remaining) + tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) fmt.Fprintln(output) return true } @@ -301,7 +297,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr // Show polling status fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ", - dim("⟳"), pollCount, elapsed, remaining) + tui.Dim("⟳"), pollCount, elapsed, remaining) } } } @@ -341,8 +337,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati // Retry the invocation fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"), - color.New(color.Bold).SprintFunc()("授权完成,正在重试...")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试...")) fmt.Fprintln(output) return runner.Run(ctx, invocation) @@ -353,6 +348,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati const ( // patPollInterval is how often we poll the device flow status endpoint. patPollInterval = 2 * time.Second + // patMaxPollInterval caps a server-provided poll interval so a malformed + // response cannot make the CLI look permanently stuck. + patMaxPollInterval = 30 * time.Second // patPollTimeout is the maximum time to wait for user authorization via device flow. patPollTimeout = 10 * time.Minute ) @@ -420,6 +418,7 @@ func runDirectPATAuthCheckWaitOnly( patErr *apperrors.PATError, output io.Writer, ) error { + ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true) return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false) } @@ -488,6 +487,7 @@ func handlePatAuthCheck( AuthorizationURL string `json:"authorizationUrl"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` + PollIntervalSecs int `json:"pollIntervalSeconds"` } `json:"data"` } if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { @@ -506,7 +506,7 @@ func handlePatAuthCheck( "hasSecret", patData.Data.ClientSecret != "", ) hostOwnedPAT := authpkg.HostOwnsPATFlow() - openBrowser := currentPATOpenBrowser(configDir) + openBrowser := currentPATOpenBrowser(ctx, configDir) slog.Debug("pat.host_owned_decision", "site", "handlePatAuthCheck", "hostOwned", hostOwnedPAT, @@ -556,21 +556,14 @@ func handlePatAuthCheck( return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)} } - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - greenFn := color.New(color.FgGreen).SprintFunc() - yellowFn := color.New(color.FgYellow).SprintFunc() - redFn := color.New(color.FgRed).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权")) if patData.Data.Desc != "" { - fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc) + fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc) } if patData.Data.URI != "" { authURL := apperrors.PATAuthorizationURL(patData.Data.URI) - fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) + fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL) fmt.Fprintln(output) if openBrowser { _ = openPATAuthorizationURI(authURL) @@ -578,22 +571,25 @@ func handlePatAuthCheck( } // Poll the device flow status until user authorizes, rejects, or timeout. - fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权...")) - fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权...")) + fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout) fmt.Fprintln(output) pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout) defer cancel() - status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output) + status, authCode, err := pollPatDeviceFlowWithInterval( + pollCtx, patData.Data.FlowID, configDir, output, + resolvePATPollInterval(patData.Data.PollIntervalSecs), + ) if err != nil { - fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err) + fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err) return executor.Result{}, patErr } switch status { case authpkg.StatusApproved: - fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!")) fmt.Fprintln(output) if appCfg != nil { @@ -609,11 +605,11 @@ func handlePatAuthCheck( tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode) if exchErr != nil { slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr) - fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr) + fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr) } else { if err := authpkg.SaveTokenData(configDir, tokenData); err != nil { slog.Warn("PAT retry: failed to save new token", "error", err) - fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err) + fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err) } else { slog.Debug("PAT retry: token refreshed and saved") } @@ -639,7 +635,7 @@ func handlePatAuthCheck( time.Sleep(1 * time.Second) // Retry the original invocation with pat-retrying flag to prevent recursion. - fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试...")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试...")) fmt.Fprintln(output) slog.Debug("PAT retry: identity env check", "DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"), @@ -648,7 +644,7 @@ func handlePatAuthCheck( return r.Run(retryCtx, invocation) case authpkg.StatusRejected: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权")) return executor.Result{}, apperrors.NewAuth( "用户已拒绝授权", apperrors.WithReason("pat_auth_rejected"), @@ -656,7 +652,7 @@ func handlePatAuthCheck( ) case authpkg.StatusExpired: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时")) return executor.Result{}, apperrors.NewAuth( "授权超时", apperrors.WithReason("pat_auth_expired"), @@ -664,7 +660,7 @@ func handlePatAuthCheck( ) case authpkg.StatusCancelled: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消")) return executor.Result{}, apperrors.NewAuth( "操作已取消", apperrors.WithReason("pat_auth_cancelled"), @@ -672,7 +668,7 @@ func handlePatAuthCheck( ) default: - fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status) + fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status) return executor.Result{}, patErr } } @@ -753,6 +749,13 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string // state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled. // Returns the final status string and the authCode (non-empty only on APPROVED). func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) { + return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval) +} + +func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) { + if interval <= 0 { + interval = patPollInterval + } pollURL := fmt.Sprintf("%s%s?flowId=%s", authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID)) @@ -769,10 +772,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out }, } - ticker := time.NewTicker(patPollInterval) + ticker := time.NewTicker(interval) defer ticker.Stop() - dim := color.New(color.Faint).SprintFunc() pollCount := 0 for { @@ -784,7 +786,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out return authpkg.StatusExpired, "", nil case <-ticker.C: pollCount++ - fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount) + fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount) req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) if err != nil { @@ -836,6 +838,20 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out } } +func resolvePATPollInterval(seconds int) time.Duration { + if seconds <= 0 { + return patPollInterval + } + interval := time.Duration(seconds) * time.Second + if interval < time.Second { + return time.Second + } + if interval > patMaxPollInterval { + return patMaxPollInterval + } + return interval +} + func browserOpenCommand(goos, rawURL string) *exec.Cmd { switch goos { case "darwin": diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 8262d6f4..42b2f226 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -638,7 +638,7 @@ func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { server, _ := setupHandlePATServer(t, "APPROVED", "") defer server.Close() - patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-direct", "test-client-id")} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} var retried atomic.Bool var retryHadKey atomic.Bool err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error { @@ -662,7 +662,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) { server, _ := setupHandlePATServer(t, "APPROVED", "") defer server.Close() - patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-direct", "test-client-id")} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} var out bytes.Buffer err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) if err != nil { @@ -676,6 +676,56 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) { } } +func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, configDir := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + var opened bool + origOpenBrowser := openBrowserFunc + openBrowserFunc = func(rawURL string) error { + opened = true + return nil + } + t.Cleanup(func() { openBrowserFunc = origOpenBrowser }) + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + var out bytes.Buffer + err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) + if err != nil { + t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err) + } + if opened { + t.Fatal("wait-only auth must not open a second browser tab") + } + if !strings.Contains(out.String(), "授权链接:") { + t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String()) + } +} + +func TestResolvePATPollInterval(t *testing.T) { + tests := []struct { + name string + seconds int + want time.Duration + }{ + {name: "default", seconds: 0, want: patPollInterval}, + {name: "server value", seconds: 3, want: 3 * time.Second}, + {name: "cap excessive value", seconds: 90, want: patMaxPollInterval}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolvePATPollInterval(tt.seconds); got != tt.want { + t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want) + } + }) + } +} + func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") configDir := t.TempDir() diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 0e87c6bd..552142bc 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -6,6 +6,7 @@ import ( "text/tabwriter" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -50,38 +51,42 @@ func renderRootHelp(root *cobra.Command) { utilities := visibleUtilityRootCommands(root) w := root.OutOrStdout() + _, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console")) + _, _ = fmt.Fprintln(w, tui.Rule(76)) + _, _ = fmt.Fprintln(w) + if len(services) == 0 { - _, _ = fmt.Fprintln(w, "No MCP services discovered.") + _, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered.")) _, _ = fmt.Fprintln(w) } else { - _, _ = fmt.Fprintln(w, "Discovered MCP Services:") + _, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:")) _, _ = fmt.Fprintln(w) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, service := range services { - _, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short)) + _, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short))) } _ = tw.Flush() _, _ = fmt.Fprintln(w) } - _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " dws [command] [flags]") + _, _ = fmt.Fprintln(w, tui.Section("Usage:")) + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws [command] [flags]")) if len(utilities) > 0 { - _, _ = fmt.Fprintln(w, " dws [flags]") + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws [flags]")) } _, _ = fmt.Fprintln(w) if len(utilities) > 0 { - _, _ = fmt.Fprintln(w, "Utility Commands:") + _, _ = fmt.Fprintln(w, tui.Section("Utility Commands:")) _, _ = fmt.Fprintln(w) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, utility := range utilities { - _, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short)) + _, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility))) } _ = tw.Flush() _, _ = fmt.Fprintln(w) } - _, _ = fmt.Fprintln(w, `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) + _, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) // Render root.Long after the command list so agents see the upgrade // hint (or any other root-level guidance) after browsing all available @@ -90,10 +95,21 @@ func renderRootHelp(root *cobra.Command) { // it and dropped this, so we restore it explicitly here. if long := strings.TrimSpace(root.Long); long != "" { _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, long) + _, _ = fmt.Fprintln(w, tui.Dim(long)) } } +func commandShort(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + short := strings.TrimSpace(cmd.Short) + if cmd.Name() == "help" && short == "Help about any command" { + return i18n.T("查看任意命令的帮助信息") + } + return short +} + // resolveVisibleProducts returns the set of top-level product IDs that should // be treated as visible. It unions the edition's VisibleProducts hook (when // set) with DirectRuntimeProductIDs(), so dynamically-registered products — diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index ad7e969b..7c47e56d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -14,20 +14,20 @@ import ( "strings" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" - "github.com/fatih/color" "github.com/spf13/cobra" ) var ( - ugBold = color.New(color.Bold).SprintFunc() - ugGreen = color.New(color.FgGreen).SprintFunc() - ugYellow = color.New(color.FgYellow).SprintFunc() - ugRed = color.New(color.FgRed).SprintFunc() - ugCyan = color.New(color.FgCyan).SprintFunc() - ugDim = color.New(color.Faint).SprintFunc() - ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc() + ugBold = tui.Bold + ugGreen = tui.Success + ugYellow = tui.Warning + ugRed = tui.Danger + ugCyan = tui.Cyan + ugDim = tui.Dim + ugBoldGrn = tui.Success ) const defaultListLimit = 10 diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 579a7664..0381c0ab 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -27,8 +27,8 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" - "github.com/fatih/color" ) const ( @@ -590,20 +590,21 @@ func truncateBody(body []byte, maxLen int) string { } var ( - dfBold = color.New(color.Bold).SprintFunc() - dfGreen = color.New(color.FgGreen).SprintFunc() - dfYellow = color.New(color.FgYellow).SprintFunc() - dfRed = color.New(color.FgRed).SprintFunc() - dfCyan = color.New(color.FgCyan).SprintFunc() - dfDim = color.New(color.Faint).SprintFunc() + dfBold = tui.Bold + dfGreen = tui.Success + dfYellow = tui.Warning + dfRed = tui.Danger + dfCyan = tui.Cyan + dfDim = tui.Dim ) func dfPrintStep(w io.Writer, step int, message string, attempt int) { + label := fmt.Sprintf("Step %d", step) if attempt > 1 { - _, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt) + _, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt) return } - _, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message))) + _, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message) } func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) { @@ -629,7 +630,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) { func dfPrintBox(w io.Writer, lines []string) { maxLen := 0 for _, line := range lines { - if l := dfPlainLength(line); l > maxLen { + if l := tui.PlainRuneWidth(line); l > maxLen { maxLen = l } } @@ -638,38 +639,19 @@ func dfPrintBox(w io.Writer, lines []string) { } border := strings.Repeat("─", maxLen+4) - _, _ = fmt.Fprintf(w, " ┌%s┐\n", border) + _, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮")) for _, line := range lines { - pad := maxLen - dfPlainLength(line) + pad := maxLen - tui.PlainRuneWidth(line) if pad < 0 { pad = 0 } - _, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad)) + _, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│")) } - _, _ = fmt.Fprintf(w, " └%s┘\n", border) -} - -func dfPlainLength(s string) int { - inEscape := false - length := 0 - for _, r := range s { - if r == '\033' { - inEscape = true - continue - } - if inEscape { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { - inEscape = false - } - continue - } - length++ - } - return length + _, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯")) } func dfPrintPollStatus(w io.Writer, count, elapsedSec int) { - _, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec))) + _, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec))) } func dfPrintPollResult(w io.Writer, status, message string) { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 1c8c2240..f6f74419 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -345,25 +346,25 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { var typed *Error if !stderrors.As(err, &typed) { - _, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error()) + _, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error())) return writeErr } // Line 1: Error summary lines := []string{ - fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message), + fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))), } // Always shown: hint, actions, retryable if typed.Hint != "" { - lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint)) + lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint))) } // Add user-friendly hint for specific server error codes switch typed.ServerDiag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": - lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。") - lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL()) + lines = append(lines, tui.Cyan("Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")) + lines = append(lines, tui.White("Action: 开启地址: "+config.GetDeveloperSettingsURL())) } if len(typed.Actions) > 0 { @@ -371,53 +372,53 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if strings.TrimSpace(action) == "" { continue } - lines = append(lines, fmt.Sprintf("Action: %s", action)) + lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action))) } } if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" { - lines = append(lines, line) + lines = append(lines, tui.Dim(line)) } if typed.Retryable { - lines = append(lines, "Retryable: true") + lines = append(lines, tui.Warning("Retryable: true")) } // Always shown when present: Trace ID, Server Code if typed.ServerDiag.TraceID != "" { - lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)) + lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))) } if typed.ServerDiag.ServerErrorCode != "" { - lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)) + lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))) } // Verbose+: technical detail, snapshot, reason, server key if v >= VerbosityVerbose { if typed.ServerDiag.TechnicalDetail != "" { - lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)) + lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))) } if typed.Reason != "" { - lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason)) + lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason))) } if typed.ServerKey != "" { - lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey)) + lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey))) } if typed.Snapshot != "" { - lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot)) + lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot))) } if typed.Cause != nil { - lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error())) + lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error()))) } } // Debug: all internal diagnostics if v >= VerbosityDebug { if typed.Operation != "" { - lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation)) + lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation))) } if typed.RPCCode != 0 { - lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode)) + lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode))) } if len(typed.RPCData) > 0 { - lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData))) + lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))) } } diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 417af44d..6275422a 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -23,6 +23,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -453,7 +454,8 @@ func writeKeyValues(w io.Writer, payload map[string]any) error { maxWidth = 24 } for _, key := range keys { - if _, err := fmt.Fprintf(w, "%-*s %s\n", maxWidth, key, formatValue(payload[key])); err != nil { + label := tui.PadRightANSI(tui.Key(key), maxWidth+1) + if _, err := fmt.Fprintf(w, "%s %s\n", label, formatValue(payload[key])); err != nil { return err } } @@ -476,57 +478,70 @@ func writeTable(w io.Writer, headers []string, rows [][]string) error { } } for i := range widths { - if widths[i] > 60 { - widths[i] = 60 + if widths[i] > tui.MaxTableColumnWidth { + widths[i] = tui.MaxTableColumnWidth } } - for i, header := range headers { - if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { - return err - } - } - if _, err := fmt.Fprintf(w, "%-*s", widths[i], truncate(header, widths[i])); err != nil { - return err - } - } - if _, err := fmt.Fprintln(w); err != nil { - return err - } - for i, width := range widths { - if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { - return err - } - } - if _, err := io.WriteString(w, strings.Repeat("-", width)); err != nil { - return err - } - } - if _, err := fmt.Fprintln(w); err != nil { - return err + if len(widths) == 0 { + return nil } - for _, row := range rows { - for i, cell := range row { - if i >= len(widths) { - continue - } + writeRow := func(values []string, render func(string) string) error { + if _, err := io.WriteString(w, tui.Gray("│ ")); err != nil { + return err + } + for i := range widths { if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { + if _, err := io.WriteString(w, tui.Gray(" │ ")); err != nil { return err } } - if _, err := fmt.Fprintf(w, "%-*s", widths[i], truncate(cell, widths[i])); err != nil { + cell := "" + if i < len(values) { + cell = values[i] + } + cell = truncate(cell, widths[i]) + if _, err := io.WriteString(w, tui.PadRightANSI(render(cell), widths[i])); err != nil { return err } } - if _, err := fmt.Fprintln(w); err != nil { + _, err := io.WriteString(w, tui.Gray(" │\n")) + return err + } + writeDivider := func(left, mid, right string, render func(string) string) error { + if _, err := io.WriteString(w, render(left)); err != nil { + return err + } + for i, width := range widths { + if i > 0 { + if _, err := io.WriteString(w, render(mid)); err != nil { + return err + } + } + if _, err := io.WriteString(w, render(strings.Repeat("─", width+2))); err != nil { + return err + } + } + _, err := io.WriteString(w, render(right+"\n")) + return err + } + + if err := writeDivider("╭", "┬", "╮", tui.Blue); err != nil { + return err + } + if err := writeRow(headers, tui.Brand); err != nil { + return err + } + if err := writeDivider("├", "┼", "┤", tui.Gray); err != nil { + return err + } + for _, row := range rows { + if err := writeRow(row, tui.White); err != nil { return err } } - return nil + return writeDivider("╰", "┴", "╯", tui.Blue) } func sortedKeys(keys map[string]struct{}) []string { @@ -554,24 +569,27 @@ func formatValue(value any) string { } func truncate(s string, maxWidth int) string { - if runeWidth(s) <= maxWidth { + if maxWidth <= 0 || runeWidth(s) <= maxWidth { return s } - runes := []rune(s) - if len(runes) > maxWidth-1 { - return string(runes[:maxWidth-1]) + "…" + if maxWidth == 1 { + return "…" } - return s + + var b strings.Builder + used := 0 + limit := maxWidth - 1 + for _, r := range s { + width := runeWidth(string(r)) + if used+width > limit { + break + } + b.WriteRune(r) + used += width + } + return b.String() + "…" } func runeWidth(s string) int { - width := 0 - for _, r := range s { - if r >= 0x2E80 && r <= 0x9FFF { - width += 2 - continue - } - width++ - } - return width + return tui.PlainRuneWidth(s) } diff --git a/internal/output/pretty.go b/internal/output/pretty.go index e9f02b70..ff2fcd6c 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -20,7 +20,7 @@ import ( "sort" "strings" - "github.com/fatih/color" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" ) // writePretty renders the payload as ANSI-colored, human-readable text. @@ -45,26 +45,19 @@ func writePretty(w io.Writer, payload any) error { // the single-tool shape (has `tool`). Colours are on by default via fatih/color, // auto-disabled when stdout is not a TTY or when NO_COLOR is set. func writeSchemaPretty(w io.Writer, payload map[string]any) error { - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - red := color.New(color.FgRed, color.Bold).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - if tool, ok := payload["tool"].(map[string]any); ok { - return writeSchemaToolPretty(w, payload, tool, bold, cyan, green, yellow, red, dim) + return writeSchemaToolPretty(w, payload, tool) } if products, ok := payload["products"].([]any); ok { - return writeSchemaListPretty(w, payload, products, bold, cyan, dim) + return writeSchemaListPretty(w, payload, products) } if degraded, _ := payload["degraded"].(bool); degraded { reason, _ := payload["reason"].(string) hint, _ := payload["hint"].(string) - fmt.Fprintf(w, "%s discovery degraded: %s\n", red("!"), yellow(reason)) + fmt.Fprintf(w, "%s discovery degraded: %s\n", tui.StateMark("warning"), tui.Warning(reason)) if hint != "" { - fmt.Fprintf(w, " %s %s\n", dim("hint:"), hint) + fmt.Fprintf(w, " %s %s\n", tui.Key("hint"), hint) } return nil } @@ -75,13 +68,13 @@ func writeSchemaListPretty( w io.Writer, payload map[string]any, products []any, - bold, cyan, dim func(...any) string, ) error { count, _ := payload["count"].(float64) if count == 0 { count = float64(len(products)) } - fmt.Fprintf(w, "%s %s products discovered\n", bold("Catalog"), cyan(fmt.Sprintf("%d", int(count)))) + fmt.Fprintf(w, "%s\n", tui.Header("Catalog", fmt.Sprintf("%d products discovered", int(count)))) + fmt.Fprintf(w, "%s\n", tui.Rule(64)) for _, raw := range products { p, ok := raw.(map[string]any) @@ -92,11 +85,11 @@ func writeSchemaListPretty( name, _ := p["name"].(string) desc, _ := p["description"].(string) tools, _ := p["tools"].([]any) - fmt.Fprintf(w, "\n%s %s %s\n", bold("▸"), bold(id), dim(name)) + fmt.Fprintf(w, "\n%s %s %s\n", tui.StateMark("ok"), tui.Bold(id), tui.Dim(name)) if desc != "" && desc != name { - fmt.Fprintf(w, " %s\n", dim(desc)) + fmt.Fprintf(w, " %s\n", tui.Dim(desc)) } - fmt.Fprintf(w, " %s %s\n", dim("tools:"), cyan(fmt.Sprintf("%d", len(tools)))) + fmt.Fprintf(w, " %s %s\n", tui.Key("tools"), tui.Cyan(fmt.Sprintf("%d", len(tools)))) shown := 0 for _, t := range tools { tm, ok := t.(map[string]any) @@ -106,13 +99,13 @@ func writeSchemaListPretty( rpc, _ := tm["name"].(string) cli, _ := tm["cli_name"].(string) if cli == "" || cli == rpc { - fmt.Fprintf(w, " - %s\n", rpc) + fmt.Fprintf(w, " %s %s\n", tui.Bullet(), rpc) } else { - fmt.Fprintf(w, " - %s %s\n", rpc, dim("→ "+cli)) + fmt.Fprintf(w, " %s %s %s %s\n", tui.Bullet(), rpc, tui.Arrow(), tui.Dim(cli)) } shown++ if shown >= 6 && len(tools) > 8 { - fmt.Fprintf(w, " %s\n", dim(fmt.Sprintf("… %d more", len(tools)-shown))) + fmt.Fprintf(w, " %s\n", tui.Dim(fmt.Sprintf("… %d more", len(tools)-shown))) break } } @@ -124,7 +117,6 @@ func writeSchemaToolPretty( w io.Writer, payload map[string]any, tool map[string]any, - bold, cyan, green, yellow, red, dim func(...any) string, ) error { rpc, _ := tool["name"].(string) cli, _ := tool["cli_name"].(string) @@ -135,19 +127,20 @@ func writeSchemaToolPretty( header := rpc if title != "" && title != rpc { - header = fmt.Sprintf("%s %s", rpc, dim(title)) + header = fmt.Sprintf("%s %s", rpc, tui.Dim(title)) } - fmt.Fprintf(w, "%s %s\n", bold("Tool"), header) + fmt.Fprintf(w, "%s\n", tui.Header("Tool "+header, "schema")) + fmt.Fprintf(w, "%s\n", tui.Rule(72)) var productID string if product, ok := payload["product"].(map[string]any); ok { pid, _ := product["id"].(string) pname, _ := product["name"].(string) productID = pid - fmt.Fprintf(w, " %s %s %s\n", dim("product:"), pid, dim(pname)) + fmt.Fprintf(w, " %s %s %s\n", tui.Key("product"), tui.Cyan(pid), tui.Dim(pname)) } if canonical != "" { - fmt.Fprintf(w, " %s %s\n", dim("canonical:"), canonical) + fmt.Fprintf(w, " %s %s\n", tui.Key("canonical"), canonical) } if cli != "" { parts := []string{} @@ -158,26 +151,26 @@ func writeSchemaToolPretty( parts = append(parts, strings.Split(group, ".")...) } parts = append(parts, cli) - fmt.Fprintf(w, " %s %s\n", dim("cli path:"), cyan(strings.Join(parts, " "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("cli path"), tui.Cyan(strings.Join(parts, " "))) } // Sensitivity + annotations in one line. sensitive, _ := tool["sensitive"].(bool) if sensitive { - fmt.Fprintf(w, " %s %s\n", dim("sensitive:"), red("yes (needs --yes)")) + fmt.Fprintf(w, " %s %s\n", tui.Key("sensitive"), tui.Danger("yes (needs --yes)")) } if ann, ok := tool["annotations"].(map[string]any); ok && len(ann) > 0 { var parts []string for _, key := range sortedMapKeys(ann) { parts = append(parts, fmt.Sprintf("%s=%v", key, ann[key])) } - fmt.Fprintf(w, " %s %s\n", dim("annotations:"), yellow(strings.Join(parts, " "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("annotations"), tui.Warning(strings.Join(parts, " "))) } if desc != "" && desc != title { fmt.Fprintln(w) for _, line := range strings.Split(strings.TrimSpace(desc), "\n") { - fmt.Fprintf(w, " %s\n", dim(line)) + fmt.Fprintf(w, " %s\n", tui.Dim(line)) } } @@ -200,18 +193,17 @@ func writeSchemaToolPretty( } } fmt.Fprintln(w) - fmt.Fprintf(w, "%s\n", bold("Parameters")) + fmt.Fprintf(w, "%s\n", tui.Section("Parameters")) for _, name := range sortedMapKeys(params) { prop, _ := params[name].(map[string]any) - writeParamPretty(w, name, prop, required[name], overlay[name], - bold, cyan, green, yellow, red, dim) + writeParamPretty(w, name, prop, required[name], overlay[name]) } } // Output schema, if any. if out, ok := tool["output_schema"].(map[string]any); ok && len(out) > 0 { fmt.Fprintln(w) - fmt.Fprintf(w, "%s\n", bold("Output schema")) + fmt.Fprintf(w, "%s\n", tui.Section("Output schema")) b, _ := json.MarshalIndent(out, " ", " ") fmt.Fprintf(w, " %s\n", string(b)) } @@ -225,25 +217,24 @@ func writeParamPretty( prop map[string]any, required bool, overlay map[string]any, - bold, cyan, green, yellow, red, dim func(...any) string, ) { typeStr := describeType(prop) marker := " " if required { - marker = red("*") + marker = tui.Danger("*") } alias, _ := overlay["alias"].(string) - line := fmt.Sprintf(" %s %s", marker, bold(name)) + line := fmt.Sprintf(" %s %s", marker, tui.Bold(name)) if alias != "" && alias != name { - line += fmt.Sprintf(" %s", cyan("--"+alias)) + line += fmt.Sprintf(" %s", tui.Cyan("--"+alias)) } - line += fmt.Sprintf(" %s", dim(typeStr)) + line += fmt.Sprintf(" %s", tui.Dim(typeStr)) fmt.Fprintln(w, line) if d, _ := prop["description"].(string); d != "" { for _, ln := range strings.Split(strings.TrimSpace(d), "\n") { - fmt.Fprintf(w, " %s\n", dim(ln)) + fmt.Fprintf(w, " %s\n", tui.Dim(ln)) } } @@ -253,7 +244,7 @@ func writeParamPretty( for _, v := range enum { vals = append(vals, fmt.Sprintf("%v", v)) } - fmt.Fprintf(w, " %s %s\n", dim("enum:"), green(strings.Join(vals, ", "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("enum"), tui.Success(strings.Join(vals, ", "))) } // overlay — transform / env default / default. @@ -266,13 +257,13 @@ func writeParamPretty( } extras += "(" + strings.Join(kvs, ", ") + ")" } - fmt.Fprintf(w, " %s %s\n", dim("transform:"), yellow(extras)) + fmt.Fprintf(w, " %s %s\n", tui.Key("transform"), tui.Warning(extras)) } if env, _ := overlay["env_default"].(string); env != "" { - fmt.Fprintf(w, " %s %s\n", dim("env default:"), green("$"+env)) + fmt.Fprintf(w, " %s %s\n", tui.Key("env default"), tui.Success("$"+env)) } if def, _ := overlay["default"].(string); def != "" { - fmt.Fprintf(w, " %s %s\n", dim("default:"), green(def)) + fmt.Fprintf(w, " %s %s\n", tui.Key("default"), tui.Success(def)) } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 00000000..7348d12c --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,175 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "fmt" + "io" + "strings" + + "github.com/fatih/color" +) + +const ( + MaxTableColumnWidth = 60 + MaxPanelWidth = 96 + MinPanelWidth = 52 +) + +var ( + brandBlue = color.New(color.FgHiBlue, color.Bold).SprintFunc() + blue = color.New(color.FgBlue).SprintFunc() + cyan = color.New(color.FgCyan).SprintFunc() + white = color.New(color.FgHiWhite).SprintFunc() + gray = color.New(color.FgHiBlack).SprintFunc() + success = color.New(color.FgGreen, color.Bold).SprintFunc() + warning = color.New(color.FgYellow, color.Bold).SprintFunc() + danger = color.New(color.FgRed, color.Bold).SprintFunc() + bold = color.New(color.Bold).SprintFunc() + dim = color.New(color.Faint).SprintFunc() +) + +func Brand(v string) string { return brandBlue(v) } +func Blue(v string) string { return blue(v) } +func Cyan(v string) string { return cyan(v) } +func White(v string) string { return white(v) } +func Gray(v string) string { return gray(v) } +func Success(v string) string { return success(v) } +func Warning(v string) string { return warning(v) } +func Danger(v string) string { return danger(v) } +func Bold(v string) string { return bold(v) } +func Dim(v string) string { return dim(v) } + +func Header(title, subtitle string) string { + title = strings.TrimSpace(title) + subtitle = strings.TrimSpace(subtitle) + if subtitle == "" { + return fmt.Sprintf("%s %s", Brand("DingTalk"), White(title)) + } + return fmt.Sprintf("%s %s %s", Brand("DingTalk"), White(title), Dim("· "+subtitle)) +} + +func Section(title string) string { + return fmt.Sprintf("%s %s", Blue("▍"), Bold(title)) +} + +func Key(key string) string { + return Gray(key + ":") +} + +func Bullet() string { + return Cyan("•") +} + +func Arrow() string { + return Dim("→") +} + +func StateMark(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "ok", "success", "pass": + return Success("●") + case "warn", "warning", "retryable": + return Warning("●") + case "error", "danger", "failed", "fail": + return Danger("●") + default: + return Cyan("●") + } +} + +func Border() string { + return Blue("─") +} + +func SoftBorder() string { + return Gray("─") +} + +func Rule(width int) string { + if width <= 0 { + width = MinPanelWidth + } + if width > MaxPanelWidth { + width = MaxPanelWidth + } + return Blue(strings.Repeat("─", width)) +} + +func PlainRuneWidth(s string) int { + width := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + continue + } + if inEscape { + if r == 'm' { + inEscape = false + } + continue + } + if r >= 0x2E80 && r <= 0x9FFF { + width += 2 + continue + } + width++ + } + return width +} + +func PadRightANSI(s string, width int) string { + pad := width - PlainRuneWidth(s) + if pad <= 0 { + return s + } + return s + strings.Repeat(" ", pad) +} + +func Panel(w io.Writer, title string, lines []string) error { + width := MinPanelWidth + for _, line := range append([]string{title}, lines...) { + if lineWidth := PlainRuneWidth(line) + 4; lineWidth > width { + width = lineWidth + } + } + if width > MaxPanelWidth { + width = MaxPanelWidth + } + + top := Blue("╭" + strings.Repeat("─", width-2) + "╮") + mid := Blue("├" + strings.Repeat("─", width-2) + "┤") + bottom := Blue("╰" + strings.Repeat("─", width-2) + "╯") + if _, err := fmt.Fprintln(w, top); err != nil { + return err + } + if title != "" { + content := " " + title + if _, err := fmt.Fprintf(w, "%s%s%s\n", Blue("│"), PadRightANSI(content, width-2), Blue("│")); err != nil { + return err + } + if _, err := fmt.Fprintln(w, mid); err != nil { + return err + } + } + for _, line := range lines { + content := " " + line + if _, err := fmt.Fprintf(w, "%s%s%s\n", Blue("│"), PadRightANSI(content, width-2), Blue("│")); err != nil { + return err + } + } + _, err := fmt.Fprintln(w, bottom) + return err +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 00000000..8f58e8c1 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "testing" + +func TestPlainRuneWidthSkipsANSIAndCountsCJK(t *testing.T) { + got := PlainRuneWidth("\x1b[34m钉钉\x1b[0m CLI") + if got != 8 { + t.Fatalf("PlainRuneWidth() = %d, want 8", got) + } +} + +func TestPadRightANSIUsesVisibleWidth(t *testing.T) { + got := PadRightANSI("\x1b[34m钉钉\x1b[0m", 6) + if PlainRuneWidth(got) != 6 { + t.Fatalf("visible width = %d, want 6", PlainRuneWidth(got)) + } +} From 00bef0a8091c90cfb289e52818508abb743ba532 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Wed, 24 Jun 2026 21:53:18 +0800 Subject: [PATCH 59/61] fix(auth): skip login scope tui when grants are complete --- internal/app/auth_command.go | 19 ++++++- internal/app/auth_command_test.go | 58 +++++++++++++++++++++ internal/pat/chmod.go | 83 +++++++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 6b51173a..8f3382a5 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -168,14 +168,24 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return nil } recommendScopeMode := pat.LoginRecommendScopeRecommended + var initialPlan *pat.LoginRecommendPlan if postLoginTUIMode { + var planErr error + initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller) + if planErr != nil { + return planErr + } + if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) { + fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项") + return nil + } var err error recommendScopeMode, err = loginRecommendScopeModeSelector() if err != nil { return err } } - opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode} + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan} if postLoginTUIMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return loginRecommendProductSelector(products) @@ -881,6 +891,13 @@ func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, for return authLoginAllowsInteractiveDefault(cmd, format) } +func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool { + if plan == nil { + return false + } + return plan.AllGranted || (len(plan.Scopes) == 0 && len(plan.Products) == 0) +} + func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool { if cmd == nil || cmd.Root() == nil { return false diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 55ae530b..b7c6edf3 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -356,6 +356,64 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { } } +func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("default auth login must not call the operation guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default auth login must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + t.Fatal("all-granted recommend plan must not call the scope-mode TUI") + return "", nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("all-granted recommend plan must not call the product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) + } + if len(fake.tools) != 1 { + t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" { + t.Fatalf("tool sequence = %v, want only plan", fake.tools) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want all-granted message", out.String()) + } +} + func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index bf9a8d2f..d512edf0 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -45,6 +45,9 @@ type LoginRecommendOptions struct { // list before the final plan. It receives products extracted from the // initial recommend plan and must return the product codes to grant. ProductSelector func([]LoginRecommendProduct) ([]string, error) + // InitialPlan lets callers preflight the default recommend plan before any + // interactive selector and then reuse the same dry-run result. + InitialPlan *LoginRecommendPlan } type LoginRecommendScopeMode string @@ -62,6 +65,13 @@ type LoginRecommendProduct struct { SelectedScopeCount int } +type LoginRecommendPlan struct { + Result *edition.ToolResult + Scopes []string + Products []LoginRecommendProduct + AllGranted bool +} + const ( sessionIDEnvDingtalk = "DINGTALK_SESSION_ID" sessionIDEnvDWS = "DWS_SESSION_ID" @@ -582,6 +592,26 @@ func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, o return RunLoginRecommendAuthorizationWithOptions(ctx, c, output, LoginRecommendOptions{}) } +func PlanLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller) (*LoginRecommendPlan, error) { + if c == nil { + return nil, fmt.Errorf("internal error: tool runtime not initialized") + } + planResult, scopes, allGranted, err := planLoginRecommend(ctx, c, nil, true) + if err != nil { + return nil, err + } + products, err := extractLoginRecommendProducts(planResult) + if err != nil { + return nil, err + } + return &LoginRecommendPlan{ + Result: planResult, + Scopes: scopes, + Products: products, + AllGranted: allGranted, + }, nil +} + func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.ToolCaller, output io.Writer, opts LoginRecommendOptions) error { if c == nil { return fmt.Errorf("internal error: tool runtime not initialized") @@ -596,9 +626,19 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To // only after the user selects concrete productCodes. initialRecommendPlan = true } - planResult, scopes, err := planLoginRecommend(ctx, c, productCodes, initialRecommendPlan) - if err != nil { - return err + var planResult *edition.ToolResult + var scopes []string + var allGranted bool + if opts.InitialPlan != nil && initialRecommendPlan && len(productCodes) == 0 { + planResult = opts.InitialPlan.Result + scopes = append([]string(nil), opts.InitialPlan.Scopes...) + allGranted = opts.InitialPlan.AllGranted + } else { + var err error + planResult, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, initialRecommendPlan) + if err != nil { + return err + } } if opts.ProductSelector != nil { products, err := extractLoginRecommendProducts(planResult) @@ -614,7 +654,7 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To if len(productCodes) == 0 { return fmt.Errorf("至少选择一个授权业务域") } - _, scopes, err = planLoginRecommend(ctx, c, productCodes, recommendPlan) + _, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, recommendPlan) if err != nil { return err } @@ -623,7 +663,7 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To return fmt.Errorf("全部授权需要服务端返回可选授权业务域") } } - if len(scopes) == 0 { + if allGranted || len(scopes) == 0 { if output != nil { _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") } @@ -646,25 +686,29 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To return handleToolResultForWriter(output, result, c) } -func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, error) { +func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, bool, error) { productCodes = normalizeProductCodes(productCodes) if !recommend && len(productCodes) == 0 { - return nil, nil, fmt.Errorf("全部授权需要至少一个授权业务域") + return nil, nil, false, fmt.Errorf("全部授权需要至少一个授权业务域") } planArgs := buildBatchPlanArgs(nil, productCodes, recommend, grantTypePermanent, "", "", true) planArgs["caller"] = patCallerAuthLoginRecommend planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) if err != nil { - return nil, nil, fmt.Errorf("pat login recommend plan failed: %w", err) + return nil, nil, false, fmt.Errorf("pat login recommend plan failed: %w", err) } if err := classifyToolResultText(planResult); err != nil { - return planResult, nil, err + return planResult, nil, false, err } scopes, err := extractSelectedScopesAllowEmpty(planResult) if err != nil { - return planResult, nil, err + return planResult, nil, false, err } - return planResult, scopes, nil + allGranted, err := extractBatchPlanAllGranted(planResult) + if err != nil { + return planResult, nil, false, err + } + return planResult, scopes, allGranted, nil } func newBatchClientRequestID(prefix string) string { @@ -816,6 +860,23 @@ func productCodeFromScope(scope string) string { return strings.TrimSpace(scope[:end]) } +func extractBatchPlanAllGranted(result *edition.ToolResult) (bool, error) { + text := firstToolResultText(result) + if text == "" { + return false, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return false, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return false, fmt.Errorf("PAT batch plan result missing data") + } + allGranted, _ := data["allGranted"].(bool) + return allGranted, nil +} + func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { text := firstToolResultText(result) if text == "" { From 00e5ce73679c19647a09bdaddd7d2bfcb805c6bb Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 25 Jun 2026 11:23:08 +0800 Subject: [PATCH 60/61] fix(auth): skip recommend tui when no scopes remain --- internal/app/auth_command.go | 2 +- internal/app/auth_command_test.go | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 31bdc010..5923b64d 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -896,7 +896,7 @@ func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommen if plan == nil { return false } - return plan.AllGranted || (len(plan.Scopes) == 0 && len(plan.Products) == 0) + return plan.AllGranted || len(plan.Scopes) == 0 } func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 4caf8a66..2844ab70 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -414,7 +414,7 @@ func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) { } } -func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsAuthorizationPage(t *testing.T) { +func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) @@ -440,10 +440,9 @@ func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsAuthorizationPage( t.Fatal("default auth login must not apply a post-login guide action") return nil } - var scopeSelectorCalled bool loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { - scopeSelectorCalled = true - return pat.LoginRecommendScopeRecommended, nil + t.Fatal("already-granted recommended auth must not call the scope-mode TUI") + return "", nil } loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { t.Fatal("already-granted recommended auth must not call product-domain TUI") @@ -462,9 +461,6 @@ func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsAuthorizationPage( if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } - if !scopeSelectorCalled { - t.Fatal("scope-mode TUI was not called") - } if len(fake.tools) != 1 { t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools)) } From 30a024e61580c4f5a662177a225232ec33336b2e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 25 Jun 2026 12:16:31 +0800 Subject: [PATCH 61/61] fix(auth): keep chmod batch grants gated --- internal/pat/chmod.go | 116 ++++++++++++++++--------------------- internal/pat/chmod_test.go | 81 ++++++++------------------ internal/tui/theme.go | 28 ++++++++- internal/tui/theme_test.go | 7 +++ 4 files changed, 106 insertions(+), 126 deletions(-) diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index ec7e01a2..584e8dd4 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -197,13 +197,12 @@ const ( // expose only the legacy Chinese display name. patGrantToolNameLegacyAlias = "个人授权" - patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" - patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" - patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" - patForgedIdentityCodeLower = "pat_forged_identity_field" - grantTypePermanent = "permanent" - patCallerAuthLoginRecommend = "auth_login_recommend" - patCallerBatchChmodInteractive = "batch_chmod_interactive" + patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" + patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" + grantTypePermanent = "permanent" + patCallerAuthLoginRecommend = "auth_login_recommend" ) var patBatchMetadataContractCodes = map[string]bool{ @@ -257,10 +256,9 @@ grantType 规则: --domains / --domain 按产品域批量展开 scope 模板, --recommend 使用服务端推荐 scope 集合。 使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认 - selected / skipped / pending,再对 selected scopes 执行 batch grant。 - --dry-run 只返回授权计划,不写入授权。未加 --yes 时会打开授权确认页; - 传 --yes 时才直接写入服务端选中的权限。批量授权未显式指定 - --grant-type 且没有 session-id 时,会以 permanent 作为本次页面确认后的授权时长。 + selected / skipped / pending,再对 selected scopes 执行 batch grant; + --dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式 + 添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。 agentCode 配置: 可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE @@ -274,10 +272,11 @@ agentCode 配置: }, 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`, + 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) @@ -286,16 +285,12 @@ agentCode 配置: } scopes := args productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) - usesPlan := recommend || len(productCodes) > 0 || len(scopes) > 1 + usesPlan := recommend || len(productCodes) > 0 grantType, _ := cmd.Flags().GetString("grant-type") sessionID, _ := cmd.Flags().GetString("session-id") if sessionID == "" { sessionID = resolveSessionIDFromEnv() } - interactiveBatchFlow := usesPlan && !batchAuthorizationConfirmed(cmd) - if interactiveBatchFlow && grantType == "session" && sessionID == "" && !cmd.Flags().Changed("grant-type") { - grantType = grantTypePermanent - } if !validGrantTypes[grantType] { return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType) @@ -337,9 +332,6 @@ agentCode 配置: if usesPlan { planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true) - if interactiveBatchFlow { - planArgs["caller"] = patCallerBatchChmodInteractive - } planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) @@ -354,6 +346,11 @@ agentCode 配置: 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, @@ -371,12 +368,6 @@ agentCode 配置: batchArgs["sessionId"] = sessionID toolArgs["sessionId"] = sessionID } - if interactiveBatchFlow { - batchArgs["startFlow"] = true - batchArgs["noWait"] = true - batchArgs["caller"] = patCallerBatchChmodInteractive - batchArgs["clientRequestId"] = newBatchClientRequestID("chmod") - } // Legacy server schema accepted singular "scope"; clone the // canonical argv and rename the key so the two payloads stay // in lock-step on every other field. @@ -390,27 +381,15 @@ agentCode 配置: } ctx := context.Background() - var result *edition.ToolResult - if interactiveBatchFlow { - result, err = callPATBatchToolWithIdentityFallback( - ctx, - c, - agentCode, - sessionID, - patBatchGrantToolName, - batchArgs, - ) - } else { - result, err = callPATBatchGrantWithLegacyFallback( - ctx, - c, - agentCode, - sessionID, - batchArgs, - toolArgs, - legacyToolArgs, - ) - } + result, err := callPATBatchGrantWithLegacyFallback( + ctx, + c, + agentCode, + sessionID, + batchArgs, + toolArgs, + legacyToolArgs, + ) if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } @@ -426,29 +405,32 @@ agentCode 配置: "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 集合批量授权") + 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 } -// 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 +func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error { + if !usesPlan && len(scopes) <= 1 { + return nil } - if yes, err := cmd.Flags().GetBool("yes"); err == nil { - return yes + if commandBoolFlag(cmd, "yes") { + return nil } - if yes, err := cmd.InheritedFlags().GetBool("yes"); err == nil { - return yes - } - return false + 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 { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 1d3e8625..f1c307ca 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -370,10 +370,9 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) { "一次传多个 scope", "batch plan", "--dry-run 只返回授权计划", - "未加 --yes 时会打开授权确认页", - "传 --yes 时才直接写入", + "执行批量授权必须显式", "由服务端默认兜底", - "aitable.record:read aitable.record:write --grant-type permanent", + "aitable.record:read aitable.record:write --grant-type permanent --yes", "dws pat chmod --products calendar,aitable", "dws pat chmod --recommend --grant-type session", } { @@ -430,79 +429,45 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } -func TestChmod_productsFlagWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { +func TestChmod_productsFlagWithoutYesBlocksAfterPlan(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, - `{"success":true,"data":{"pendingScopes":[{"scope":"calendar.event:read"}]}}`, }} 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) + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("chmod RunE error = nil, want batch --yes blocker") } - if len(fake.calls) != 2 { - t.Fatalf("CallTool count = %d, want plan + batch flow", len(fake.calls)) + 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) - } - if got := fake.calls[0].args["caller"]; got != patCallerBatchChmodInteractive { - t.Fatalf("plan caller = %#v, want %q", got, patCallerBatchChmodInteractive) - } - if fake.calls[1].tool != patBatchGrantToolName { - t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) - } - if got := fake.calls[1].args["startFlow"]; got != true { - t.Fatalf("grant startFlow = %#v, want true", got) - } - if got := fake.calls[1].args["noWait"]; got != true { - t.Fatalf("grant noWait = %#v, want true", got) - } - if got := fake.calls[1].args["caller"]; got != patCallerBatchChmodInteractive { - t.Fatalf("grant caller = %#v, want %q", got, patCallerBatchChmodInteractive) - } - if got := fake.calls[1].args["grantType"]; got != grantTypePermanent { - t.Fatalf("grantType = %#v, want %q for default interactive flow", got, grantTypePermanent) - } - if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) { - t.Fatalf("grant scopes = %#v, want selected scope", got) - } - if got, ok := fake.calls[1].args["clientRequestId"].(string); !ok || strings.TrimSpace(got) == "" { - t.Fatalf("grant clientRequestId = %#v, want non-empty string", got) + t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) } } -func TestChmod_multipleScopesWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { +func TestChmod_multipleScopesWithoutYesBlocksBeforeMCP(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") - fake := &sequenceToolCaller{responses: []string{ - `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:write"]}}`, - `{"success":true,"data":{"pendingScopes":[{"scope":"calendar.event:read"},{"scope":"aitable.record:write"}]}}`, - }} + fake := &sequenceToolCaller{} cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "permanent") - if err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"}); err != nil { - t.Fatalf("chmod RunE error = %v", err) + err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want batch --yes blocker") } - if len(fake.calls) != 2 { - t.Fatalf("CallTool count = %d, want plan + batch flow", len(fake.calls)) + 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.calls[0].tool != patBatchPlanToolName { - t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) - } - if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { - t.Fatalf("plan scopes = %#v, want explicit scopes", 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["startFlow"]; got != true { - t.Fatalf("grant startFlow = %#v, want true", got) - } - if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { - t.Fatalf("grant scopes = %#v, want selected scopes", got) + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want blocker before MCP", len(fake.calls)) } } @@ -630,7 +595,7 @@ func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) setFlags: func(cmd *cobra.Command) { _ = cmd.Flags().Set("grant-type", "once") }, - wantCallCount: 2, + wantCallCount: 1, }, { name: "product repeated", diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 7348d12c..a72ecbb2 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "strings" + "unicode" "github.com/fatih/color" ) @@ -121,7 +122,10 @@ func PlainRuneWidth(s string) int { } continue } - if r >= 0x2E80 && r <= 0x9FFF { + if isZeroWidthRune(r) { + continue + } + if isWideRune(r) { width += 2 continue } @@ -130,6 +134,28 @@ func PlainRuneWidth(s string) int { return width } +func isZeroWidthRune(r rune) bool { + return r == '\u200d' || + (r >= 0xFE00 && r <= 0xFE0F) || + (r >= 0xE0100 && r <= 0xE01EF) || + unicode.Is(unicode.Mn, r) || + unicode.Is(unicode.Me, r) +} + +func isWideRune(r rune) bool { + return (r >= 0x1100 && r <= 0x115F) || + (r >= 0x2329 && r <= 0x232A) || + (r >= 0x2E80 && r <= 0xA4CF) || + (r >= 0xAC00 && r <= 0xD7A3) || + (r >= 0xF900 && r <= 0xFAFF) || + (r >= 0xFE10 && r <= 0xFE19) || + (r >= 0xFE30 && r <= 0xFE6F) || + (r >= 0xFF00 && r <= 0xFF60) || + (r >= 0xFFE0 && r <= 0xFFE6) || + (r >= 0x1F1E6 && r <= 0x1F1FF) || + (r >= 0x1F300 && r <= 0x1FAFF) +} + func PadRightANSI(s string, width int) string { pad := width - PlainRuneWidth(s) if pad <= 0 { diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index 8f58e8c1..992f9746 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -22,6 +22,13 @@ func TestPlainRuneWidthSkipsANSIAndCountsCJK(t *testing.T) { } } +func TestPlainRuneWidthCountsFullwidthHangulAndEmoji(t *testing.T) { + got := PlainRuneWidth("A한🟦") + if got != 6 { + t.Fatalf("PlainRuneWidth() = %d, want 6", got) + } +} + func TestPadRightANSIUsesVisibleWidth(t *testing.T) { got := PadRightANSI("\x1b[34m钉钉\x1b[0m", 6) if PlainRuneWidth(got) != 6 {