From 725577103da70df3baf70b2f46ebbe82ad031d43 Mon Sep 17 00:00:00 2001 From: shangguanxuan633-lab Date: Sun, 19 Apr 2026 19:12:34 +0800 Subject: [PATCH] feat: PAT scope error visualization and auto-retry with authorization polling (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: PAT scope error visualization and auto-retry with authorization polling 1. PAT error result visualization (non-JSON) - Human-readable error output matching lark-cli style - Shows error type, message, hint, and authorization command - JSON output also available via --format json 2. Auto-retry with polling after authorization wall - Detects missing_scope / insufficient_scope / permission errors - Polls every 5s for token update after user completes auth - 10 minute timeout before giving up - Automatically retries the original command after successful auth Changes: - internal/app/pat_auth_retry.go: Core PAT error handling and retry logic - internal/app/pat_auth_retry_test.go: 11 unit tests - internal/app/runner.go: Integration with executeInvocation error path - internal/auth/device_flow.go: Add SetScope() method * feat: migrate PAT (Permission Authorization) infrastructure to open-source core Move PAT permission management code from dws-wukong overlay to the open-source dingtalk-workspace-cli core. This enables PAT handling in the public distribution while keeping dws-wukong fully compatible through the edition.Hooks mechanism. Changes: - pkg/edition: add 6 new Hooks fields (AuthClientID, AuthClientFromMCP, SaveToken, LoadToken, DeleteToken, ClassifyToolResult) for overlay extensions to inject custom auth/token/error behaviour - internal/errors/pat: new PATError type with ExitCode()=4 and RawStderr() for embedded-mode passthrough, plus ClassifyToolResultContent and ClassifyMCPResponseText classification functions - internal/pat: new command group (pat chmod) with tool result handling - internal/app/root: register PAT commands before RegisterExtraCommands hook - internal/app/runner: integrate ClassifyToolResult hook in the callResult.IsError branch so overlays can intercept PAT/gateway errors before generic handling The dws-wukong overlay remains unchanged — its existing pat/ package and RegisterExtraCommands hook continue to work. Through the go.mod replace directive and deduplicateCommands mechanism, both codebases coexist without conflict. * feat(pat): add PAT auth check with device flow polling and auto-retry - Add ClassifyPatAuthCheck / AsPatAuthCheckError in pat.go for AGENT_CODE_NOT_EXISTS error detection - Add handlePatAuthCheck in pat_auth_retry.go: inject clientId as x-robot-uid header, poll device flow endpoint, auto-retry on APPROVED - Integrate PAT check into runner.go executeInvocation pipeline (edition hook + open-source fallback) - Add patAuthRequiredCodes map for extensible auth-required code matching - Remove test_parse.go (temporary test script) * fix(pat): update poll path to /cli/oauth/device/poll and add x-user-access-token header - Change DevicePollPath from /api/dingtalk-workspace-cli/oauth/device/poll to /cli/oauth/device/poll (aligning with backend endpoint convention) - Add x-user-access-token header to poll requests (loaded from stored token) * feat(auth): PAT device flow improvements and flowId empty guard - pat_auth_retry: fix JSON field parsing (result -> data) using DevicePollResponse - pat_auth_retry: fix elapsed time calculation based on start timestamp - device_flow: add flowId empty guard - skip polling and show auth URL for manual handling - device_flow: unify terminalBaseURL to GetMCPBaseURL for polling endpoint - device_flow: remove unused PollFlowApproval dead code - runner: integrate DWS_CLIENT_ID env and app.json persistence - oauth_provider: add x-robot-uid header removal on logout - client: add request/response logging for transport debugging * feat(env): switch all service discovery URLs from pre-release to production - endpoints: pre-login/pre-api/pre-mcp/pre-open-dev -> login/api/mcp/open-dev - loader: DefaultMarketBaseURL -> mcp.dingtalk.com - registry: defaultBaseURL -> mcp.dingtalk.com - constants: DefaultTerminalBaseURL -> open-dev.dingtalk.com * fix: remove residual merge conflict markers and duplicate Hooks fields in edition.go; unify developer-settings URL via config helper in device_flow.go * chore: remove lark-cli reference from PrintPatAuthError comment * test: add comprehensive unit tests for PAT auth modules - Create internal/errors/pat_test.go (38 tests): covers ClassifyToolResultContent, ClassifyMCPResponseText, ClassifyPatAuthCheck, AsPatAuthCheckError, cleanPATJSON, stripClassFields, getDWSGatewayErrorCode, isNotLoggedInError, isBusinessError, IsPATError, IsPATNoPermissionCode, suggestForBusinessErrorText - Supplement pat_auth_retry_test.go (+7 tests): IsPatRetrying context checks, pollPatDeviceFlow edge cases (server error fallback, SSO redirect skip), extractPatScopeError nil/identity extraction * feat(pat): exchange authCode for fresh token after PAT APPROVED Previously pollPatDeviceFlow discarded the authCode from the poll response, so handlePatAuthCheck retried with the stale token after APPROVED. This mirrors device_flow.go loginOnce which calls exchangeCode → SaveTokenData. Changes: - pollPatDeviceFlow: return (status, authCode, error) instead of (status, error); extract Data.AuthCode on APPROVED - handlePatAuthCheck APPROVED path: call ExchangeCodeForToken + SaveTokenData before ResetRuntimeTokenCache, with graceful fallback on exchange failure - Update all 6 poll test cases for new 3-return signature; add authCode assertions for APPROVED/REJECTED/EXPIRED/CANCELLED/ ServerError scenarios * fix(pat): CR round-2 must-fix items - scopeValueRegex: support multi-segment scopes (mail:a.b:send) Updated regex to ([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+) - handlePatAuthCheck: add 3 integration tests (APPROVED/REJECTED/EmptyFlowID) Covers the 132-line main orchestrator with mock runner + httptest server - Extract ParseDeviceFlowStatus + status constants to auth package Eliminates string literal duplication across device_flow.go and pat_auth_retry.go * fix(pat): use direct OAuth path when clientSecret is provided When PAT error response includes both clientId and clientSecret, use SetClientID() (direct DingTalk API mode) instead of SetClientIDFromMCP() (MCP proxy mode). The MCP proxy does not hold the secret for the PAT-assigned app, causing HTTP 400 'invalidParameter.idOrSecret.notFound' on token exchange. Now: clientSecret present → direct mode; absent → MCP proxy mode. --------- Co-authored-by: shangguanxuan.sgx --- dingtalk-workspace-cli.code-workspace | 11 + dingtalk-workspace.zip | Bin 0 -> 66071 bytes internal/app/pat_auth_retry.go | 598 ++++++++++++++ internal/app/pat_auth_retry_test.go | 606 ++++++++++++++ internal/app/root.go | 5 + internal/app/runner.go | 48 +- internal/auth/cli_auth_check_test.go | 87 +- internal/auth/device_flow.go | 158 +++- internal/auth/device_flow_status.go | 41 + internal/auth/device_flow_test.go | 48 +- internal/auth/endpoints.go | 22 + internal/auth/oauth_helpers.go | 13 + internal/auth/oauth_provider.go | 7 + internal/errors/errors.go | 6 +- internal/errors/pat.go | 289 +++++++ internal/errors/pat_test.go | 521 ++++++++++++ internal/i18n/locales/en.json | 2 +- internal/i18n/locales/zh.json | 2 +- internal/pat/chmod.go | 135 ++++ internal/pat/pat.go | 42 + internal/transport/client.go | 9 + pkg/config/constants.go | 47 ++ pkg/edition/default.go | 7 + pkg/edition/edition.go | 33 +- test_parse.go | 34 - 技术方案 (2).md | 1052 +++++++++++++++++++++++++ 26 files changed, 3689 insertions(+), 134 deletions(-) create mode 100644 dingtalk-workspace-cli.code-workspace create mode 100644 dingtalk-workspace.zip create mode 100644 internal/app/pat_auth_retry.go create mode 100644 internal/app/pat_auth_retry_test.go create mode 100644 internal/auth/device_flow_status.go create mode 100644 internal/errors/pat.go create mode 100644 internal/errors/pat_test.go create mode 100644 internal/pat/chmod.go create mode 100644 internal/pat/pat.go delete mode 100644 test_parse.go create mode 100644 技术方案 (2).md 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 0000000000000000000000000000000000000000..270b99c7b887743483f8bfb5013ed95ad5830ab5 GIT binary patch literal 66071 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 W(4fixkC~DJ>;|+%gTMIWzyAl=l=?OR literal 0 HcmV?d00001 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