diff --git a/dingtalk-workspace-cli.code-workspace b/dingtalk-workspace-cli.code-workspace new file mode 100644 index 00000000..b64fcd64 --- /dev/null +++ b/dingtalk-workspace-cli.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../dws-wukong" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/dingtalk-workspace.zip b/dingtalk-workspace.zip new file mode 100644 index 00000000..270b99c7 Binary files /dev/null and b/dingtalk-workspace.zip differ diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go new file mode 100644 index 00000000..05ac73e6 --- /dev/null +++ b/internal/app/pat_auth_retry.go @@ -0,0 +1,598 @@ +// 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 app + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "time" + + 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/fatih/color" +) + +const ( + // PatAuthRetryTimeout is the maximum time to wait for user authorization + // when a PAT scope error is detected. + PatAuthRetryTimeout = 10 * time.Minute + + // PatAuthPollInterval is how often we poll to check if the user has + // completed authorization. + PatAuthPollInterval = 5 * time.Second +) + +// PatScopeError holds information about a missing PAT scope. +type PatScopeError struct { + OriginalError string + Identity string + ErrorType string + Message string + Hint string + MissingScope string +} + +func (e *PatScopeError) Error() string { + return e.OriginalError +} + +// patScopeRegex matches PAT-protocol scope error patterns from the API. +// Only matches explicit scope-related keywords; generic "permission denied" or +// "forbidden" are intentionally excluded to avoid false positives on business +// authorization errors (e.g. mailbox access denied, 403 Forbidden). +var patScopeRegex = regexp.MustCompile(`(?i)(missing_scope|insufficient_scope|scope.*required)`) + +// scopeValueRegex extracts a scope identifier (e.g. "calendar:read", +// "mail:user_mailbox.message:send") from an error message. +// Supports multi-segment scopes with multiple colons (resource:sub:action). +var scopeValueRegex = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)`) + +// identityValueRegex extracts an identity label from an error message. +var identityValueRegex = regexp.MustCompile(`(?i)identity["\s:]+([a-zA-Z_]+)`) + +// isPatScopeError checks if an error looks like a PAT scope/permission error +// that can be resolved by re-authorizing with additional scopes. +func isPatScopeError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + + // Check for missing_scope pattern in error message or hint + if patScopeRegex.MatchString(msg) { + return true + } + + var typed *apperrors.Error + if stderrors.As(err, &typed) { + // Check message, reason, and hint for scope-related patterns + fullText := strings.ToLower(typed.Message + " " + typed.Reason + " " + typed.Hint) + if typed.Category == apperrors.CategoryAuth { + if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") || + (strings.Contains(fullText, "scope") && strings.Contains(fullText, "required")) { + return true + } + } + // Any category with scope/permission hints + if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") { + return true + } + } + + return false +} + +// extractPatScopeError parses an error to extract PAT scope details. +func extractPatScopeError(err error) *PatScopeError { + if err == nil { + return nil + } + + msg := err.Error() + scope := "" + + var typed *apperrors.Error + if stderrors.As(err, &typed) { + msg = typed.Message + if typed.Reason != "" { + msg += " (" + typed.Reason + ")" + } + } + + // Try to extract scope value (e.g. "calendar:read") from error message. + scopeMatch := scopeValueRegex.FindStringSubmatch(msg) + if len(scopeMatch) > 1 { + scope = scopeMatch[1] + } + + // Try to extract identity from error message. + identity := "user" + identityMatch := identityValueRegex.FindStringSubmatch(msg) + if len(identityMatch) > 1 { + identity = identityMatch[1] + } + + return &PatScopeError{ + OriginalError: err.Error(), + Identity: identity, + ErrorType: "missing_scope", + Message: msg, + Hint: fmt.Sprintf("run `dws auth login --scope %q` to authorize the missing scope", scope), + MissingScope: scope, + } +} + +// 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, " }\n") + fmt.Fprintf(w, "}\n") + fmt.Fprintln(w) + + // Print authorization instructions + fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权")) + fmt.Fprintln(w) + fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权")) + + if scopeErr.MissingScope != "" { + fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope))) + } else { + fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login")) + } + + fmt.Fprintln(w) + fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ")) + fmt.Fprintln(w) +} + +// PrintPatAuthJSON prints a machine-readable PAT authorization error. +func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) { + payload := map[string]any{ + "ok": false, + "identity": scopeErr.Identity, + "error": map[string]any{ + "type": scopeErr.ErrorType, + "message": scopeErr.Message, + "hint": scopeErr.Hint, + }, + } + if scopeErr.MissingScope != "" { + payload["missing_scope"] = scopeErr.MissingScope + } + + data, _ := json.MarshalIndent(payload, "", " ") + fmt.Fprintln(w, string(data)) +} + +// 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) + defer pollTicker.Stop() + 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.Fprintln(output) + + pollCount := 0 + for { + select { + case <-ctx.Done(): + fmt.Fprintf(output, "%s 操作已取消\n", red("✗")) + return false + + case <-time.After(time.Until(deadline)): + fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout) + fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ")) + return false + + case <-pollTicker.C: + pollCount++ + elapsed := time.Since(start).Truncate(time.Second) + remaining := time.Until(deadline).Truncate(time.Second) + + // Check if token is now valid + tokenData, err := authpkg.LoadTokenData(configDir) + if err == nil && tokenData != nil { + if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { + fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", + green("✓"), bold("授权成功!"), elapsed, remaining) + fmt.Fprintln(output) + return true + } + } + + // Show polling status + fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ", + dim("⟳"), pollCount, elapsed, remaining) + } + } +} + +// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error. +// It waits for the user to complete authorization and then retries the invocation. +func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) { + // Print the PAT error in human-readable format + PrintPatAuthError(output, scopeErr) + + // Wait for user to complete authorization + authorized := WaitForPatAuthorization(ctx, configDir, output) + if !authorized { + return executor.Result{}, apperrors.NewAuth( + "等待用户授权超时", + apperrors.WithReason("pat_auth_timeout"), + apperrors.WithHint(fmt.Sprintf("授权超时 (%s),请重新执行命令", PatAuthRetryTimeout)), + apperrors.WithActions("dws auth login"), + ) + } + + // Clear the token cache so the new token is loaded + ResetRuntimeTokenCache() + + // Retry the invocation + fmt.Fprintln(output) + fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"), + color.New(color.Bold).SprintFunc()("授权完成,正在重试...")) + fmt.Fprintln(output) + + return runner.Run(ctx, invocation) +} + +// loadMCPClientIDIfNeeded ensures we have a client ID for device flow. +// Priority: in-memory runtime value → DWS_CLIENT_ID env → MCP remote fetch. +func loadMCPClientIDIfNeeded(ctx context.Context, configDir string) string { + clientID := authpkg.ClientID() + if clientID != "" { + return clientID + } + // Fallback: read from environment variable (set by previous PAT auth or caller). + if envID := os.Getenv("DWS_CLIENT_ID"); envID != "" { + authpkg.SetClientIDFromMCP(envID) + return envID + } + // Last resort: fetch from MCP server. + mcpClientID, err := authpkg.FetchClientIDFromMCP(ctx) + if err == nil && mcpClientID != "" { + authpkg.SetClientIDFromMCP(mcpClientID) + return mcpClientID + } + return "" +} + +// ---- handlePatAuthCheck (runner.go entry point) ----------------------------- + +const ( + // patPollInterval is how often we poll the device flow status endpoint. + patPollInterval = 2 * time.Second + // patPollTimeout is the maximum time to wait for user authorization via device flow. + patPollTimeout = 10 * time.Minute +) + +// patRetryingKey is a context key to prevent recursive PAT auth checks. +// After APPROVED, the retry should not trigger another PAT flow. +type patRetryingKeyType struct{} + +var patRetryingKey = patRetryingKeyType{} + +// IsPatRetrying returns true if the current context is already in a PAT retry. +func IsPatRetrying(ctx context.Context) bool { + v, _ := ctx.Value(patRetryingKey).(bool) + return v +} + +// 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, +// polls the device flow endpoint until the user authorizes, and retries the +// original invocation on success. +func handlePatAuthCheck( + ctx context.Context, + r *runtimeRunner, + invocation executor.Invocation, + patErr *apperrors.PATError, + configDir string, + output io.Writer, +) (executor.Result, error) { + // Parse authorization details from PATError.RawJSON. + var patData struct { + Code string `json:"code"` + Data struct { + Desc string `json:"desc"` + FlowID string `json:"flowId"` + URI string `json:"uri"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { + return executor.Result{}, patErr + } + + slog.Debug("PAT auth check", + "clientId", patData.Data.ClientID, + "flowId", patData.Data.FlowID, + "hasSecret", patData.Data.ClientSecret != "", + ) + + // Inject clientId/clientSecret from PAT response as runtime credentials + // so that subsequent device flow auth uses the server-assigned app identity. + if patData.Data.ClientID != "" { + if patData.Data.ClientSecret != "" { + // When both clientId and clientSecret are provided, use direct mode + // (DingTalk API) rather than MCP proxy — the MCP proxy does not hold + // the secret for this particular app. + authpkg.SetClientID(patData.Data.ClientID) + authpkg.SetClientSecret(patData.Data.ClientSecret) + } else { + // No clientSecret — rely on MCP proxy to manage the secret server-side. + authpkg.SetClientIDFromMCP(patData.Data.ClientID) + } + + // Persist clientId (and optionally secret) to ~/.dws/app.json so that + // future process invocations can load it at startup and populate + // DWS_CLIENT_ID env before the first MCP request. + appCfg := &authpkg.AppConfig{ + ClientID: patData.Data.ClientID, + } + if patData.Data.ClientSecret != "" { + appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret) + } + if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil { + slog.Warn("failed to persist app config from PAT", "error", err) + fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err) + } + } + + 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 授权")) + if patData.Data.Desc != "" { + fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc) + } + if patData.Data.URI != "" { + fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI)) + // Best-effort browser open. + _ = tryOpenBrowser(patData.Data.URI) + } + + // If no flowId, we can't poll — fall back to returning PATError for host-app. + if patData.Data.FlowID == "" { + fmt.Fprintln(output) + return executor.Result{}, patErr + } + + // 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.Fprintln(output) + + pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout) + defer cancel() + + status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output) + if err != nil { + fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err) + return executor.Result{}, patErr + } + + switch status { + case authpkg.StatusApproved: + fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!")) + fmt.Fprintln(output) + + // Exchange authCode for a fresh access token (mirrors device_flow loginOnce). + if authCode != "" { + slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true) + 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) + } 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) + } else { + slog.Debug("PAT retry: token refreshed and saved") + } + } + } + + // Clear token cache so the new credentials take effect. + ResetRuntimeTokenCache() + + // 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") + 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.Fprintln(output) + slog.Debug("PAT retry: identity env check", + "DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"), + ) + retryCtx := context.WithValue(ctx, patRetryingKey, true) + return r.Run(retryCtx, invocation) + + case authpkg.StatusRejected: + fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权")) + return executor.Result{}, apperrors.NewAuth( + "用户已拒绝授权", + apperrors.WithReason("pat_auth_rejected"), + apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"), + ) + + case authpkg.StatusExpired: + fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时")) + return executor.Result{}, apperrors.NewAuth( + "授权超时", + apperrors.WithReason("pat_auth_expired"), + apperrors.WithHint("授权链接已过期,请重新执行命令。"), + ) + + case authpkg.StatusCancelled: + fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消")) + return executor.Result{}, apperrors.NewAuth( + "操作已取消", + apperrors.WithReason("pat_auth_cancelled"), + apperrors.WithHint("用户取消了授权操作。"), + ) + + default: + fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status) + return executor.Result{}, patErr + } +} + +// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal +// 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) { + pollURL := fmt.Sprintf("%s%s?flowId=%s", + authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID)) + + // Load user access token for the poll request header. + var accessToken string + if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil { + accessToken = tokenData.AccessToken + } + + // Use a client that does NOT follow redirects, so we can detect SSO 302. + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + ticker := time.NewTicker(patPollInterval) + defer ticker.Stop() + + dim := color.New(color.Faint).SprintFunc() + pollCount := 0 + + for { + select { + case <-ctx.Done(): + if ctx.Err() == context.Canceled { + return authpkg.StatusCancelled, "", nil + } + return authpkg.StatusExpired, "", nil + case <-ticker.C: + pollCount++ + fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + if err != nil { + slog.Debug("PAT poll: failed to create request", "error", err) + continue + } + if accessToken != "" { + req.Header.Set("x-user-access-token", accessToken) + } + resp, err := noRedirectClient.Do(req) + if err != nil { + slog.Debug("PAT poll: request failed", "error", err) + continue // transient network error, keep polling + } + + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + // If we got a redirect (302/301), SSO gateway intercepted — skip JSON parse. + if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently { + continue + } + + var pollResp authpkg.DevicePollResponse + if err := json.Unmarshal(bodyBytes, &pollResp); err != nil { + slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes)) + continue + } + + status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success) + switch status { + case authpkg.StatusApproved: + fmt.Fprintln(output) // clear the polling line + return status, pollResp.Data.AuthCode, nil + case authpkg.StatusRejected, authpkg.StatusExpired: + fmt.Fprintln(output) // clear the polling line + return status, "", nil + case authpkg.StatusPending: + // keep polling + default: + // ParseDeviceFlowStatus normalizes empty+!success to EXPIRED, + // so this branch handles truly unknown statuses. + fmt.Fprintln(output) + return status, "", nil + } + } + } +} + +// tryOpenBrowser opens url in the default browser; errors are silently ignored. +func tryOpenBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return nil + } + return cmd.Start() +} diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go new file mode 100644 index 00000000..613c30ec --- /dev/null +++ b/internal/app/pat_auth_retry_test.go @@ -0,0 +1,606 @@ +// 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 app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + 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" +) + +func TestIsPatScopeError_MissingScope(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send") + if !isPatScopeError(err) { + t.Fatal("expected missing_scope error to be detected") + } +} + +func TestIsPatScopeError_PlainString(t *testing.T) { + t.Parallel() + err := &PatScopeError{ + OriginalError: "missing_scope: user lacks required scope", + ErrorType: "missing_scope", + Message: "user lacks required scope", + } + if !isPatScopeError(err) { + t.Fatal("expected plain string with missing_scope to be detected") + } +} + +func TestIsPatScopeError_NotScopeError(t *testing.T) { + t.Parallel() + err := apperrors.NewValidation("invalid parameter") + if isPatScopeError(err) { + t.Fatal("expected validation error NOT to be detected as scope error") + } +} + +func TestIsPatScopeError_Nil(t *testing.T) { + t.Parallel() + if isPatScopeError(nil) { + t.Fatal("nil error should not be detected as scope error") + } +} + +func TestIsPatScopeError_WithReason(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth("API error", + apperrors.WithReason("missing_scope"), + ) + if !isPatScopeError(err) { + t.Fatal("expected error with missing_scope reason to be detected") + } +} + +func TestIsPatScopeError_InsufficientScope(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth("insufficient_scope for resource", + apperrors.WithReason("insufficient_scope"), + ) + if !isPatScopeError(err) { + t.Fatal("expected insufficient_scope error to be detected") + } +} + +func TestExtractPatScopeError_MissingScope(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send") + scopeErr := extractPatScopeError(err) + if scopeErr == nil { + t.Fatal("expected non-nil PatScopeError") + } + if scopeErr.ErrorType != "missing_scope" { + t.Errorf("expected error type 'missing_scope', got %q", scopeErr.ErrorType) + } + if !strings.Contains(scopeErr.Hint, "dws auth login") { + t.Errorf("expected hint to contain 'dws auth login', got %q", scopeErr.Hint) + } +} + +func TestExtractPatScopeError_ExtractsScope(t *testing.T) { + t.Parallel() + err := &PatScopeError{ + OriginalError: "missing_scope: user needs calendar:read", + ErrorType: "missing_scope", + Message: "user needs calendar:read", + } + scopeErr := extractPatScopeError(err) + if scopeErr == nil { + t.Fatal("expected non-nil PatScopeError") + } + if scopeErr.MissingScope != "calendar:read" { + t.Errorf("expected MissingScope 'calendar:read', got %q", scopeErr.MissingScope) + } +} + +func TestPrintPatAuthError_HumanReadable(t *testing.T) { + t.Parallel() + var buf strings.Builder + scopeErr := &PatScopeError{ + Identity: "user", + ErrorType: "missing_scope", + Message: "missing required scope(s): mail:user_mailbox.message:send", + Hint: "run `dws auth login --scope \"mail:user_mailbox.message:send\"` to authorize", + MissingScope: "mail:user_mailbox.message:send", + } + PrintPatAuthError(&buf, scopeErr) + + output := buf.String() + if !strings.Contains(output, "missing_scope") { + t.Errorf("expected output to contain 'missing_scope', got: %s", output) + } + if !strings.Contains(output, "dws auth login") { + t.Errorf("expected output to contain 'dws auth login', got: %s", output) + } + if !strings.Contains(output, "需要额外授权") { + t.Errorf("expected output to contain Chinese auth prompt, got: %s", output) + } +} + +func TestPrintPatAuthJSON_MachineReadable(t *testing.T) { + t.Parallel() + var buf strings.Builder + scopeErr := &PatScopeError{ + Identity: "user", + ErrorType: "missing_scope", + Message: "missing required scope(s): mail:send", + Hint: "run dws auth login --scope mail:send", + MissingScope: "mail:send", + } + PrintPatAuthJSON(&buf, scopeErr) + + output := buf.String() + if !strings.Contains(output, `"ok": false`) { + t.Errorf("expected JSON to contain ok: false, got: %s", output) + } + if !strings.Contains(output, `"missing_scope": "mail:send"`) { + t.Errorf("expected JSON to contain missing_scope, got: %s", output) + } +} + +func TestIsPatScopeError_BusinessPermissionDenied(t *testing.T) { + t.Parallel() + // Generic business "permission denied" should NOT trigger PAT re-auth. + err := apperrors.NewAuth("User has no permission to access this mailbox, permission denied") + if isPatScopeError(err) { + t.Fatal("generic 'permission denied' should not be detected as PAT scope error") + } +} + +func TestIsPatScopeError_GenericForbidden(t *testing.T) { + t.Parallel() + // HTTP 403 Forbidden should NOT trigger PAT re-auth. + err := apperrors.NewAuth("403 Forbidden") + if isPatScopeError(err) { + t.Fatal("'403 Forbidden' should not be detected as PAT scope error") + } +} + +func TestExtractPatScopeError_ComplexScope(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send") + scopeErr := extractPatScopeError(err) + if scopeErr == nil { + t.Fatal("expected non-nil PatScopeError") + } + if scopeErr.MissingScope != "mail:user_mailbox.message:send" { + t.Errorf("expected MissingScope 'mail:user_mailbox.message:send', got %q", scopeErr.MissingScope) + } +} + +func TestPatScopeError_Error(t *testing.T) { + t.Parallel() + err := &PatScopeError{ + OriginalError: "test error message", + } + if err.Error() != "test error message" { + t.Errorf("expected Error() to return OriginalError, got %q", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// pollPatDeviceFlow integration tests — httptest mock covering four terminal +// states: APPROVED, REJECTED, EXPIRED, CANCELLED (ctx cancel). +// --------------------------------------------------------------------------- + +// setupPollServer creates an httptest server that responds to +// /cli/oauth/device/poll?flowId= with the given status sequence. +// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that +// GetMCPBaseURL() returns the test server address. +func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) { + t.Helper() + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idx := int(callCount.Add(1)) - 1 + if idx >= len(statuses) { + idx = len(statuses) - 1 + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(statuses[idx]) + })) + + // Write mcp_url so GetMCPBaseURL picks up the test server. + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644) + t.Setenv("DWS_CONFIG_DIR", tmpDir) + + return server, tmpDir +} + +func TestPollPatDeviceFlow_Approved(t *testing.T) { + server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{ + {Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}}, + {Success: true, Data: authpkg.DevicePollData{Status: "APPROVED", AuthCode: "code123"}}, + }) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var buf bytes.Buffer + status, authCode, err := pollPatDeviceFlow(ctx, "flow-1", configDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "APPROVED" { + t.Errorf("expected APPROVED, got %q", status) + } + if authCode != "code123" { + t.Errorf("expected authCode 'code123', got %q", authCode) + } +} + +func TestPollPatDeviceFlow_Rejected(t *testing.T) { + server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{ + {Success: false, Data: authpkg.DevicePollData{Status: "REJECTED"}}, + }) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var buf bytes.Buffer + status, authCode, err := pollPatDeviceFlow(ctx, "flow-2", configDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "REJECTED" { + t.Errorf("expected REJECTED, got %q", status) + } + if authCode != "" { + t.Errorf("expected empty authCode for REJECTED, got %q", authCode) + } +} + +func TestPollPatDeviceFlow_Expired(t *testing.T) { + server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{ + {Success: false, Data: authpkg.DevicePollData{Status: "EXPIRED"}}, + }) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var buf bytes.Buffer + status, authCode, err := pollPatDeviceFlow(ctx, "flow-3", configDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "EXPIRED" { + t.Errorf("expected EXPIRED, got %q", status) + } + if authCode != "" { + t.Errorf("expected empty authCode for EXPIRED, got %q", authCode) + } +} + +func TestPollPatDeviceFlow_Cancelled(t *testing.T) { + // Server always returns PENDING so context cancellation is the only exit. + server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{ + {Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}}, + }) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel immediately after first poll tick. + go func() { + time.Sleep(500 * time.Millisecond) + cancel() + }() + + var buf bytes.Buffer + status, authCode, err := pollPatDeviceFlow(ctx, "flow-4", configDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "CANCELLED" { + t.Errorf("expected CANCELLED, got %q", status) + } + if authCode != "" { + t.Errorf("expected empty authCode for CANCELLED, got %q", authCode) + } +} + +// --------------------------------------------------------------------------- +// IsPatRetrying tests +// --------------------------------------------------------------------------- + +func TestIsPatRetrying_Default(t *testing.T) { + t.Parallel() + ctx := context.Background() + if IsPatRetrying(ctx) { + t.Fatal("expected false for plain context") + } +} + +func TestIsPatRetrying_WithValue(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), patRetryingKey, true) + if !IsPatRetrying(ctx) { + t.Fatal("expected true when pat retry key is set") + } +} + +// --------------------------------------------------------------------------- +// pollPatDeviceFlow edge cases +// --------------------------------------------------------------------------- + +func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) { + // When server returns success=false with empty status, should treat as EXPIRED. + server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{ + {Success: false, Data: authpkg.DevicePollData{Status: ""}}, + }) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var buf bytes.Buffer + status, authCode, err := pollPatDeviceFlow(ctx, "flow-err", configDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "EXPIRED" { + t.Errorf("expected EXPIRED for server error fallback, got %q", status) + } + if authCode != "" { + t.Errorf("expected empty authCode for server error, got %q", authCode) + } +} + +func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) { + // When server returns 302 (SSO redirect), poll should continue until real response. + var callCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount <= 1 { + // First call: simulate SSO redirect + w.Header().Set("Location", "https://sso.example.com") + w.WriteHeader(http.StatusFound) + return + } + // Second call: return APPROVED + w.Header().Set("Content-Type", "application/json") + resp := authpkg.DevicePollResponse{ + Success: true, + Data: authpkg.DevicePollData{Status: "APPROVED"}, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644) + t.Setenv("DWS_CONFIG_DIR", tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var buf bytes.Buffer + status, _, err := pollPatDeviceFlow(ctx, "flow-redirect", tmpDir, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != "APPROVED" { + t.Errorf("expected APPROVED after redirect, got %q", status) + } +} + +// --------------------------------------------------------------------------- +// extractPatScopeError edge cases +// --------------------------------------------------------------------------- + +func TestExtractPatScopeError_Nil(t *testing.T) { + t.Parallel() + if got := extractPatScopeError(nil); got != nil { + t.Fatalf("expected nil for nil error, got %+v", got) + } +} + +func TestExtractPatScopeError_WithIdentity(t *testing.T) { + t.Parallel() + err := apperrors.NewAuth(`insufficient_scope: identity "app_user" needs calendar:write`) + scopeErr := extractPatScopeError(err) + if scopeErr == nil { + t.Fatal("expected non-nil PatScopeError") + } + if scopeErr.Identity != "app_user" { + t.Errorf("expected Identity 'app_user', got %q", scopeErr.Identity) + } + if scopeErr.MissingScope != "calendar:write" { + t.Errorf("expected MissingScope 'calendar:write', got %q", scopeErr.MissingScope) + } +} + +// --------------------------------------------------------------------------- +// handlePatAuthCheck integration tests — cover the main orchestrator with +// mock runner + httptest poll server for APPROVED, REJECTED, EmptyFlowID. +// --------------------------------------------------------------------------- + +// mockRunner is a simple executor.Runner for testing handlePatAuthCheck. +type mockRunner struct { + runFunc func(ctx context.Context, inv executor.Invocation) (executor.Result, error) +} + +func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor.Result, error) { + return m.runFunc(ctx, inv) +} + +// setupHandlePATServer creates an httptest server for handlePatAuthCheck tests. +// It responds to device poll requests with the given status after the first poll. +func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) { + t.Helper() + var pollCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/cli/oauth/device/poll") { + idx := int(pollCount.Add(1)) - 1 + var resp authpkg.DevicePollResponse + if idx == 0 { + resp = authpkg.DevicePollResponse{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}} + } else { + resp = authpkg.DevicePollResponse{ + Success: terminalStatus == "APPROVED", + Data: authpkg.DevicePollData{Status: terminalStatus, AuthCode: authCode}, + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644) + t.Setenv("DWS_CONFIG_DIR", tmpDir) + + return server, tmpDir +} + +func makePATErrorJSON(flowID, clientID string) string { + type patData struct { + Desc string `json:"desc"` + FlowID string `json:"flowId"` + URI string `json:"uri"` + ClientID string `json:"clientId"` + } + payload := struct { + Code string `json:"code"` + Data patData `json:"data"` + }{ + Code: "AGENT_CODE_NOT_EXISTS", + Data: patData{ + Desc: "test auth", + FlowID: flowID, + URI: "", // empty to avoid opening browser in test + ClientID: clientID, + }, + } + data, _ := json.Marshal(payload) + return string(data) +} + +func TestHandlePatAuthCheck_Approved(t *testing.T) { + server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code") + defer server.Close() + + var retryCalled bool + var retryHasKey bool + mock := &mockRunner{ + runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) { + retryCalled = true + retryHasKey = IsPatRetrying(ctx) + return executor.Result{Response: map[string]any{"ok": true}}, nil + }, + } + + runner := &runtimeRunner{fallback: mock} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-approved", "test-client-id")} + + ctx := context.Background() + var buf bytes.Buffer + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + CanonicalProduct: "test", + Tool: "test_tool", + }, patErr, configDir, &buf) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !retryCalled { + t.Fatal("expected mock runner to be called for retry") + } + if !retryHasKey { + t.Fatal("expected retry context to have patRetryingKey") + } + // Verify SetClientIDFromMCP was called with the PAT response clientId. + if cid := authpkg.ClientID(); cid != "test-client-id" { + t.Errorf("expected ClientID 'test-client-id', got %q", cid) + } +} + +func TestHandlePatAuthCheck_Rejected(t *testing.T) { + server, configDir := setupHandlePATServer(t, "REJECTED", "") + defer server.Close() + + mock := &mockRunner{ + runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) { + t.Fatal("runner should not be called on REJECTED") + return executor.Result{}, nil + }, + } + + runner := &runtimeRunner{fallback: mock} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-rejected", "test-client-id")} + + ctx := context.Background() + var buf bytes.Buffer + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + CanonicalProduct: "test", + Tool: "test_tool", + }, patErr, configDir, &buf) + + if err == nil { + t.Fatal("expected error for REJECTED") + } + if !strings.Contains(err.Error(), "用户已拒绝授权") { + t.Errorf("expected rejection error, got: %v", err) + } +} + +func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) { + // No poll server needed — empty flowId means no polling, return PATError directly. + tmpDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", tmpDir) + + mock := &mockRunner{ + runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) { + t.Fatal("runner should not be called when flowId is empty") + return executor.Result{}, nil + }, + } + + runner := &runtimeRunner{fallback: mock} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")} + + ctx := context.Background() + var buf bytes.Buffer + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + CanonicalProduct: "test", + Tool: "test_tool", + }, patErr, tmpDir, &buf) + + if err == nil { + t.Fatal("expected PATError when flowId is empty") + } + // Should return the original PATError. + if _, ok := err.(*apperrors.PATError); !ok { + t.Errorf("expected *PATError, got %T: %v", err, err) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index af1eb9ea..c64d97a0 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -41,6 +41,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "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/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" @@ -315,6 +316,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) addPluginCommandsSafe(root, pluginCmds) } + // PAT authorization commands (open-source core) + patCaller := newToolCallerAdapter(runner, flags) + pat.RegisterCommands(root, patCaller) + if fn := edition.Get().RegisterExtraCommands; fn != nil { caller := newToolCallerAdapter(runner, flags) fn(root, caller) diff --git a/internal/app/runner.go b/internal/app/runner.go index 6ad92fa7..e17227d1 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -94,6 +94,15 @@ const ( ) func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner { + // Ensure DWS_CLIENT_ID env is populated from persisted config before + // resolveIdentityHeaders reads it. This covers fresh-process cold starts + // where no env var has been inherited from a parent process. + if os.Getenv("DWS_CLIENT_ID") == "" { + if cid := authpkg.ClientID(); cid != "" { + _ = os.Setenv("DWS_CLIENT_ID", cid) + } + } + var httpClient *http.Client if flags != nil && flags.Timeout > 0 { httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second} @@ -298,19 +307,50 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } } } + // PAT scope error: offer human-readable output and retry after authorization + if isPatScopeError(err) { + scopeErr := extractPatScopeError(err) + captureRuntimeFailure(invocation, err, err) + return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) + } captureRuntimeFailure(invocation, err, err) return executor.Result{}, err } + // ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ---- if fn := edition.Get().ClassifyToolResult; fn != nil { if editionErr := fn(callResult.Content); editionErr != nil { + if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil { + if IsPatRetrying(ctx) { + return executor.Result{}, patCheck // already retried once, don't loop + } + return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) + } return executor.Result{}, editionErr } } + // ---- Structured PAT auth check (open-source fallback) ---- + if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil { + if IsPatRetrying(ctx) { + return executor.Result{}, patCheck // already retried once, don't loop + } + return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) + } + if callResult.IsError { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag) + + // ClassifyToolResult hook: let the overlay intercept known error + // patterns (PAT permission, gateway-auth) before generic handling. + if classify := edition.Get().ClassifyToolResult; classify != nil { + if hookErr := classify(callResult.Content); hookErr != nil { + captureRuntimeFailure(invocation, hookErr, hookErr) + return executor.Result{}, hookErr + } + } + mcpErr := apperrors.NewAPI( extractMCPErrorMessage(callResult), apperrors.WithOperation("tools/call"), @@ -319,6 +359,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."), apperrors.WithServerDiag(diag), ) + // PAT scope error in business response: offer human-readable output and retry + if isPatScopeError(mcpErr) { + scopeErr := extractPatScopeError(mcpErr) + captureRuntimeFailure(invocation, mcpErr, mcpErr) + return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) + } captureRuntimeFailure(invocation, mcpErr, mcpErr) return executor.Result{}, mcpErr } @@ -532,7 +578,7 @@ func resolveIdentityHeaders() map[string]string { headers = make(map[string]string) } - // Inject environment variable based headers for MCP gateway tracking + // Inject environment variable based headers for MCP gateway tracking. envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index 73b0e33e..420af07e 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -304,12 +304,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) { VerificationURI: "https://example.com/verify", ExpiresIn: 60, Interval: 1, + FlowID: "test-flow-id", }, "", "") - case strings.HasSuffix(r.URL.Path, DeviceTokenPath): - writeServiceResult(w, true, DeviceTokenResponse{ - AuthCode: "test-auth-code", - }, "", "") + case strings.HasSuffix(r.URL.Path, DevicePollPath): + // New terminal API: return APPROVED status + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{ + "status": "APPROVED", + "authCode": "test-auth-code", + }, + }) case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): json.NewEncoder(w).Encode(map[string]any{ @@ -330,13 +336,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) { configDir := setupMCPConfigDir(t, srv.URL) provider := &DeviceFlowProvider{ - configDir: configDir, - clientID: "test-client-id", - scope: DefaultScopes, - baseURL: srv.URL, - logger: newDeviceFlowTestLogger(), - Output: io.Discard, - httpClient: srv.Client(), + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + terminalBaseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), } _, err := provider.loginOnce(context.Background(), 1) @@ -368,12 +375,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) { VerificationURI: "https://example.com/verify", ExpiresIn: 60, Interval: 1, + FlowID: "test-flow-id", }, "", "") - case strings.HasSuffix(r.URL.Path, DeviceTokenPath): - writeServiceResult(w, true, DeviceTokenResponse{ - AuthCode: "test-auth-code", - }, "", "") + case strings.HasSuffix(r.URL.Path, DevicePollPath): + // New terminal API: return APPROVED status + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{ + "status": "APPROVED", + "authCode": "test-auth-code", + }, + }) case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): json.NewEncoder(w).Encode(map[string]any{ @@ -403,13 +416,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) { configDir := setupMCPConfigDir(t, srv.URL) provider := &DeviceFlowProvider{ - configDir: configDir, - clientID: "test-client-id", - scope: DefaultScopes, - baseURL: srv.URL, - logger: newDeviceFlowTestLogger(), - Output: io.Discard, - httpClient: srv.Client(), + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + terminalBaseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), } _, err := provider.loginOnce(context.Background(), 1) @@ -438,12 +452,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) { VerificationURI: "https://example.com/verify", ExpiresIn: 60, Interval: 1, + FlowID: "test-flow-id", }, "", "") - case strings.HasSuffix(r.URL.Path, DeviceTokenPath): - writeServiceResult(w, true, DeviceTokenResponse{ - AuthCode: "test-auth-code", - }, "", "") + case strings.HasSuffix(r.URL.Path, DevicePollPath): + // New terminal API: return APPROVED status + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{ + "status": "APPROVED", + "authCode": "test-auth-code", + }, + }) case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): json.NewEncoder(w).Encode(map[string]any{ @@ -467,13 +487,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) { configDir := setupMCPConfigDir(t, srv.URL) provider := &DeviceFlowProvider{ - configDir: configDir, - clientID: "test-client-id", - scope: DefaultScopes, - baseURL: srv.URL, - logger: newDeviceFlowTestLogger(), - Output: io.Discard, - httpClient: srv.Client(), + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + terminalBaseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), } token, err := provider.loginOnce(context.Background(), 1) diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index c1c2fc0b..c21cf5a7 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -33,32 +33,37 @@ import ( const ( // defaultPollInterval is the default seconds between device token polls. - defaultPollInterval = 5 + // The server-side Redis TTL is 10 minutes; a 2-second interval keeps the + // user-perceived latency low while staying well within rate limits. + defaultPollInterval = 2 // maxPollInterval caps the polling interval to prevent DoS via slow_down. maxPollInterval = 30 // maxPollTotalWait caps the total wait time for device authorization. - maxPollTotalWait = 15 * time.Minute + // Aligned with the server-side Redis TTL (10 minutes). + maxPollTotalWait = 10 * time.Minute ) type DeviceFlowProvider struct { - configDir string - clientID string - scope string - baseURL string - logger *slog.Logger - Output io.Writer - httpClient *http.Client + configDir string + clientID string + scope string + baseURL string + terminalBaseURL string + logger *slog.Logger + Output io.Writer + httpClient *http.Client } func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider { return &DeviceFlowProvider{ - configDir: configDir, - clientID: ClientID(), - scope: DefaultScopes, - baseURL: DefaultDeviceBaseURL, - logger: logger, - Output: os.Stderr, - httpClient: &http.Client{Timeout: 30 * time.Second}, + configDir: configDir, + clientID: ClientID(), + scope: DefaultScopes, + baseURL: DefaultDeviceBaseURL, + terminalBaseURL: GetMCPBaseURL(), + logger: logger, + Output: os.Stderr, + httpClient: &http.Client{Timeout: 30 * time.Second}, } } @@ -66,6 +71,18 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) { p.baseURL = strings.TrimRight(baseURL, "/") } +// SetTerminalBaseURL sets the terminal API base URL for device flow polling. +func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) { + p.terminalBaseURL = strings.TrimRight(baseURL, "/") +} + +// SetScope overrides the OAuth scope for the device flow. +func (p *DeviceFlowProvider) SetScope(scope string) { + if p != nil { + p.scope = scope + } +} + func (p *DeviceFlowProvider) output() io.Writer { if p != nil && p.Output != nil { return p.Output @@ -80,6 +97,7 @@ type DeviceAuthResponse struct { VerificationURIComplete string `json:"verificationUriComplete"` ExpiresIn int `json:"expiresIn"` Interval int `json:"interval"` + FlowID string `json:"flowId"` } type DeviceTokenResponse struct { @@ -88,6 +106,20 @@ type DeviceTokenResponse struct { Error string `json:"error"` } +// DevicePollResponse represents the response from the terminal API poll endpoint. +type DevicePollResponse struct { + Success bool `json:"success"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Data DevicePollData `json:"data"` +} + +type DevicePollData struct { + Status string `json:"status"` + AuthCode string `json:"authCode,omitempty"` + FlowID string `json:"flowId,omitempty"` +} + type serviceResult struct { Success bool `json:"success"` Result json.RawMessage `json:"result"` @@ -153,6 +185,11 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token if err != nil { return nil, err } + if tokenResult == nil { + // FlowID was empty — no polling happened; authorization URL was already + // printed, so the user can handle it manually. + return nil, nil + } _, _ = fmt.Fprintln(p.output(), "") dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0) @@ -227,7 +264,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。")) _, _ = fmt.Fprintln(p.output(), "") - _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings")) + _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL()) _, _ = fmt.Fprintln(p.output(), "") return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启")) } @@ -239,6 +276,13 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } + // Always persist clientId to app.json so future process startups + // can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env. + if p.clientID != "" { + _ = os.Setenv("DWS_CLIENT_ID", p.clientID) + _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + } + // Persist app credentials if using custom client credentials oauthProvider.persistAppConfigIfNeeded() @@ -307,7 +351,41 @@ func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode str return &resp, nil } +// pollDeviceStatus polls the terminal API for device authorization status. +// +// Note: The server returns success=false for REJECTED and EXPIRED terminal +// states (with a valid data.Status value). These are normal business outcomes, +// not transport errors, so we return the response to the caller and let the +// status-switch handle them. +func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) { + endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID)) + body, err := p.doGet(ctx, endpoint) + if err != nil { + return nil, err + } + + var resp DevicePollResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err) + } + // REJECTED/EXPIRED carry success=false but have a valid data.Status; + // only treat as a real server error when data.Status is empty. + if !resp.Success && resp.Data.Status == "" { + return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message) + } + return &resp, nil +} + func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) { + // No FlowID from server — cannot poll status; return immediately so the + // user can still see the authorization URL printed earlier and handle it + // manually (same pattern as pat_auth_retry.go L451). + if auth.FlowID == "" { + dfPrintDim(p.output(), i18n.T(" 服务端未返回 flowId,跳过轮询,请在浏览器中手动完成授权后重试")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, nil + } + startTime := time.Now() interval := time.Duration(auth.Interval) * time.Second deadline := time.Duration(auth.ExpiresIn) * time.Second @@ -330,7 +408,7 @@ func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *Dev elapsedSec := int(time.Since(startTime).Seconds()) dfPrintPollStatus(p.output(), pollCount, elapsedSec) - resp, err := p.pollDeviceToken(ctx, auth.DeviceCode) + pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID) if err != nil { dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试...")) if p.logger != nil { @@ -339,27 +417,20 @@ func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *Dev continue } - if resp.Error == "" { + switch pollResp.Data.Status { + case StatusApproved: dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!")) - return resp, nil - } - switch resp.Error { - case "authorization_pending": + return &DeviceTokenResponse{AuthCode: pollResp.Data.AuthCode}, nil + case StatusPending: dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权...")) - case "slow_down": - interval += 5 * time.Second - if interval > maxPollInterval*time.Second { - interval = maxPollInterval * time.Second - } - dfPrintPollResult(p.output(), "slow_down", fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), int(interval.Seconds()))) - case "access_denied": + case StatusRejected: _, _ = fmt.Fprintln(p.output(), "") return nil, errors.New(i18n.T("用户拒绝了授权请求")) - case "expired_token": + case StatusExpired: _, _ = fmt.Fprintln(p.output(), "") return nil, errors.New(i18n.T("设备授权码已过期")) default: - dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知错误: %s"), resp.Error)) + dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollResp.Data.Status)) } } } @@ -387,6 +458,29 @@ func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, para return body, nil } +// doGet performs an HTTP GET request and returns the response body. +func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200)) + } + return body, nil +} + // truncateBody returns a string of at most maxLen bytes from body, appending // "...(truncated)" when the content exceeds the limit. This prevents leaking // potentially sensitive response payloads in error messages. diff --git a/internal/auth/device_flow_status.go b/internal/auth/device_flow_status.go new file mode 100644 index 00000000..24332e57 --- /dev/null +++ b/internal/auth/device_flow_status.go @@ -0,0 +1,41 @@ +// 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 auth + +// Device flow authorization status constants. +// Shared across device_flow.go and pat_auth_retry.go to avoid maintaining +// string literals in multiple places. +const ( + StatusPending = "PENDING" + StatusApproved = "APPROVED" + StatusRejected = "REJECTED" + StatusExpired = "EXPIRED" + StatusCancelled = "CANCELLED" +) + +// ParseDeviceFlowStatus normalizes a raw status string from the device flow +// poll response into a canonical status constant. When the server returns an +// empty status with success=false, it falls back to StatusExpired (server +// error / flow not found). +func ParseDeviceFlowStatus(rawStatus string, success bool) string { + switch rawStatus { + case StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled: + return rawStatus + default: + if rawStatus == "" && !success { + return StatusExpired + } + return rawStatus + } +} diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index d56201f4..2a3c38d8 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -20,6 +20,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -89,22 +90,42 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // New terminal API uses GET method + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if !strings.Contains(r.URL.RawQuery, "flowId=") { + t.Fatal("flowId query parameter should be present") + } if calls.Add(1) <= 2 { - writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "") + // Return PENDING status + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{"status": "PENDING"}, + }) return } - writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "final-auth-code"}, "", "") + // Return APPROVED status with authCode + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{ + "status": "APPROVED", + "authCode": "final-auth-code", + }, + }) })) defer server.Close() provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) provider.Output = io.Discard - provider.SetBaseURL(server.URL) + provider.SetTerminalBaseURL(server.URL) resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{ - DeviceCode: "dc-1", - ExpiresIn: 10, - Interval: 1, + FlowID: "test-flow-id", + ExpiresIn: 10, + Interval: 1, }) if err != nil { t.Fatalf("waitForAuthorization() error = %v", err) @@ -121,21 +142,26 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "") + // New terminal API uses GET method + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{"status": "PENDING"}, + }) })) defer server.Close() provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) provider.Output = io.Discard - provider.SetBaseURL(server.URL) + provider.SetTerminalBaseURL(server.URL) ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) defer cancel() if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{ - DeviceCode: "dc-2", - ExpiresIn: 60, - Interval: 1, + FlowID: "test-flow-id-2", + ExpiresIn: 60, + Interval: 1, }); err == nil { t.Fatal("waitForAuthorization() error = nil, want context cancellation") } diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index bcc5500f..7837ab4c 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -19,6 +19,7 @@ import ( "strings" "sync" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -83,6 +84,14 @@ const ( // DeviceGrantType is the grant_type value defined by RFC 8628. DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + // Terminal API base URL for developer settings page. + DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" + // DevicePollPath is the device flow polling path (used with MCP base URL). + DevicePollPath = "/cli/oauth/device/poll" + + // DeveloperSettingsPath is the path to the organization developer settings page. + DeveloperSettingsPath = "/fe/old#/developerSettings" + LogoutURL = "https://login.dingtalk.com/oauth2/logout" LogoutContinueURL = "https://login.dingtalk.com" @@ -99,6 +108,19 @@ const ( MCPRevokeTokenPath = "/oauth2/revokeToken" ) +// 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) +func GetTerminalBaseURL() string { + return config.GetTerminalBaseURL() +} + +// GetDeveloperSettingsURL returns the full URL to the organization developer +// settings page, derived from the terminal base URL. +func GetDeveloperSettingsURL() string { + return config.GetDeveloperSettingsURL() +} + // 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) diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 3966bae2..d6df1075 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -60,6 +60,19 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa return data, nil } +// ExchangeCodeForToken exchanges an authorization code for token data using +// the currently configured client credentials. This is a convenience wrapper +// around OAuthProvider.exchangeCode for callers outside the auth package. +func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) { + p := &OAuthProvider{ + configDir: configDir, + clientID: ClientID(), + Output: io.Discard, + httpClient: oauthHTTPClient, + } + return p.exchangeCode(ctx, code) +} + // exchangeCodeViaMCP exchanges auth code for token via MCP proxy. // This is used when client secret is not available (server-side secret management). func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 34112feb..8faa65a7 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -485,6 +485,13 @@ continueLogin: return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } + // Always persist clientId to app.json so future process startups + // can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env. + if p.clientID != "" { + _ = os.Setenv("DWS_CLIENT_ID", p.clientID) + _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) + } + // Persist app credentials if using custom client credentials p.persistAppConfigIfNeeded() diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 2569ce7b..92585a24 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -20,6 +20,8 @@ import ( "fmt" "io" "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) // Category represents a stable error class with a documented exit code. @@ -276,7 +278,7 @@ func PrintJSON(w io.Writer, err error) error { switch typed.ServerDiag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。" - errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings" + errorPayload["action_url"] = config.GetDeveloperSettingsURL() } } if typed.ServerDiag.TechnicalDetail != "" { @@ -342,7 +344,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { switch typed.ServerDiag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。") - lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings") + lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL()) } if len(typed.Actions) > 0 { diff --git a/internal/errors/pat.go b/internal/errors/pat.go new file mode 100644 index 00000000..05feec27 --- /dev/null +++ b/internal/errors/pat.go @@ -0,0 +1,289 @@ +// 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 errors + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "strings" +) + +// ExitCodePermission is the process exit code for PAT authorisation failures. +const ExitCodePermission = 4 + +// PATError represents a PAT (Personal Action Token) authorization failure +// that should be passed through to stderr as raw JSON without any CLI-layer +// wrapping. The host application (e.g. RewindDesktop) parses the JSON to +// display its own authorisation UI. +type PATError struct { + RawJSON string +} + +func (e *PATError) Error() string { return e.RawJSON } + +// ExitCode returns the documented exit code for PAT permission errors (4). +func (e *PATError) ExitCode() int { return ExitCodePermission } + +// RawStderr returns the raw JSON to be written directly to stderr. +func (e *PATError) RawStderr() string { return e.RawJSON } + +// patNoPermissionCodes are PAT error codes that should be passed through +// as transparent PATError without CLI-level wrapping. +var patNoPermissionCodes = map[string]bool{ + "PAT_NO_PERMISSION": true, + "PAT_LOW_RISK_NO_PERMISSION": true, + "PAT_MEDIUM_RISK_NO_PERMISSION": true, + "PAT_HIGH_RISK_NO_PERMISSION": true, +} + +// patAuthRequiredCodes are error codes that trigger the PAT authorization +// flow (e.g. the server auto-created a CLI app and returned auth details). +var patAuthRequiredCodes = map[string]bool{ + "AGENT_CODE_NOT_EXISTS": true, +} + +// IsPATError reports whether err is a *PATError. +func IsPATError(err error) bool { + _, ok := err.(*PATError) + return ok +} + +// IsPATNoPermissionCode reports whether code is a known PAT permission error code. +func IsPATNoPermissionCode(code string) bool { + return patNoPermissionCodes[code] +} + +// ---- DWS gateway auth errors (shared between PAT & general auth) ---------- + +// dwsGatewayErrors is the set of DWS gateway-level auth error codes. +var dwsGatewayErrors = map[string]bool{ + "DWS_SERVICE_UNAUTHORIZED": true, + "DWS_AUTH_SERVICE_FAILED": true, +} + +// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody +// (supports both errorCode and error_code field names). +func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) { + for _, key := range []string{"errorCode", "error_code"} { + if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] { + return code, true + } + } + return "", false +} + +// isNotLoggedInError checks if the error body indicates missing authentication. +func isNotLoggedInError(body map[string]any) bool { + if errMsg, ok := body["error"].(string); ok { + if strings.Contains(errMsg, "Missing service_id or access_key") { + return true + } + } + return false +} + +// isBusinessError checks if a parsed JSON body represents a business-level error. +func isBusinessError(body map[string]any) bool { + if _, ok := body["error"].(string); ok { + return true + } + if v, ok := body["success"].(bool); ok && !v { + return true + } + if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") { + return true + } + return false +} + +// ---- Classification functions ----------------------------------------------- + +// ClassifyToolResultContent checks a raw MCP tool result content map for +// DWS gateway auth errors and PAT permission error codes. This is intended +// for use as the edition.Hooks.ClassifyToolResult callback so the framework's +// runner returns a typed error before its generic business-error classification. +// +// Check order: DWS gateway auth > PAT permission. +func ClassifyToolResultContent(content map[string]any) error { + if _, ok := getDWSGatewayErrorCode(content); ok { + raw, _ := json.Marshal(content) + return NewAuth(string(raw), + WithReason("gateway_auth_expired"), + WithHint(authExpiredHint()), + ) + } + for _, key := range []string{"code", "errorCode"} { + if code, ok := content[key].(string); ok && patNoPermissionCodes[code] { + return &PATError{RawJSON: cleanPATJSON(content, code)} + } + } + return nil +} + +// ClassifyMCPResponseText classifies a text response returned by an MCP tool call. +// Returns a typed error for known gateway auth failures, PAT interceptions, +// and business-level errors embedded in HTTP-200 JSON bodies. +// +// Check order: DWS gateway > PAT permission > generic business error. +func ClassifyMCPResponseText(text string) error { + var body map[string]any + if json.Unmarshal([]byte(text), &body) != nil { + return nil + } + + if _, ok := getDWSGatewayErrorCode(body); ok { + return NewAuth(text, + WithReason("gateway_auth_expired"), + WithHint(authExpiredHint()), + ) + } + + if isNotLoggedInError(body) { + return NewAuth("当前未登录", + WithReason("not_configured"), + WithHint(notLoggedInHint()), + WithActions("dws auth login"), + ) + } + + for _, key := range []string{"code", "errorCode"} { + if code, ok := body[key].(string); ok && patNoPermissionCodes[code] { + return &PATError{RawJSON: cleanPATJSON(body, code)} + } + } + + if isBusinessError(body) { + return NewAPI(text, + WithReason("business_error"), + WithHint(suggestForBusinessErrorText(body)), + ) + } + + return nil +} + +// ---- Hints ----------------------------------------------------------------- + +func authExpiredHint() string { + return "Re-authenticate: dws auth login" +} + +func notLoggedInHint() string { + return "请先登录:dws auth login" +} + +func suggestForBusinessErrorText(body map[string]any) string { + msg := "" + if v, ok := body["errorMsg"].(string); ok { + msg = v + } else if v, ok := body["message"].(string); ok { + msg = v + } else if v, ok := body["error"].(string); ok { + msg = v + } + switch { + case strings.Contains(msg, "搜索内容不能为空"): + return "请提供非空搜索关键词: dws doc search --query \"关键词\"" + case strings.Contains(msg, "User has no permission to access this email"): + return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list" + case strings.Contains(msg, "频率超限") || strings.Contains(msg, "rate limit"): + return "API rate limit exceeded, wait a moment and retry" + case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"): + return "Check input parameters. Use --help for available flags" + default: + return "MCP tool returned a business error; check parameters and refer to skill documentation." + } +} + +// ---- PAT JSON helpers ------------------------------------------------------ + +var patTopLevelStrip = map[string]bool{ + "success": true, "code": true, "errorCode": true, "error_code": true, + "message": true, "error": true, "trace_id": true, "class": true, +} + +func cleanPATJSON(body map[string]any, code string) string { + out := map[string]any{ + "success": false, + "code": code, + } + if data, ok := body["data"]; ok { + out["data"] = stripClassFields(data) + } else { + fallback := map[string]any{} + for k, v := range body { + if !patTopLevelStrip[k] { + fallback[k] = v + } + } + if len(fallback) > 0 { + out["data"] = stripClassFields(fallback) + } + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Sprintf(`{"success":false,"code":"%s"}`, code) + } + return string(b) +} + +// ---- Runner adapter functions ------------------------------------------------ +// These match the function signatures referenced by runner.go's PAT check +// framework (ClassifyPatAuthCheck / AsPatAuthCheckError). + +// ClassifyPatAuthCheck is the open-source fallback that checks a tool-call +// Content map for PAT permission codes and auth-required codes. Returns a +// non-nil *PATError when the content carries a recognised PAT/auth error. +func ClassifyPatAuthCheck(content map[string]any) *PATError { + for _, key := range []string{"code", "errorCode"} { + if code, ok := content[key].(string); ok { + if patNoPermissionCodes[code] || patAuthRequiredCodes[code] { + return &PATError{RawJSON: cleanPATJSON(content, code)} + } + } + } + return nil +} + +// AsPatAuthCheckError extracts a *PATError from an error chain. +func AsPatAuthCheckError(err error) *PATError { + var patErr *PATError + if stderrors.As(err, &patErr) { + return patErr + } + return nil +} + +func stripClassFields(v any) any { + switch val := v.(type) { + case map[string]any: + clean := make(map[string]any, len(val)) + for k, item := range val { + if k == "class" { + continue + } + clean[k] = stripClassFields(item) + } + return clean + case []any: + clean := make([]any, len(val)) + for i, item := range val { + clean[i] = stripClassFields(item) + } + return clean + default: + return v + } +} diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go new file mode 100644 index 00000000..409d040e --- /dev/null +++ b/internal/errors/pat_test.go @@ -0,0 +1,521 @@ +// 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 errors + +import ( + stderrors "errors" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// PATError basic behaviour +// --------------------------------------------------------------------------- + +func TestPATError_Implements(t *testing.T) { + t.Parallel() + raw := `{"success":false,"code":"PAT_NO_PERMISSION"}` + pe := &PATError{RawJSON: raw} + + if pe.Error() != raw { + t.Errorf("Error() = %q, want %q", pe.Error(), raw) + } + if pe.ExitCode() != ExitCodePermission { + t.Errorf("ExitCode() = %d, want %d", pe.ExitCode(), ExitCodePermission) + } + if pe.RawStderr() != raw { + t.Errorf("RawStderr() = %q, want %q", pe.RawStderr(), raw) + } +} + +func TestIsPATError_True(t *testing.T) { + t.Parallel() + err := &PATError{RawJSON: "{}"} + if !IsPATError(err) { + t.Fatal("expected IsPATError to return true for *PATError") + } +} + +func TestIsPATError_False(t *testing.T) { + t.Parallel() + err := stderrors.New("some other error") + if IsPATError(err) { + t.Fatal("expected IsPATError to return false for non-PATError") + } +} + +// --------------------------------------------------------------------------- +// IsPATNoPermissionCode +// --------------------------------------------------------------------------- + +func TestIsPATNoPermissionCode(t *testing.T) { + t.Parallel() + cases := []struct { + code string + want bool + }{ + {"PAT_NO_PERMISSION", true}, + {"PAT_LOW_RISK_NO_PERMISSION", true}, + {"PAT_MEDIUM_RISK_NO_PERMISSION", true}, + {"PAT_HIGH_RISK_NO_PERMISSION", true}, + {"AGENT_CODE_NOT_EXISTS", false}, + {"UNKNOWN_CODE", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsPATNoPermissionCode(tc.code); got != tc.want { + t.Errorf("IsPATNoPermissionCode(%q) = %v, want %v", tc.code, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// getDWSGatewayErrorCode +// --------------------------------------------------------------------------- + +func TestGetDWSGatewayErrorCode_ErrorCode(t *testing.T) { + t.Parallel() + body := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED"} + code, ok := getDWSGatewayErrorCode(body) + if !ok || code != "DWS_SERVICE_UNAUTHORIZED" { + t.Errorf("got (%q, %v), want (DWS_SERVICE_UNAUTHORIZED, true)", code, ok) + } +} + +func TestGetDWSGatewayErrorCode_ErrorCodeUnderscore(t *testing.T) { + t.Parallel() + body := map[string]any{"error_code": "DWS_AUTH_SERVICE_FAILED"} + code, ok := getDWSGatewayErrorCode(body) + if !ok || code != "DWS_AUTH_SERVICE_FAILED" { + t.Errorf("got (%q, %v), want (DWS_AUTH_SERVICE_FAILED, true)", code, ok) + } +} + +func TestGetDWSGatewayErrorCode_Unknown(t *testing.T) { + t.Parallel() + body := map[string]any{"errorCode": "SOME_OTHER_ERROR"} + _, ok := getDWSGatewayErrorCode(body) + if ok { + t.Fatal("expected ok=false for unknown error code") + } +} + +func TestGetDWSGatewayErrorCode_Empty(t *testing.T) { + t.Parallel() + body := map[string]any{} + _, ok := getDWSGatewayErrorCode(body) + if ok { + t.Fatal("expected ok=false for empty body") + } +} + +// --------------------------------------------------------------------------- +// isNotLoggedInError +// --------------------------------------------------------------------------- + +func TestIsNotLoggedInError_True(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "Missing service_id or access_key in request headers"} + if !isNotLoggedInError(body) { + t.Fatal("expected true for Missing service_id message") + } +} + +func TestIsNotLoggedInError_False(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "something else happened"} + if isNotLoggedInError(body) { + t.Fatal("expected false for unrelated error message") + } +} + +func TestIsNotLoggedInError_NoErrorField(t *testing.T) { + t.Parallel() + body := map[string]any{"message": "Missing service_id or access_key"} + if isNotLoggedInError(body) { + t.Fatal("expected false when error field is absent") + } +} + +// --------------------------------------------------------------------------- +// isBusinessError +// --------------------------------------------------------------------------- + +func TestIsBusinessError_ErrorField(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "some error message"} + if !isBusinessError(body) { + t.Fatal("expected true when 'error' field is present") + } +} + +func TestIsBusinessError_SuccessBoolFalse(t *testing.T) { + t.Parallel() + body := map[string]any{"success": false} + if !isBusinessError(body) { + t.Fatal("expected true when success=false (bool)") + } +} + +func TestIsBusinessError_SuccessStringFalse(t *testing.T) { + t.Parallel() + body := map[string]any{"success": "False"} + if !isBusinessError(body) { + t.Fatal("expected true when success=\"False\" (string)") + } +} + +func TestIsBusinessError_SuccessTrue(t *testing.T) { + t.Parallel() + body := map[string]any{"success": true, "data": "ok"} + if isBusinessError(body) { + t.Fatal("expected false when success=true") + } +} + +func TestIsBusinessError_EmptyBody(t *testing.T) { + t.Parallel() + body := map[string]any{"data": "hello"} + if isBusinessError(body) { + t.Fatal("expected false for body without error indicators") + } +} + +// --------------------------------------------------------------------------- +// ClassifyToolResultContent +// --------------------------------------------------------------------------- + +func TestClassifyToolResultContent_GatewayAuth(t *testing.T) { + t.Parallel() + content := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED", "message": "expired"} + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for gateway auth") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Category != CategoryAuth { + t.Errorf("Category = %v, want %v", typed.Category, CategoryAuth) + } + if typed.Reason != "gateway_auth_expired" { + t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) + } +} + +func TestClassifyToolResultContent_PATPermission(t *testing.T) { + t.Parallel() + content := map[string]any{ + "code": "PAT_NO_PERMISSION", + "data": map[string]any{"desc": "需要授权"}, + } + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for PAT permission") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { + t.Errorf("RawJSON should contain PAT_NO_PERMISSION, got: %s", patErr.RawJSON) + } +} + +func TestClassifyToolResultContent_NoError(t *testing.T) { + t.Parallel() + content := map[string]any{"success": true, "data": "ok"} + if err := ClassifyToolResultContent(content); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// ClassifyMCPResponseText +// --------------------------------------------------------------------------- + +func TestClassifyMCPResponseText_GatewayAuth(t *testing.T) { + t.Parallel() + text := `{"errorCode":"DWS_SERVICE_UNAUTHORIZED","message":"token expired"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "gateway_auth_expired" { + t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) + } +} + +func TestClassifyMCPResponseText_NotLoggedIn(t *testing.T) { + t.Parallel() + text := `{"error":"Missing service_id or access_key in headers"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "not_configured" { + t.Errorf("Reason = %q, want not_configured", typed.Reason) + } +} + +func TestClassifyMCPResponseText_PATPermission(t *testing.T) { + t.Parallel() + text := `{"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"desc":"high risk"}}` + 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_HIGH_RISK_NO_PERMISSION") { + t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) + } +} + +func TestClassifyMCPResponseText_BusinessError(t *testing.T) { + t.Parallel() + text := `{"success":false,"errorMsg":"搜索内容不能为空"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "business_error" { + t.Errorf("Reason = %q, want business_error", typed.Reason) + } + if !strings.Contains(typed.Hint, "搜索关键词") { + t.Errorf("Hint should contain search suggestion, got: %s", typed.Hint) + } +} + +func TestClassifyMCPResponseText_InvalidJSON(t *testing.T) { + t.Parallel() + text := "not json at all" + if err := ClassifyMCPResponseText(text); err != nil { + t.Fatalf("expected nil for invalid JSON, got %v", err) + } +} + +func TestClassifyMCPResponseText_NoError(t *testing.T) { + t.Parallel() + text := `{"success":true,"data":"hello"}` + if err := ClassifyMCPResponseText(text); err != nil { + t.Fatalf("expected nil for success response, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// ClassifyPatAuthCheck +// --------------------------------------------------------------------------- + +func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) { + t.Parallel() + content := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}} + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError") + } + if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { + t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) + } +} + +func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) { + t.Parallel() + content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}} + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError for AGENT_CODE_NOT_EXISTS") + } + if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") { + t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON) + } +} + +func TestClassifyPatAuthCheck_NoMatch(t *testing.T) { + t.Parallel() + content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"} + if patErr := ClassifyPatAuthCheck(content); patErr != nil { + t.Fatalf("expected nil, got %v", patErr) + } +} + +func TestClassifyPatAuthCheck_EmptyContent(t *testing.T) { + t.Parallel() + content := map[string]any{} + if patErr := ClassifyPatAuthCheck(content); patErr != nil { + t.Fatalf("expected nil for empty content, got %v", patErr) + } +} + +// --------------------------------------------------------------------------- +// AsPatAuthCheckError +// --------------------------------------------------------------------------- + +func TestAsPatAuthCheckError_Wrapped(t *testing.T) { + t.Parallel() + inner := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} + wrapped := stderrors.Join(stderrors.New("context"), inner) + got := AsPatAuthCheckError(wrapped) + if got == nil { + t.Fatal("expected non-nil *PATError from wrapped error") + } + if got.RawJSON != inner.RawJSON { + t.Errorf("RawJSON = %q, want %q", got.RawJSON, inner.RawJSON) + } +} + +func TestAsPatAuthCheckError_NotPAT(t *testing.T) { + t.Parallel() + err := stderrors.New("just a plain error") + if got := AsPatAuthCheckError(err); got != nil { + t.Fatalf("expected nil for non-PAT error, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// cleanPATJSON +// --------------------------------------------------------------------------- + +func TestCleanPATJSON_WithData(t *testing.T) { + t.Parallel() + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "data": map[string]any{ + "desc": "需要授权", + "flowId": "f123", + "class": "com.foo.Bar", + }, + } + result := cleanPATJSON(body, "PAT_NO_PERMISSION") + if !strings.Contains(result, "PAT_NO_PERMISSION") { + t.Errorf("expected code in output, got: %s", result) + } + if !strings.Contains(result, "flowId") { + t.Errorf("expected flowId in data, got: %s", result) + } + if strings.Contains(result, "class") { + t.Errorf("expected class field to be stripped, got: %s", result) + } +} + +func TestCleanPATJSON_WithoutData(t *testing.T) { + t.Parallel() + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "message": "no permission", + "extra": "value", + } + result := cleanPATJSON(body, "PAT_NO_PERMISSION") + if !strings.Contains(result, "extra") { + t.Errorf("expected extra field in fallback data, got: %s", result) + } + // Top-level stripped fields should not appear + if strings.Contains(result, `"message"`) { + t.Errorf("expected message to be stripped from top level, got: %s", result) + } +} + +// --------------------------------------------------------------------------- +// stripClassFields +// --------------------------------------------------------------------------- + +func TestStripClassFields_Map(t *testing.T) { + t.Parallel() + input := map[string]any{ + "name": "test", + "class": "com.foo.Bar", + "nested": map[string]any{ + "value": 42, + "class": "com.baz.Qux", + }, + } + result := stripClassFields(input).(map[string]any) + if _, ok := result["class"]; ok { + t.Error("top-level class should be removed") + } + nested := result["nested"].(map[string]any) + if _, ok := nested["class"]; ok { + t.Error("nested class should be removed") + } + if nested["value"] != 42 { + t.Errorf("nested value should be preserved, got %v", nested["value"]) + } +} + +func TestStripClassFields_Array(t *testing.T) { + t.Parallel() + input := []any{ + map[string]any{"id": 1, "class": "Foo"}, + map[string]any{"id": 2}, + } + result := stripClassFields(input).([]any) + first := result[0].(map[string]any) + if _, ok := first["class"]; ok { + t.Error("class in array element should be removed") + } + if first["id"] != 1 { + t.Error("other fields in array element should be preserved") + } +} + +func TestStripClassFields_Scalar(t *testing.T) { + t.Parallel() + if stripClassFields("hello") != "hello" { + t.Error("scalar string should pass through unchanged") + } + if stripClassFields(42) != 42 { + t.Error("scalar int should pass through unchanged") + } +} + +// --------------------------------------------------------------------------- +// suggestForBusinessErrorText +// --------------------------------------------------------------------------- + +func TestSuggestForBusinessErrorText(t *testing.T) { + t.Parallel() + cases := []struct { + body map[string]any + contains string + }{ + {map[string]any{"errorMsg": "搜索内容不能为空"}, "搜索关键词"}, + {map[string]any{"message": "User has no permission to access this email"}, "邮箱"}, + {map[string]any{"error": "频率超限"}, "rate limit"}, + {map[string]any{"errorMsg": "参数错误"}, "parameters"}, + {map[string]any{"error": "unknown"}, "business error"}, + } + for _, tc := range cases { + hint := suggestForBusinessErrorText(tc.body) + if !strings.Contains(strings.ToLower(hint), strings.ToLower(tc.contains)) { + t.Errorf("suggestForBusinessErrorText(%v) = %q, want to contain %q", tc.body, hint, tc.contains) + } + } +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index bfd36759..972d3433 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -162,7 +162,7 @@ " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".", " 组织主管理员:": " Organization super admins: ", " 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.", - " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings", + "管理员操作入口:": "Admin settings: ", "该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it", "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...", "✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index dbfe9e9b..6143a97e 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -162,7 +162,7 @@ " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。", " 组织主管理员:": " 组织主管理员:", " 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。", - " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings", + "管理员操作入口:": "管理员操作入口:", "该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启", "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...", "✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...", diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go new file mode 100644 index 00000000..09ada075 --- /dev/null +++ b/internal/pat/chmod.go @@ -0,0 +1,135 @@ +// 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 pat + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +var validGrantTypes = map[string]bool{ + "once": true, + "session": true, + "permanent": true, +} + +var chmodCmd = &cobra.Command{ + Use: "chmod ...", + Short: "授予指定权限", + Long: `授予指定 scope 的操作权限。 + +scope 格式: .: + 例: aitable.record:read chat.group:write calendar.event:read + +grantType 规则: + once 一次性,执行一次后自动失效 + session 当前会话有效(默认),需要 --session-id + permanent 永久有效`, + Args: cobra.MinimumNArgs(1), + Example: ` dws pat chmod aitable.record:read --agentCode agt-xxxx --grant-type session --session-id session-xxx + dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx + dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`, + RunE: func(cmd *cobra.Command, args []string) error { + agentCode, _ := cmd.Flags().GetString("agentCode") + if agentCode == "" { + return fmt.Errorf("flag --agentCode is required\n hint: dws pat chmod ... --agentCode ") + } + scopes := args + grantType, _ := cmd.Flags().GetString("grant-type") + sessionID, _ := cmd.Flags().GetString("session-id") + + if !validGrantTypes[grantType] { + return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType) + } + + if grantType == "session" && sessionID == "" && os.Getenv("DWS_SESSION_ID") == "" { + return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod --agentCode --grant-type session --session-id ") + } + + if caller != nil && caller.DryRun() { + bold := color.New(color.FgYellow, color.Bold) + bold.Println("[DRY-RUN] Preview only, not executed:") + fmt.Printf("%-16s%s\n", "Tool:", "个人授权") + fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) + fmt.Printf("%-16s%v\n", "Scope:", scopes) + fmt.Printf("%-16s%s\n", "GrantType:", grantType) + if sessionID != "" { + fmt.Printf("%-16s%s\n", "SessionID:", sessionID) + } + return nil + } + + if caller == nil { + return fmt.Errorf("internal error: tool runtime not initialized") + } + + toolArgs := map[string]any{ + "agentCode": agentCode, + "scope": scopes, + "grantType": grantType, + } + if sessionID == "" { + sessionID = os.Getenv("DWS_SESSION_ID") + } + if sessionID != "" { + toolArgs["sessionId"] = sessionID + } + + ctx := context.Background() + result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs) + if err != nil { + return fmt.Errorf("pat chmod failed: %w", err) + } + + return handleToolResult(result) + }, +} + +func init() { + chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)") + _ = chmodCmd.MarkFlagRequired("agentCode") + chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") + chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") +} + +// handleToolResult processes a ToolResult and writes output to stdout. +func handleToolResult(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 + } + if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil { + return respErr + } + fmt.Println(c.Text) + return nil + } + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal result: %w", err) + } + fmt.Println(string(data)) + return nil +} diff --git a/internal/pat/pat.go b/internal/pat/pat.go new file mode 100644 index 00000000..90cc4498 --- /dev/null +++ b/internal/pat/pat.go @@ -0,0 +1,42 @@ +// 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 pat implements the "dws pat" command group for PAT (Personal Action +// Token) authorization management. +package pat + +import ( + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +var caller edition.ToolCaller + +// RegisterCommands adds the pat command tree to rootCmd. +func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { + caller = c + patCmd := &cobra.Command{ + Use: "pat", + Short: "行为授权管理", + Long: `管理行为授权(PAT)。 + +命令结构: + dws pat chmod ... 授予指定权限`, + RunE: cmdutil.GroupRunE, + } + + patCmd.AddCommand(chmodCmd) + root.AddCommand(patCmd) +} diff --git a/internal/transport/client.go b/internal/transport/client.go index 994e2c27..3e683b67 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -525,6 +525,15 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) } } + // Diagnostic: log identity-related headers on first attempt. + if attempt == 0 && c.FileLogger != nil { + c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers", + slog.String("endpoint", endpoint), + slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")), + slog.Int("extra_headers_count", len(c.ExtraHeaders)), + ) + } + resp, err := c.HTTPClient.Do(req) if err != nil { lastErr = err diff --git a/pkg/config/constants.go b/pkg/config/constants.go index 64b77481..f3cdc10b 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -18,6 +18,8 @@ package config import ( "os" + "path/filepath" + "strings" "time" ) @@ -128,3 +130,48 @@ const ( // PluginHookTimeout is the default timeout for plugin hook commands. PluginHookTimeout = 30 * time.Second ) + +// ── Platform URLs ──────────────────────────────────────────────────────────── +// Shared across auth, errors, and device-flow packages. + +const ( + // DefaultTerminalBaseURL is the DingTalk developer platform base URL. + // Override at runtime via ~/.dws/terminal_url file. + DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" + + // DeveloperSettingsPath is the path to the organization developer + // settings page (CLI access management). + DeveloperSettingsPath = "/fe/old#/developerSettings" +) + +// DefaultConfigDir returns the default DWS configuration directory. +// Priority: DWS_CONFIG_DIR env var > ~/.dws +func DefaultConfigDir() string { + if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" { + return envDir + } + homeDir, err := os.UserHomeDir() + if err != nil { + return ".dws" + } + return filepath.Join(homeDir, ".dws") +} + +// 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) +func GetTerminalBaseURL() string { + terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url") + if data, err := os.ReadFile(terminalURLPath); err == nil { + if u := strings.TrimSpace(string(data)); u != "" { + return u + } + } + return DefaultTerminalBaseURL +} + +// GetDeveloperSettingsURL returns the full URL to the organization developer +// settings page, derived from the terminal base URL. +func GetDeveloperSettingsURL() string { + return GetTerminalBaseURL() + DeveloperSettingsPath +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 682d9686..5bdddae6 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -19,5 +19,12 @@ package edition func defaultHooks() *Hooks { return &Hooks{ Name: "open", + MergeHeaders: func(base map[string]string) map[string]string { + if base == nil { + base = make(map[string]string) + } + base["claw-type"] = "openClaw" + return base + }, } } diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index dc95e5fa..556105b4 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -73,20 +73,22 @@ type Hooks struct { // --- HTTP headers --- MergeHeaders func(base map[string]string) map[string]string - // --- auth hooks --- - OnAuthError func(configDir string, err error) error - TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error) + // --- auth --- + AuthClientID string // OAuth client ID for device-flow authorisation + AuthClientFromMCP bool // true → fetch client ID from MCP at runtime + OnAuthError func(configDir string, err error) error + TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error) - // --- token persistence (overlay-only) --- - // When non-nil, these override the default keychain-based token storage. - // The data parameter is JSON-serialized TokenData. - SaveToken func(configDir string, data []byte) error - LoadToken func(configDir string) ([]byte, error) - DeleteToken func(configDir string) error + // --- token persistence (overlay-managed keychain / encrypted storage) --- + SaveToken func(configDir string, data []byte) error // persist token blob + LoadToken func(configDir string) ([]byte, error) // retrieve token blob + DeleteToken func(configDir string) error // remove persisted token - // --- auth credentials (overlay-only) --- - AuthClientID string // non-empty overrides DefaultClientID - AuthClientFromMCP bool // true routes OAuth through MCP endpoints + // --- MCP result classification --- + // ClassifyToolResult inspects raw MCP tool-call content and returns a typed + // error (e.g. PATError, CLIError) when the response contains a known + // gateway-auth or PAT-permission failure. nil → no special handling. + ClassifyToolResult func(content map[string]any) error // --- product & endpoint --- StaticServers func() []ServerInfo // non-nil → skip Market discovery @@ -97,13 +99,6 @@ type Hooks struct { // global setup (OAuth flag overrides, log level, output sink). Overlays use // this for clients that bypass the MCP runner (e.g. A2A gateway). AfterPersistentPreRun func(cmd *cobra.Command, args []string) error - - // ClassifyToolResult is called before the framework's default business-error - // detection on MCP tool results. If it returns a non-nil error, that error - // is used instead of the generic CategoryAPI business error. Editions use - // this to return custom error types with specific exit codes (e.g. PAT - // authorization errors with exit code 4). - ClassifyToolResult func(content map[string]any) error } var ( diff --git a/test_parse.go b/test_parse.go deleted file mode 100644 index d06abc87..00000000 --- a/test_parse.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" -) - -type ContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -func main() { - data := []byte(`{"content":[{"type":"text","text":"{\"summary\":\"...\",\"data\":{\"tableId\":\"abc\"},\"status\":\"success\"}"}],"structuredContent":{"summary":"...","data":{"tableId":"abc"},"status":"success"},"isError":false}`) - - type rawResult struct { - Content json.RawMessage `json:"content"` - StructuredContent map[string]any `json:"structuredContent"` - IsError bool `json:"isError,omitempty"` - } - - var raw rawResult - _ = json.Unmarshal(data, &raw) - - fmt.Printf("raw.Content string: %s\n", string(raw.Content)) - - var object map[string]any - errMap := json.Unmarshal(raw.Content, &object) - fmt.Printf("errMap: %v\n", errMap) - - var blocks []ContentBlock - errBlocks := json.Unmarshal(raw.Content, &blocks) - fmt.Printf("errBlocks: %v, len(blocks): %d\n", errBlocks, len(blocks)) -} diff --git a/技术方案 (2).md b/技术方案 (2).md new file mode 100644 index 00000000..09868069 --- /dev/null +++ b/技术方案 (2).md @@ -0,0 +1,1052 @@ +# 技术方案 + +--- + +name: 开源版本授权方案 overview: 聚焦 lippi-pat-core 中自身负责的部分:用户撞墙授权链路(单 scope 的 Device Code Flow,状态存 Redis)、grantAuth 写入 agent\_pat、按 clientId 查询权限清单、按 scope 修改授权策略。组织管理员管控层、用户应用列表页、user\_app\_authorization 表均不在本方案范围。本期不做 SESSION 维度授权。 todos: + +* id: device-code-flow content: 实现单 scope Device Code Flow (创建flow/轮询状态),flow 状态存 Redis,TTL 10min 自动过期 status: completed + +* id: auth-h5-api content: 授权 H5 页面后端接口 (查询flow详情含单个scope信息+风险标签, 授权/拒绝合一接口) status: completed + +* id: grant-auth-enhance content: approve 时写入单条 agent\_pat 记录 (grant\_scope=PERMANENT) status: completed + +* id: query-app-scopes content: 实现按 clientId(agentCode) 查询权限清单接口 (scope列表按品类分组 + 风险等级 + 当前授权策略) status: completed + +* id: update-scope-policy content: 实现按 scope 修改单个授权策略(始终允许/禁止) status: completed + +* id: check-auth-adapt content: 适配 checkAuth 链路,区分"无记录"与"DENIED",scope无授权时生成 Device Code Flow status: completed + +* id: error-codes content: 扩展 PatErrorCodeEnum 和 PatAuthCheckResult.Data,新增 uri/desc 字段和 Device Code Flow 错误码 status: completed + +* id: bff-hsf-consumer content: lippi-open-app-dev HsfConfig 新增 PatAuthService 的 @HSFConsumer status: completed + +* id: bff-rpc-service content: lippi-open-app-dev 新增 DWSPatAuthRpcService Controller + routing.json 路由 status: completed isProject: false + + +--- + +# 开源版本授权方案 -- 技术方案(PAT 侧) + +## 一、职责边界 + +**本方案覆盖(PAT 侧负责):** + +* 用户撞墙授权链路(**单 scope** 的 Device Code Flow 全流程) + +* 授权 H5 页面后端接口 + +* grantAuth 授权写入(单条 `agent_pat`) + +* 按应用 clientId 查询权限清单 + +* 按 scope 修改授权策略(始终允许 / 禁止) + + +**不在本方案范围:** + +* 组织管理员管控层(org\_dws\_config / 可用人员白名单 / 渠道管控) + +* 用户应用列表页(queryAuthorizedApps) + +* `user_app_authorization` 表(由其他模块维护) + +* SESSION 维度授权(本期不做) + + +## 二、用户撞墙授权链路(单 scope Device Code Flow) + +### 2.1 产品交互 + +用户在 IDE/Agent 中执行 DWS 命令,`checkAuth` 对**当前这一个 scope** 判定无授权时,**一步到位**创建 Device Code Flow 并返回授权链接,终端直接展示(无需用户手动执行额外的 auth 命令)。 + +**授权 H5 页面:** + +* **授权确认页:** 顶部"个人机器人XX请求你的授权"(应用图标 ⇄ 钉钉图标),展示账号信息,权限信息「授权后,应用将获得以下权限(共1项)>」,显示该 scope 名称 + 风险标签(如"使用钉盘上传下载操作 -- 中风险"),底部「拒绝」/「授权」按钮 + +* **授权成功页:** 绿色勾 + "授权成功,请返回终端设备继续操作",底部「个人权限配置」按钮 + +* **拒绝结果页:** "您已拒绝本次授权请求",底部「关闭页面」按钮 + + +### 2.2 数据模型 -- Redis 存储 + +Device Code Flow 是短时状态(10 分钟过期),使用 **Redis** 存储,利用 TTL 自动过期,避免轮询打 DB。 + +**Redis Key 设计:** + +| Key | 用途 | +| --- | --- | +| `pat:device_code_flow:{flowId}` | 主 key,存 flow 完整信息 | +| `pat:device_code_flow:user_code:{userCode}` | userCode 反查 flowId | +| `pat:device_code_flow:pending:{orgId}:{uid}:{agentCode}:{scope}` | 幂等索引,同一 (orgId, uid, agentCode, scope) 复用已有 PENDING flow | + +`**pat:device_code_flow:{flowId}**` **Value(Hash):** + +| Field | 说明 | +| --- | --- | +| `orgId` | 组织 ID | +| `uid` | 用户 UID | +| `agentCode` | 应用 clientId | +| `scope` | 本次授权的 scope | +| `userCode` | 用户代码(如 JZN2-U4CJ) | +| `status` | PENDING / APPROVED / REJECTED | + +**TTL:** 10 分钟。过期即视为超时(终端轮询发现 key 不存在 -> 返回 EXPIRED)。 + +`**pat:device_code_flow:user_code:{userCode}**` **Value:** flowId 字符串,TTL 同 10 分钟。 + +### 2.3 接口设计 + +#### 内部方法:`createDeviceCodeFlow` + +`checkAuth` 在 scope 无授权时内部调用,不直接暴露给前端。 + +**入参:** `orgId`, `uid`, `agentCode`, `scope` + +**出参:** `flowId`, `userCode`, `authUrl` + +**逻辑(Lua 原子化):** + +整个创建过程通过 **一段 Lua 脚本** 原子执行,避免并发下重复创建: + +```lua +-- KEYS[1] = pending索引 key: pat:device_code_flow:pending:{orgId}:{uid}:{agentCode}:{scope} +-- KEYS[2] = 主flow key: pat:device_code_flow:{newFlowId} +-- KEYS[3] = userCode key: pat:device_code_flow:user_code:{newUserCode} +-- ARGV[1] = newFlowId, ARGV[2] = newUserCode, ARGV[3..N] = flow hash fields, ARGV[last] = TTL + +-- Step 1: 查 pending 索引 +local existingFlowId = redis.call('GET', KEYS[1]) +if existingFlowId then + -- Step 2: 校验主 flow key 是否仍然存在(防止脏索引复用已过期的 flow) + local exists = redis.call('EXISTS', 'pat:device_code_flow:' .. existingFlowId) + if exists == 1 then + -- 主 key 存在,复用:返回已有 flowId + userCode + local uc = redis.call('HGET', 'pat:device_code_flow:' .. existingFlowId, 'userCode') + return {existingFlowId, uc} + else + -- 脏索引:主 key 已过期,清掉索引后 fall through 创建新 flow + redis.call('DEL', KEYS[1]) + end +end + +-- Step 3: 创建新 flow(到达此处说明无有效 pending flow) +redis.call('HSET', KEYS[2], unpack(ARGV, 3, #ARGV - 1)) +redis.call('EXPIRE', KEYS[2], ARGV[#ARGV]) +redis.call('SET', KEYS[3], ARGV[1], 'EX', ARGV[#ARGV]) +redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[#ARGV]) +return {ARGV[1], ARGV[2]} + +``` + +**Java 调用步骤:** + +1. 生成 newFlowId (UUID) + newUserCode (随机 XXXX-XXXX,8位字母数字) + +2. 执行上述 Lua 脚本,传入 pending 索引 key、新 flow key、新 userCode key 以及 flow 字段和 TTL(600s) + +3. Lua 返回 `[flowId, userCode]`(可能是复用的旧值或新创建的值) + +4. 拼接 authUrl:`{baseUrl}/oauth/device/verify?flow_id={flowId}&user_code={userCode}` + +5. 返回 + + +> 并发安全:Lua 脚本在 Redis 中原子执行,两个并发请求不会各自查空并各自创建。脏索引安全:复用 pending 索引前必须校验主 flow key 存在,过期的脏索引会被清除后重建。 + +#### 对外接口(BFF HTTP + PAT HSF) + +BFF 应用 `lippi-open-app-dev`(域名 `pre-open-dev.dingtalk.com`)暴露 HTTP 接口,内部通过 HSF 调用 PAT core。 + +**A. Device Code Flow H5 页面(BFF 需登录态,uid 由 BFF 传入 HSF)** + +| BFF HTTP 路径 | PAT HSF 方法 | 说明 | +| --- | --- | --- | +| `GET /api/dingtalk-workspace-cli/oauth/device/detail?flowId={flowId}&userCode={userCode}` | queryFlowDetail | H5 页面加载,校验 userCode,返回 agentCode + scope + 风险标签 + status | +| `POST /api/dingtalk-workspace-cli/oauth/device/action` | handleFlowAction | 授权或拒绝,body: `{flowId, userCode, action}`,action=approve/reject | + +**B. 权限清单管理(BFF 需登录态)** + +| BFF HTTP 路径 | PAT HSF 方法 | 说明 | +| --- | --- | --- | +| `GET /api/dingtalk-workspace-cli/app-auth/scopes?agentCode={agentCode}` | queryAppScopeDetail | scope 列表(按品类分组 + 风险标签 + 授权策略) | +| `POST /api/dingtalk-workspace-cli/app-auth/scope-policy` | updateScopeGrantPolicy | 修改单个 scope 策略,body: `{agentCode, scope, grantPolicy}` | + +**C. 终端轮询(BFF 无需登录态)** + +| BFF HTTP 路径 | PAT HSF 方法 | 说明 | +| --- | --- | --- | +| `GET /api/dingtalk-workspace-cli/oauth/device/poll?flowId={flowId}` | pollDeviceCodeStatus | 返回 status(PENDING/APPROVED/REJECTED/EXPIRED) | + +#### BFF 侧对接(lippi-open-app-dev) + +1. [`**HsfConfig.java**`](lippi-open-app-dev/lippi-open-app-dev-service/src/main/java/com/dingtalk/open/appdev/hsf/consumer/HsfConfig.java) -- 新增 `@HSFConsumer` 声明 `PatAuthService`(已有接口,新增方法后 BFF 侧只需添加 consumer 引用) + +2. **新增** `**DWSPatAuthRpcService.java**` -- 在 `lippi-open-app-dev-start/.../http/` 下新增 Controller,继承 `BaseRpcService`,`@RequestMapping("/dingtalk-workspace-cli")`,注入 HSF consumer 并转发调用 + +3. * `/api/dingtalk-workspace-cli/oauth/device/detail` -> `/dingtalk-workspace-cli/oauth/device/detail` + + * `/api/dingtalk-workspace-cli/oauth/device/action` -> `/dingtalk-workspace-cli/oauth/device/action` + + * `/api/dingtalk-workspace-cli/oauth/device/poll` -> `/dingtalk-workspace-cli/oauth/device/poll` + + * `/api/dingtalk-workspace-cli/app-auth/scopes` -> `/dingtalk-workspace-cli/app-auth/scopes` + + * `/api/dingtalk-workspace-cli/app-auth/scope-policy` -> `/dingtalk-workspace-cli/app-auth/scope-policy` + + +#### 接口详情 + +##### `queryFlowDetail` + +BFF 路径:`GET /api/dingtalk-workspace-cli/oauth/device/detail?flowId={flowId}&userCode={userCode}` + +**HSF 入参:** `flowId`, `userCode`, `uid`(BFF 从登录态获取后传入) + +**出参:** + +* `agentCode` -- 应用 clientId(前端通过 agentCode 反查应用系统获取 appName、appIcon) + +* `scope`, `displayName`, `riskLevel` + +* `status` -- PENDING / APPROVED / REJECTED / EXPIRED + + +**逻辑:** + +1. Redis HGETALL `pat:device_code_flow:{flowId}` + +2. 不存在 -> 返回 EXPIRED + +3. **校验 userCode 与 flow 中存储的 userCode 一致**,不一致返回错误 + +4. **校验 uid 与 flow 中存储的 uid 一致**,不一致返回错误(防止越权读他人授权详情) + +5. 查询 `product_scope_template` 获取 displayName、riskLevel + +6. 返回 + + +##### `handleFlowAction` + +BFF 路径:`POST /api/dingtalk-workspace-cli/oauth/device/action` + +**HSF 入参:** `flowId`, `userCode`, `action`(approve / reject),`uid`(BFF 从登录态获取后传入) + +**逻辑:** + +1. Redis HGETALL `pat:device_code_flow:{flowId}`,校验存在、status=PENDING、uid 一致、**userCode 一致** + +2. action=approve: + + * **先写 DB**:写入 `agent_pat`(单条 INSERT ON DUPLICATE KEY UPDATE,grant\_scope=PERMANENT) + + * **DB 成功后更新 Redis**:Lua 原子操作 PENDING->APPROVED + + * **删除幂等索引**:DEL `pat:device_code_flow:pending:{orgId}:{uid}:{agentCode}:{scope}` + + * DB 失败则不更新 Redis,保持 PENDING,H5 提示重试 + +3. action=reject: + + * Redis Lua 原子操作:仅 PENDING->REJECTED + + * **删除幂等索引**:DEL `pat:device_code_flow:pending:{orgId}:{uid}:{agentCode}:{scope}` + + +##### `pollDeviceCodeStatus` + +BFF 路径:`GET /api/dingtalk-workspace-cli/oauth/device/poll?flowId={flowId}` + +**HSF 入参:** `flowId` + +**出参:** `status` (PENDING / APPROVED / REJECTED / EXPIRED) + +**逻辑:** + +1. Redis HGET `pat:device_code_flow:{flowId}` status + +2. key 不存在 -> EXPIRED + +3. 返回 status + + +> 轮询全程只读 Redis,不查 DB。 + +### 2.4 checkAuth 链路适配 -- 新旧分流设计 + +#### 设计原则:最大限度复用主链路,只在"推送方式"分流 + +DCF 完全走现有 `checkAuth` 主链路(scope 解析、风险评估、中敏升高敏、PERMANENT 匹配、SESSION/ONCE 处理),差异只在两点: + +1. **DENIED 判定**:主链路查询改 all-status + 加 DENIED 判定(端内未来也需要,直接在主链路加) + +2. **被拦截时的推送方式**:端内返回 `buildMediumRiskResult`/`buildHighRiskResult`(推手机),DCF 改为 `createFlowAndBuildResult`(创建 flow + 返回 uri) + + +#### 方案:主链路改造 + source 分流 + +**1.** `**PatAuthCheckRequest**` **新增** `**source**` **字段:** + +```java +public class PatAuthCheckRequest implements Serializable { + // ... existing fields ... + private String source; // null/空=端内(默认),"DEVICE_CODE_FLOW"=开源CLI +} + +``` + +**2.** `**checkAuth()**` **主入口改造(DENIED 判定 + 查询改 all-status):** + +```java +public PatAuthCheckResult checkAuth(PatAuthCheckRequest request) { + if (!resolveRequestScope(request)) { + return PatAuthCheckResult.ok(); + } + String scope = request.getScope(); + if (StringUtils.isBlank(scope)) { + return PatAuthCheckResult.ok(); + } + + // >>> 改动 1:查询改为 all-status(端内未来也需要 DENIED) <<< + AgentPatDO pat = agentPatDAO.queryByAgentCodeAndScopeAllStatus( + request.getOrgId(), request.getUid(), request.getAgentCode(), scope); + + // >>> 改动 2:DENIED 判定(端内和 DCF 统一生效) <<< + if (pat != null && pat.getStatus() == PatStatusEnum.DENIED.getCode()) { + return PatAuthCheckResult.fail( + PatErrorCodeEnum.PAT_SCOPE_DENIED.getCode(), + PatErrorCodeEnum.PAT_SCOPE_DENIED.getMessage()); + } + + // >>> 改动 3:非 ACTIVE 视为无记录(REVOKED/PENDING_APPROVAL 等同于 null) <<< + if (pat != null && pat.getStatus() != PatStatusEnum.ACTIVE.getCode()) { + pat = null; + } + + // === 以下为现有逻辑,完全不动 === + if (pat == null) { + return evaluateRiskAndBuildResult(request, scope); + } + return classifyAndHandle(request, scope, pat); +} + +``` + +**3.** `**evaluateRiskAndBuildResult()**` **内部加 source 分流(仅在构建结果时):** + +```java +private PatAuthCheckResult evaluateRiskAndBuildResult(PatAuthCheckRequest request, String scope) { + // ... 现有风险评估逻辑完全不动 ... + // LOW → ok + // MEDIUM → shouldUpgradeToHigh? → HIGH + + if (riskLevel == RiskLevelEnum.MEDIUM) { + // >>> source 分流 <<< + if ("DEVICE_CODE_FLOW".equals(request.getSource())) { + return createFlowAndBuildResult(request, scope); + } + return buildMediumRiskResult(template); // 现有行为不变 + } + + // HIGH + if (tryConsumeApprovedHighRiskRecord(request, scope)) { + return PatAuthCheckResult.ok(); // DCF 无 sessionId,此处自然返回 false,继续往下走 + } + + // >>> source 分流 <<< + if ("DEVICE_CODE_FLOW".equals(request.getSource())) { + return createFlowAndBuildResult(request, scope); + } + return buildHighRiskResult(request, scope, template); // 现有行为不变 +} + +``` + +**4.** `**tryConsumeHighRiskApproval()**` **内部加 source 分流(PERMANENT + 高敏升级场景):** + +```java +private PatAuthCheckResult tryConsumeHighRiskApproval(PatAuthCheckRequest request, + String scope, AgentPatDO pat) { + // ... 现有升级判定逻辑完全不动 ... + // isScopeUpgradable + shouldUpgradeToHigh + + if (tryConsumeApprovedHighRiskRecord(request, scope)) { + return PatAuthCheckResult.ok(); // DCF 无 sessionId,自然返回 false + } + + // >>> source 分流 <<< + if ("DEVICE_CODE_FLOW".equals(request.getSource())) { + return createFlowAndBuildResult(request, scope); + } + return evaluateAsHighRisk(request, scope); // 现有行为不变 +} + +``` + +**5. MCP Gateway 设置** `**source**`**:** + +MCP Gateway 调用 `PatAuthService.checkAuth()` 时设置 `request.setSource("DEVICE_CODE_FLOW")`。现有端内调用方不设置 source(为 null),所有 `if ("DEVICE_CODE_FLOW".equals(...))` 分支不进入。 + +#### 改动最小化对照 + +| 改动点 | 影响范围 | 说明 | +| --- | --- | --- | +| 查询改 all-status | 主链路 | `queryByAgentCodeAndScope` → `queryByAgentCodeAndScopeAllStatus`,非 ACTIVE 视为 null | +| DENIED 判定 | 主链路 | 端内和 DCF 统一生效,端内当前无 status=3 记录不受影响 | +| `evaluateRiskAndBuildResult` 加 2 个 `if` | MEDIUM/HIGH 结果构建 | DCF 走 `createFlowAndBuildResult`,端内不变 | +| `tryConsumeHighRiskApproval` 加 1 个 `if` | PERMANENT + 升级结果构建 | DCF 走 `createFlowAndBuildResult`,端内不变 | +| 新增 `source` 字段 | `PatAuthCheckRequest` | 端内不设置,默认 null | + +> **关键:scope 解析、风险评估、中敏升高敏判定、PERMANENT/SESSION/ONCE 分类处理、**`**tryConsumeApprovedHighRiskRecord**` **等全部复用,一行不改。** DCF 无 sessionId 时 `tryConsumeApprovedHighRiskRecord` 自然返回 false(已有的空值保护),无需额外处理。 + +`**PatAuthCheckResult**` **响应结构(字段放在 Data 内部类中):** + +撞墙(scope 无授权):  $\color{#0089FF}{@上官玄(玄玦)}$ + +无Agent实例 + +```json +{ + "code": "AGENT_CODE_NOT_EXISTS", + "data": { + "clientId": "123", + "clientSecret": "456", + }, + "flowId":"xxx", + "url": "https://open-dev.dingtalk.com/oauth/device/verify?flow_id=xxx&user_code=JZN2-U4CJ", + "success": false, + "message": "agentCode not exists, CLI app created", +、 } +``` + +首次使用CLI,撞墙,刷新clienId和clientSecret到环境变量,并登出登录(告知用户),并轮训flowId的状态。 + +无对应权限点 + +```json +{ + "success": false, + "code": "PAT_MEDIUM_RISK_NO_PERMISSION", + "data": { + "requiredScopes": [ + { + "productCode": "aitable", + "productName": "AI表格", + "scope": "aitable.record.read", + "displayName": "AI表格-行数据-读取" + } + ], + "grantOptions": ["permanent"], + "flowId":"xxx", + "url": "https://open-dev.dingtalk.com/oauth/device/verify?flow_id=xxx&user_code=JZN2-U4CJ", + "desc": "在浏览器中打开以下链接进行认证" + } +} + +``` + +撞墙,并轮训flowId的状态。 + +用户明确禁止的 scope + +```json +{ + "success": false, + "code": "PAT_SCOPE_DENIED", + "message": "This scope has been explicitly denied by the user." +} + +``` + +直接告诉用户你已禁止访问本权限点访问。 + +**轮询响应示例:** + +授权成功: + +```json +{ + "success": true, + "data": { "status": "APPROVED" } +} + +``` + +用户拒绝: + +```json +{ + "success": false, + "code": "DEVICE_CODE_REJECTED", + "data": { "status": "REJECTED" }, + "message": "The end user denied the authorization request." +} + +``` + +授权超时(Redis key 过期): + +```json +{ + "success": false, + "code": "DEVICE_CODE_EXPIRED", + "data": { "status": "EXPIRED" }, + "message": "Authorization timed out, please try again." +} + +``` + +`**PatAuthCheckResult.Data**` **新增字段:** + +```java +@Getter @Setter +public static class Data implements Serializable { + private List requiredScopes; + private List grantOptions; + private String authRequestId; + // Device Code Flow 新增 + private String uri; // 授权链接(含 flow_id 参数,终端直接展示+轮询解析) + private String desc; // 授权提示文案 +} + +``` + +## 三、grantAuth 授权写入 + +`handleFlowAction(action=approve)` 确认授权时,写入单条 `agent_pat` 记录: + +* **INSERT ON DUPLICATE KEY UPDATE** + +* `grant_scope` 统一为 **PERMANENT(3)**(本期不做 SESSION 维度授权) + + +> `user_app_authorization` 的维护不在本方案范围,由调用方或其他模块负责。 + +## 四、按 clientId 查询权限清单 + +### 4.1 产品交互 + +用户点击某应用"编辑"后弹出「数据权限范围管理」弹窗,scope 按品类分组,每条显示名称 + 风险标签 + 授权策略下拉框(始终允许 / 禁止)。 + +### 4.2 接口:`queryAppScopeDetail(orgId, uid, agentCode)` + +**复用** `**DWSPatAuthIServiceImpl.queryAuthList()**` **的核心逻辑。** + +#### 重构方式 -- 共享数据准备,DTO 映射隔离 + +现有 `queryAuthList()` Lines 197-256 分两层: + +* **数据准备层**(Lines 197-212):template 查询 + auth map 构建 + productCode 分组 -- **可共享** + +* **DTO 映射层**(Lines 220-252):填充 `AuthItem` 字段 -- **各自独立**,因为 LWP 的 `AuthItem` 和新 HSF 的 `ScopeGroupDTO` 字段不同 + + +**公共方法返回中间数据结构,不返回任何 DTO:** + +```java +// PatAuthManager 新增 — 返回中间数据,不绑定任何出参 DTO +public ScopeAuthContext buildScopeAuthContext(List records) { + List allTemplates = checkService.queryAllTemplates(); + Map authMap = buildScopeBestAuthMap(records); + Map> groupedTemplates = allTemplates.stream() + .collect(Collectors.groupingBy( + tmpl -> tmpl.getProductCode() != null ? tmpl.getProductCode() : "unknown", + LinkedHashMap::new, Collectors.toList())); + return new ScopeAuthContext(groupedTemplates, authMap); +} + +// 中间数据结构(内部使用,不暴露给调用方) +@Getter @AllArgsConstructor +public class ScopeAuthContext { + private Map> groupedTemplates; + private Map authMap; +} + +``` + +**两个调用方各自做 DTO 映射:** + +```java +// LWP queryAuthList — DTO 映射不变,只是数据准备改为调用公共方法 +List records = agentPatDAO.queryByOrgIdAndUid( + orgId, uid, agentCode, productCode, grantScope); // 仅 status=1 +ScopeAuthContext ctx = patAuthManager.buildScopeAuthContext(records); +// 遍历 ctx.groupedTemplates,填充 AuthItem(grantScope/grantScopeDesc/accessGrantScope) +// 逻辑与现有 Lines 220-252 完全一致 + +// HSF queryAppScopeDetail — 独立 DTO 映射,含 DENIED 分支 +List records = agentPatDAO.queryByOrgIdAndUidAllStatus( + orgId, uid, agentCode); // status=1 和 status=3 +ScopeAuthContext ctx = patAuthManager.buildScopeAuthContext(records); +// 遍历 ctx.groupedTemplates,填充 ScopeGroupDTO(grantPolicy: ALWAYS_ALLOW/DENIED/默认值) +// 新增 DENIED 分支:pat.status=3 -> grantPolicy=DENIED + +``` + +#### DTO 隔离保证 + +| 维度 | 现有 `queryAuthList`(LWP) | 新 `queryAppScopeDetail`(HSF) | +| --- | --- | --- | +| 调用入口 | LWP H5 → DWSPatAuthIServiceImpl | BFF HTTP → PatAuthService HSF | +| DAO 查询 | `queryByOrgIdAndUid`(仅 status=1) | `queryByOrgIdAndUidAllStatus`(status=1 和 3) | +| 公共方法 | `patAuthManager.buildScopeAuthContext(records)` | 同左 | +| 返回 DTO | `PatAuthListResponse.AuthGroup` / `AuthItem`(grantScope/grantScopeDesc) | `ScopeGroupDTO`(grantPolicy: ALWAYS\_ALLOW/DENIED) | +| DENIED 映射 | 不涉及(查不到 status=3,映射逻辑也不含 DENIED 分支) | 有(pat.status=3 -> DENIED) | +| 无记录默认 | 按风险等级给默认值 LOW->PERMANENT, MEDIUM/HIGH->"待确认" | 按风险等级给默认值(同左逻辑,但映射到 grantPolicy 字段) | + +> 公共方法只返回 `ScopeAuthContext`(template 分组 + auth map),**不包含任何 DTO 字段映射**。LWP 和 HSF 各自遍历 context 填充自己的 DTO,互不干扰。 + +**出参:** + +```json +{ + "groups": [ + { + "productCode": "chat", + "productName": "会话与群聊", + "totalCount": 13, + "scopes": [ + { + "scope": "chat.conversation:search", + "displayName": "搜索会话列表", + "riskLevel": "LOW", + "grantPolicy": "ALWAYS_ALLOW" + }, + { + "scope": "chat.message:read", + "displayName": "拉取会话消息内容", + "riskLevel": "MEDIUM", + "grantPolicy": "DENIED" + } + ] + } + ] +} + +``` + +**逻辑:** + +1. `product_scope_template.queryAllEnabled()` 获取全量 scope 元数据 + +2. `agent_pat.queryByOrgIdAndUidAllStatus(orgId, uid, agentCode)` 获取该应用已有授权记录(包含 status=1 和 status=3) + +3. 按  + + * 有记录 + status=3(DENIED) -> `DENIED`(新增分支) + + * 有记录 + status=1(ACTIVE) -> 按 grant\_scope 映射(复用端内逻辑) + + * 无记录 -> 按风险等级给默认值(复用端内逻辑:LOW->PERMANENT/"始终允许", MEDIUM->"待确认", HIGH->"待确认") + + +## 五、按 scope 修改授权策略 + +#### `updateScopeGrantPolicy(orgId, uid, agentCode, scope, grantPolicy)` + +**逻辑:** + +已有记录时 UPDATE,无记录时 INSERT(需从 `product_scope_template` 补齐字段)。 + +* * 有记录 -> UPDATE: grant\_scope=PERMANENT(3), status=1(ACTIVE) + + * 无记录 -> INSERT: productCode 从 template 获取, patToken=UUID, grant\_scope=PERMANENT(3), status=1(ACTIVE) + +* * 有记录 -> UPDATE: status=3(DENIED),grant\_scope 保持不变 + + * 无记录 -> INSERT: productCode 从 template 获取, patToken=UUID, grant\_scope=PERMANENT(3), status=3(DENIED) + + +**INSERT 时必填字段:** + +```java +AgentPatDO pat = new AgentPatDO(); +pat.setOrgId(orgId); +pat.setUid(uid); +pat.setAgentCode(agentCode); +pat.setScope(scope); +pat.setProductCode(template.getProductCode()); // 从 product_scope_template 获取 +pat.setPatToken(UUID.randomUUID().toString().replace("-", "")); +pat.setGrantScope(GrantScopeEnum.PERMANENT.getCode()); // 固定 PERMANENT(3) +pat.setStatus(statusByPolicy); // ALWAYS_ALLOW->1, DENIED->3 + +``` + +### agent\_pat status 值定义 + +| status | 枚举 | 含义 | +| --- | --- | --- | +| 0 | REVOKED | 已撤销(过期、系统撤销等) | +| 1 | ACTIVE | 有效授权 | +| 2 | PENDING\_APPROVAL | 待审批(已有,端内高敏审批用) | +| 3 | DENIED | 用户明确禁止(**新增**) | + +> status=3(DENIED) 是本期新增的状态值。不能使用 status=2,因为 PENDING\_APPROVAL 已被 `PatAuthApprovalService` 的高敏审批链路使用。checkAuth 查到 DENIED 时直接返回 `PAT_SCOPE_DENIED`,不触发 Device Code Flow。 + +## 六、完整时序图 + +```mermaid +sequenceDiagram + participant Term as 用户终端 + participant GW as MCP Gateway + participant PAT as PatAuthService + participant Redis as Redis + participant DB as Database + participant BFF as BFF + participant H5 as 授权H5 + + Note over Term,H5: 撞墙授权(单scope,一步到位) + Term->>GW: 执行DWS命令(如创建日程) + GW->>PAT: checkAuth(orgId, uid, agentCode, scope) + PAT->>DB: 查询agent_pat(不限status) + alt 无记录 或 REVOKED + PAT->>Redis: 查pending索引, 无则创建flow+幂等索引 (TTL 10min) + PAT-->>GW: deny + uri + GW-->>Term: 展示授权链接 + else DENIED(status=3) + PAT-->>GW: PAT_SCOPE_DENIED + GW-->>Term: 该权限已被禁止 + else ACTIVE + PERMANENT + PAT-->>GW: 通过 + end + + Term->>H5: 打开授权链接 + H5->>BFF: GET /oauth/device/detail + BFF->>PAT: queryFlowDetail(flowId) + PAT->>Redis: HGETALL + PAT->>DB: product_scope_template + PAT-->>BFF: agentCode + scope + 风险标签 + BFF-->>H5: 展示授权确认页 + + alt 用户点击授权 + H5->>BFF: POST /oauth/device/action (approve) + BFF->>PAT: handleFlowAction(flowId, approve, uid) + PAT->>DB: INSERT agent_pat (先写DB) + PAT->>Redis: Lua CAS PENDING->APPROVED (DB成功后) + PAT-->>BFF: ok + BFF-->>H5: 授权成功页 + else 用户点击拒绝 + H5->>BFF: POST /oauth/device/action (reject) + BFF->>PAT: handleFlowAction(flowId, reject, uid) + PAT->>Redis: Lua CAS PENDING->REJECTED + PAT-->>BFF: ok + BFF-->>H5: 拒绝结果页 + end + + loop 轮询(2s, 直到终态) + Term->>BFF: GET /oauth/device/poll + BFF->>PAT: pollDeviceCodeStatus(flowId) + PAT->>Redis: HGET status + PAT-->>BFF: status + BFF-->>Term: status + end + Note over Term: APPROVED->重新执行 / REJECTED->输出拒绝 / EXPIRED->输出超时 + + Term->>GW: 重新执行命令 + GW->>PAT: checkAuth -> 通过 + +``` + +## 七、代码改动清单 + +### 新增类 + +| 类名 | 包路径 | 职责 | +| --- | --- | --- | +| `DeviceCodeFlowService` | `c.d.pat.manager` | 单 scope Device Code Flow(创建/查询/批准/拒绝/轮询),操作 Redis | +| `ScopeAuthContext` | `c.d.pat.manager` | 公共中间数据结构(template 分组 + auth map),供 LWP 和 HSF 各自做 DTO 映射 | +| `GrantPolicyEnum` | `c.d.pat.enums` | ALWAYS\_ALLOW / DENIED | +| `ScopeGroupDTO` | `c.d.pat.model` | 新 HSF 专用:按品类分组的 scope 返回结构(含 grantPolicy 字段) | +| `DeviceCodeFlowDTO` | `c.d.pat.model` | Device Code Flow 详情返回结构 | + +### 改动类 + +| 文件 | 改动内容 | +| --- | --- | +| [`PatAuthCheckRequest.java`](lippi-pat-core/lippi-pat-core-api/src/main/java/com/dingtalk/pat/model/request/PatAuthCheckRequest.java) | 新增 `source` 字段(String),用于分流路由。值为 `"DEVICE_CODE_FLOW"` 时走开源版逻辑,null/空走现有逻辑 | +| [`PatAuthCheckService.java`](lippi-pat-core/lippi-pat-core-service/src/main/java/com/dingtalk/pat/manager/PatAuthCheckService.java) | `checkAuth()` 查询改 all-status + 加 DENIED 判定(主链路改动,端内也生效);`evaluateRiskAndBuildResult` 和 `tryConsumeHighRiskApproval` 内部各加一个 `if(source==DCF)` 分流到 `createFlowAndBuildResult`;其余逻辑不动 | +| [`PatAuthCheckResult.java`](lippi-pat-core/lippi-pat-core-api/src/main/java/com/dingtalk/pat/model/response/PatAuthCheckResult.java) | Data 内部类新增 `uri`, `desc` 字段 | +| [`PatAuthService.java`](lippi-pat-core/lippi-pat-core-api/src/main/java/com/dingtalk/pat/hsf/PatAuthService.java) | 接口新增 queryFlowDetail / handleFlowAction / pollDeviceCodeStatus / queryAppScopeDetail / updateScopeGrantPolicy | +| [`PatAuthServiceImpl.java`](lippi-pat-core/lippi-pat-core-service/src/main/java/com/dingtalk/pat/hsf/provider/PatAuthServiceImpl.java) | 实现新增的 5 个 HSF 方法 | +| [`PatAuthManager.java`](lippi-pat-core/lippi-pat-core-service/src/main/java/com/dingtalk/pat/manager/PatAuthManager.java) | 转发 DeviceCodeFlowService 接口;新增 `buildScopeAuthContext(records)` 公共方法(返回 `ScopeAuthContext`:template 分组 + auth map,**不含 DTO 映射**)。**不抽取涉及 SESSION/ONCE/审批的逻辑** | +| [`DWSPatAuthIServiceImpl.java`](lippi-pat-core/lippi-pat-core-service/src/main/java/com/dingtalk/pat/lwp/DWSPatAuthIServiceImpl.java) | queryAuthList 数据准备层改为调用 `PatAuthManager.buildScopeAuthContext()`,**DTO 映射逻辑(AuthItem 填充)保持原位不动**。DAO 查询不变(仍用 `queryByOrgIdAndUid` 仅 status=1),出参结构不变 | +| [`PatErrorCodeEnum.java`](lippi-pat-core/lippi-pat-core-api/src/main/java/com/dingtalk/pat/enums/PatErrorCodeEnum.java) | 新增 `PAT_SCOPE_DENIED`, `DEVICE_CODE_REJECTED`, `DEVICE_CODE_EXPIRED` | +| [`PatStatusEnum.java`](lippi-pat-core/lippi-pat-core-api/src/main/java/com/dingtalk/pat/enums/PatStatusEnum.java) | 新增 `DENIED(3)` 状态 | +| [`agent-pat.xml`](lippi-pat-core/lippi-pat-core-service/src/main/resources/mybatis/pat/agent-pat.xml) | 新增两个 all-status 查询:`queryByAgentCodeAndScopeAllStatus`(checkAuth Device Code Flow 分支用)、`queryByOrgIdAndUidAllStatus`(queryAppScopeDetail 用) | + +### 改动影响一览 + +```plaintext +PatAuthCheckService.checkAuth() + │ + ├─ resolveRequestScope() [不动] + ├─ queryByAgentCodeAndScopeAllStatus() [改动: status=1 → all-status] + ├─ DENIED(3) → PAT_SCOPE_DENIED [新增: 端内+DCF统一] + ├─ 非ACTIVE → 视为null [新增: REVOKED等同于无记录] + │ + ├─ pat==null → evaluateRiskAndBuildResult() + │ ├─ LOW → ok [不动] + │ ├─ MEDIUM + shouldUpgradeToHigh → HIGH [不动] + │ ├─ MEDIUM blocked: + │ │ ├─ source=DCF → createFlowAndBuildResult [新增分流] + │ │ └─ 端内 → buildMediumRiskResult [不动] + │ └─ HIGH blocked: + │ ├─ tryConsumeApproved (DCF无sessionId→false) [不动] + │ ├─ source=DCF → createFlowAndBuildResult [新增分流] + │ └─ 端内 → buildHighRiskResult(推手机) [不动] + │ + └─ pat!=null → classifyAndHandle() + ├─ PERMANENT → handleMatchedScope + │ └─ tryConsumeHighRiskApproval + │ ├─ 无需升级 → ok [不动] + │ ├─ 需升级 + tryConsumeApproved [不动] + │ ├─ source=DCF → createFlowAndBuildResult [新增分流] + │ └─ 端内 → evaluateAsHighRisk(推手机) [不动] + ├─ SESSION → 匹配sessionId [不动, DCF不会命中] + └─ ONCE → CAS消费 [不动, DCF不会命中] + +``` + +## 八、HTTP 接口协议(给前端和终端) + +域名:`pre-open-dev.dingtalk.com`,BFF 应用 `lippi-open-app-dev` + +### A. 前端接口(需登录态) + +#### 1. 查询 flow 详情 + +```plaintext +GET /dingtalk-workspace-cli/oauth/device/detail?flowId={flowId}&userCode={userCode} + +``` + +成功响应: + +```json +{ + "success": true, + "data": { + "agentCode": "app_xxxx", + "appName": "个人机器人XX", + "appIcon": "https://cdn.dingtalk.com/xxx/app\_icon.png", + "userName": "小钉", + "userAvatar": "@lADPxxxxxx", + "status": "PENDING", + "scopes": [ + { + "scope": "aitable.record.read", + "displayName": "使用钉盘上传下载操作", + "productCode": "dingpan", + "productName": "钉盘", + "riskLevel": 1, + "riskDesc": "中风险" + } + ] + } +} + +``` + +字段说明: + +* `agentCode`:应用唯一标识 + +* `appName`:应用名称,BFF 层根据 agentCode 反查应用系统后填充,用于页面顶部 "个人机器人XX请求你的授权" + +* `appIcon`:应用图标 URL,BFF 层填充,用于页面顶部应用图标展示 + +* `userName`:当前登录用户昵称,BFF 层从 `UserProfileClient` 获取,用于 "你将使用以下账号授权登录" 区域 + +* `userAvatar`:当前登录用户头像(avatarMediaId),BFF 层填充,用于用户头像展示 + +* `status`:`PENDING` / `APPROVED` / `REJECTED` / `EXPIRED` + +* `scopes`:权限条目列表(当前单 scope),前端用 `scopes.length` 显示"共N项" + +* `scopes[ ].displayName`:权限名称,直接渲染为权限条目文案 + +* `scopes[ ].riskDesc`:风险等级标签文案("低风险" / "中风险" / "高风险"),直接渲染为标签 + + +错误响应: + +```json +{ "success": false, "code": "INVALID_USER_CODE", "message": "userCode mismatch" } + +``` + +#### 2. 授权 / 拒绝 + +```plaintext +POST /dingtalk-workspace-cli/oauth/device/action +Content-Type: application/json + +{ "flowId": "OIDIRv8KHm...", "userCode": "JZN2-U4CJ", "action": "approve" } + +``` + +action 取值:`approve` / `reject` + +成功响应: + +```json +{ "success": true } + +``` + +错误响应: + +```json +{ "success": false, "code": "FLOW_ALREADY_PROCESSED", "message": "flow is not in PENDING status" } + +``` + +#### 3. 查询权限清单 + +```plaintext +GET /dingtalk-workspace-cli/app-auth/scopes?agentCode={agentCode} + +``` + +响应: + +```json +{ + "success": true, + "data": { + "groups": [ + { + "productCode": "chat", + "productName": "会话与群聊", + "scopes": [ + { + "scope": "chat.conversation:search", + "displayName": "搜索会话列表", + "riskLevel": 1, + "grantPolicy": "ALWAYS_ALLOW" + }, + { + "scope": "chat.message:read", + "displayName": "拉取会话消息内容", + "riskLevel": 2, + "grantPolicy": "DENIED" + } + ] + } + ] + } +} + +``` + +grantPolicy 取值:`ALWAYS_ALLOW`(始终允许)/ `DENIED`(禁止)/ 无此字段时按风险等级默认(LOW->始终允许, MEDIUM/HIGH->待确认) + +#### 4. 修改单个 scope 策略 + +```plaintext +POST /dingtalk-workspace-cli/app-auth/scope-policy +Content-Type: application/json + +{ "agentCode": "app_xxxx", "scope": "chat.message:read", "grantPolicy": "DENIED" } + +``` + +grantPolicy 取值:`ALWAYS_ALLOW` / `DENIED` + +响应: + +```json +{ "success": true } + +``` + +### B. 终端接口(无需登录态)  $\color{#0089FF}{@上官玄(玄玦)}$ + +#### 5. 轮询 flow 状态 + +```plaintext +GET /dingtalk-workspace-cli/oauth/device/poll?flowId={flowId} + +``` + +等待中: + +```json +{ "success": true, "data": { "status": "PENDING" } } + +``` + +授权成功: + +```json +{ "success": true, "data": { "status": "APPROVED" } } + +``` + +用户拒绝: + +```json +{ "success": false, "code": "DEVICE_CODE_REJECTED", "data": { "status": "REJECTED" }, "message": "The end user denied the authorization request." } + +``` + +超时过期: + +```json +{ "success": false, "code": "DEVICE_CODE_EXPIRED", "data": { "status": "EXPIRED" }, "message": "Authorization timed out, please try again." } + +``` + +### C. checkAuth HSF 响应扩展(MCP Gateway 调用) + +撞墙时返回 `uri`,终端从中解析 `flow_id` 参数用于轮询: + +```json +{ + "success": false, + "code": "PAT_MEDIUM_RISK_NO_PERMISSION", + "data": { + "requiredScopes": [{ "productCode": "aitable", "productName": "AI表格", "scope": "aitable.record.read", "displayName": "AI表格-行数据-读取" }], + "grantOptions": ["permanent"], + "uri": "https://open-dev.dingtalk.com/oauth/device/verify?flow\_id=xxx&user\_code=JZN2-U4CJ", + "desc": "在浏览器中打开以下链接进行认证" + } +} + +``` + +用户明确禁止的 scope: + +```json +{ "success": false, "code": "PAT_SCOPE_DENIED", "message": "This scope has been explicitly denied by the user." } + +``` + +## 九、风险与注意事项 + +* **agentCode 天然隔离:** 端内应用和开源 CLI 应用使用不同的 agentCode,`agent_pat` 的 UK 是 `(org_id, uid, agent_code, scope)`,因此 DCF 写入(approve/DENIED)不会覆盖端内已有的 SESSION/ONCE/ACTIVE 记录,两套数据互不影响 + +* **主链路复用 + 最小分流:** DCF 完全走现有 `checkAuth` 主链路(scope 解析、风险评估、中敏升高敏、PERMANENT/SESSION/ONCE 处理),只在 `evaluateRiskAndBuildResult` 和 `tryConsumeHighRiskApproval` 内部各加一个 `if(source==DCF)` 分流到 `createFlowAndBuildResult`。DENIED 判定和 all-status 查询直接改在主链路(端内未来也需要)。DCF 无 sessionId 时 `tryConsumeApprovedHighRiskRecord` 自然返回 false,无需额外处理 + +* **queryAuthList 行为不变:** 公共方法 `buildScopeAuthContext(records)` 只返回中间数据(template 分组 + auth map),**不返回任何 DTO**。LWP 的 `AuthItem` 填充逻辑保持原位,新 HSF 用独立的 `ScopeGroupDTO` 做映射,两者 DTO 完全隔离。LWP DAO 查询不变(`queryByOrgIdAndUid` 仅 status=1),出参结构不变 + +* **DENIED 稳定拒绝:** agent\_pat 新增 status=3(DENIED),`checkAuthForDeviceCodeFlow` 查到 DENIED 直接返回 `PAT_SCOPE_DENIED`,不触发 Device Code Flow。现有 `checkAuth` 查的是 status=1,不会查到 status=3 的记录,不影响端内行为 + +* **本期不做 SESSION:** 开源版 grant\_scope 统一为 PERMANENT,这只发生在 Device Code Flow 分支和 `updateScopeGrantPolicy` 新入口,不影响端内 grantAuth 的 SESSION/ONCE 写入 + +* **先 DB 后 Redis:** approve 时先写 agent\_pat 到 DB,DB 成功后再更新 Redis 状态为 APPROVED,避免"终端看到 APPROVED 但 DB 未落库"的一致性问题 + +* **字段位置兼容:** Device Code Flow 新增字段(uri/desc)放在 `PatAuthCheckResult.Data` 内部类中,与现有 `data.requiredScopes`/`data.grantOptions` 结构一致,不破坏外层模型。终端从 `uri` 的 `flow_id` 参数解析 flowId 用于轮询,不再单独返回 flowId 字段 + +* **单 scope 粒度:** 每次撞墙只针对当前 scope 创建 flow,用户使用不同 scope 的命令会触发多次独立授权 + +* **统一网页授权:** 开源版本(`source=DEVICE_CODE_FLOW` 分支下)中/高风险 scope 均通过 Device Code Flow 网页授权,不影响端内的高敏手机审批链路 + +* **Redis 存储:** flow 状态存 Redis,TTL 10min 自动过期,无需定时清理任务;轮询全程只读 Redis 不查 DB + +* **幂等原子性:** createDeviceCodeFlow 使用 Lua 脚本原子执行(查 pending 索引 + 校验主 key 存在 + 创建 flow),approve/reject 使用 Lua CAS(仅 PENDING 可转换) + +* **Redis 不可用降级:** Redis 故障时 createDeviceCodeFlow 失败,checkAuth 降级返回传统 deny + grantOptions(不影响端内场景) + +* **轮询策略:** 终端 2 秒间隔轮询,最多 10 分钟(与 TTL 对齐) \ No newline at end of file