891 lines
29 KiB
Go
891 lines
29 KiB
Go
// 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"
|
||
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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||
)
|
||
|
||
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
|
||
|
||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||
)
|
||
|
||
var openBrowserFunc = tryOpenBrowser
|
||
|
||
type patSuppressBrowserOpenKeyType struct{}
|
||
|
||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||
|
||
// 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) {
|
||
fmt.Fprintln(w)
|
||
fmt.Fprintf(w, "{\n")
|
||
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
|
||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
|
||
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
|
||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
|
||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
|
||
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
|
||
fmt.Fprintf(w, " }\n")
|
||
fmt.Fprintf(w, "}\n")
|
||
fmt.Fprintln(w)
|
||
|
||
// Print authorization instructions
|
||
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
|
||
fmt.Fprintln(w)
|
||
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
|
||
|
||
if scopeErr.MissingScope != "" {
|
||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||
} else {
|
||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
|
||
}
|
||
|
||
fmt.Fprintln(w)
|
||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
|
||
fmt.Fprintln(w)
|
||
}
|
||
|
||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
|
||
}
|
||
|
||
func wantsStructuredPATOutput(r *runtimeRunner) bool {
|
||
if r == nil || r.globalFlags == nil {
|
||
return false
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
|
||
}
|
||
|
||
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||
rr, ok := runner.(*runtimeRunner)
|
||
if !ok {
|
||
return false
|
||
}
|
||
return wantsStructuredPATOutput(rr)
|
||
}
|
||
|
||
func currentPATOpenBrowser(ctx context.Context, configDir string) bool {
|
||
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
|
||
return false
|
||
}
|
||
return pat.EffectiveOpenBrowser(configDir)
|
||
}
|
||
|
||
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return raw
|
||
}
|
||
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return raw
|
||
}
|
||
|
||
data, ok := payload["data"].(map[string]any)
|
||
if !ok || data == nil {
|
||
data = map[string]any{}
|
||
payload["data"] = data
|
||
}
|
||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||
authURL := apperrors.PATAuthorizationURL(rawURI)
|
||
data["uri"] = authURL
|
||
delete(data, "authUrl")
|
||
delete(data, "authorizationUrl")
|
||
}
|
||
data["openBrowser"] = openBrowser
|
||
|
||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||
if err != nil {
|
||
return raw
|
||
}
|
||
return string(encoded)
|
||
}
|
||
|
||
func patAuthorizationURIFromData(data map[string]any) string {
|
||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||
value, _ := data[key].(string)
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||
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", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
|
||
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
|
||
fmt.Fprintln(output)
|
||
|
||
pollCount := 0
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||
return false
|
||
|
||
case <-time.After(time.Until(deadline)):
|
||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.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",
|
||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||
fmt.Fprintln(output)
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Show polling status
|
||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||
tui.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) {
|
||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||
slog.Debug("pat.host_owned_decision",
|
||
"site", "retryWithPatAuthRetry",
|
||
"hostOwned", hostOwnedPAT,
|
||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||
)
|
||
if hostOwnedPAT {
|
||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, true)}
|
||
}
|
||
if wantsStructuredPATOutputFromRunner(runner) {
|
||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, false)}
|
||
}
|
||
|
||
// 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", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||
fmt.Fprintln(output)
|
||
|
||
return runner.Run(ctx, invocation)
|
||
}
|
||
|
||
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
|
||
|
||
const (
|
||
// patPollInterval is how often we poll the device flow status endpoint.
|
||
patPollInterval = 2 * time.Second
|
||
// patMaxPollInterval caps a server-provided poll interval so a malformed
|
||
// response cannot make the CLI look permanently stuck.
|
||
patMaxPollInterval = 30 * time.Second
|
||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||
patPollTimeout = 10 * time.Minute
|
||
)
|
||
|
||
// 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{}
|
||
|
||
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
|
||
|
||
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||
return f(ctx, invocation)
|
||
}
|
||
|
||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||
func IsPatRetrying(ctx context.Context) bool {
|
||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||
return v
|
||
}
|
||
|
||
func openPATAuthorizationURI(rawURI string) error {
|
||
if rawURI == "" {
|
||
// Defensive guard for future callers. The current call site already
|
||
// checks for a non-empty PAT URI before invoking this helper.
|
||
return nil
|
||
}
|
||
// The PAT service returns the complete authorization URL. Treat it as an
|
||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||
// That variant is normalized by the PAT error contract helper before being
|
||
// printed, opened, or returned in structured output.
|
||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||
}
|
||
|
||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||
if os.Getenv("DWS_DEBUG_PAT_POLL") == "" {
|
||
return
|
||
}
|
||
trimmed := strings.TrimSpace(string(body))
|
||
if trimmed == "" {
|
||
trimmed = "<empty body>"
|
||
}
|
||
fmt.Fprintln(output)
|
||
fmt.Fprintf(output, " ℹ PAT 轮询接口返回原文 (HTTP %d):\n", statusCode)
|
||
fmt.Fprintf(output, " %s\n", trimmed)
|
||
}
|
||
|
||
func runDirectPATAuthCheck(
|
||
ctx context.Context,
|
||
globalFlags *GlobalFlags,
|
||
patErr *apperrors.PATError,
|
||
retry func(context.Context) error,
|
||
output io.Writer,
|
||
) error {
|
||
if retry == nil {
|
||
return patErr
|
||
}
|
||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
|
||
}
|
||
|
||
func runDirectPATAuthCheckWaitOnly(
|
||
ctx context.Context,
|
||
globalFlags *GlobalFlags,
|
||
patErr *apperrors.PATError,
|
||
output io.Writer,
|
||
) error {
|
||
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
|
||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
|
||
}
|
||
|
||
func runDirectPATAuthCheckWithMode(
|
||
ctx context.Context,
|
||
globalFlags *GlobalFlags,
|
||
patErr *apperrors.PATError,
|
||
retry func(context.Context) error,
|
||
output io.Writer,
|
||
retryAfterApproval bool,
|
||
) error {
|
||
if retryAfterApproval && retry == nil {
|
||
return patErr
|
||
}
|
||
runner := &runtimeRunner{
|
||
globalFlags: globalFlags,
|
||
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||
if retry != nil {
|
||
if err := retry(retryCtx); err != nil {
|
||
return executor.Result{}, err
|
||
}
|
||
}
|
||
invocation.Implemented = true
|
||
return executor.Result{
|
||
Invocation: invocation,
|
||
Response: map[string]any{
|
||
"ok": true,
|
||
},
|
||
}, nil
|
||
}),
|
||
}
|
||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||
Kind: "direct_pat_authorization",
|
||
Stage: "auth_login_recommend",
|
||
CanonicalProduct: defaultPATProductID,
|
||
Tool: "pat.batch_grant",
|
||
CanonicalPath: "pat.batch_grant",
|
||
Params: map[string]any{
|
||
"retryAfterApproval": retryAfterApproval,
|
||
},
|
||
}, patErr, defaultConfigDir(), output)
|
||
return err
|
||
}
|
||
|
||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||
// authorization error is detected. It injects the server-assigned clientId
|
||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||
// 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"`
|
||
AuthURL string `json:"authUrl"`
|
||
AuthorizationURL string `json:"authorizationUrl"`
|
||
ClientID string `json:"clientId"`
|
||
ClientSecret string `json:"clientSecret"`
|
||
PollIntervalSecs int `json:"pollIntervalSeconds"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||
return executor.Result{}, patErr
|
||
}
|
||
if patData.Data.URI == "" {
|
||
patData.Data.URI = patData.Data.AuthURL
|
||
}
|
||
if patData.Data.URI == "" {
|
||
patData.Data.URI = patData.Data.AuthorizationURL
|
||
}
|
||
|
||
slog.Debug("PAT auth check",
|
||
"clientId", patData.Data.ClientID,
|
||
"flowId", patData.Data.FlowID,
|
||
"hasSecret", patData.Data.ClientSecret != "",
|
||
)
|
||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||
openBrowser := currentPATOpenBrowser(ctx, configDir)
|
||
slog.Debug("pat.host_owned_decision",
|
||
"site", "handlePatAuthCheck",
|
||
"hostOwned", hostOwnedPAT,
|
||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||
)
|
||
|
||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||
var appCfg *authpkg.AppConfig
|
||
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 only after an explicit APPROVED result below. Raw PAT
|
||
// interceptions (host-owned / json / empty-flow pass-through) must not
|
||
// rewrite the shared ~/.dws/app.json state for unrelated shells or agents.
|
||
appCfg = &authpkg.AppConfig{ClientID: patData.Data.ClientID}
|
||
if patData.Data.ClientSecret != "" {
|
||
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
|
||
}
|
||
}
|
||
|
||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||
// used for this decision — it is only forwarded on the wire via
|
||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||
if hostOwnedPAT {
|
||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||
}
|
||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||
}
|
||
|
||
if wantsStructuredPATOutput(r) {
|
||
if openBrowser && patData.Data.URI != "" {
|
||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||
}
|
||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||
}
|
||
|
||
fmt.Fprintln(output)
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
|
||
if patData.Data.Desc != "" {
|
||
fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc)
|
||
}
|
||
if patData.Data.URI != "" {
|
||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
|
||
fmt.Fprintln(output)
|
||
if openBrowser {
|
||
_ = openPATAuthorizationURI(authURL)
|
||
}
|
||
}
|
||
|
||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
|
||
fmt.Fprintln(output)
|
||
|
||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||
defer cancel()
|
||
|
||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
||
pollCtx, patData.Data.FlowID, configDir, output,
|
||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||
)
|
||
if err != nil {
|
||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
|
||
return executor.Result{}, patErr
|
||
}
|
||
|
||
switch status {
|
||
case authpkg.StatusApproved:
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!"))
|
||
fmt.Fprintln(output)
|
||
|
||
if appCfg != nil {
|
||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||
}
|
||
}
|
||
|
||
// 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", tui.StateMark("warning"), exchErr)
|
||
} else {
|
||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||
} else {
|
||
slog.Debug("PAT retry: token refreshed and saved")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear token cache so the new credentials take effect.
|
||
ResetRuntimeTokenCache()
|
||
|
||
if shouldSkipPATRetryAfterApproval(invocation) {
|
||
invocation.Implemented = true
|
||
return executor.Result{
|
||
Invocation: invocation,
|
||
Response: map[string]any{
|
||
"ok": true,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// Workaround: brief delay to let server-side authorization state propagate
|
||
// before retrying. Without this the retry may use stale credentials.
|
||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||
time.Sleep(1 * time.Second)
|
||
|
||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||
fmt.Fprintln(output)
|
||
slog.Debug("PAT retry: identity env check",
|
||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||
)
|
||
retryCtx := context.WithValue(ctx, patRetryingKey, true)
|
||
return r.Run(retryCtx, invocation)
|
||
|
||
case authpkg.StatusRejected:
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
|
||
return executor.Result{}, apperrors.NewAuth(
|
||
"用户已拒绝授权",
|
||
apperrors.WithReason("pat_auth_rejected"),
|
||
apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"),
|
||
)
|
||
|
||
case authpkg.StatusExpired:
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
|
||
return executor.Result{}, apperrors.NewAuth(
|
||
"授权超时",
|
||
apperrors.WithReason("pat_auth_expired"),
|
||
apperrors.WithHint("授权链接已过期,请重新执行命令。"),
|
||
)
|
||
|
||
case authpkg.StatusCancelled:
|
||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
|
||
return executor.Result{}, apperrors.NewAuth(
|
||
"操作已取消",
|
||
apperrors.WithReason("pat_auth_cancelled"),
|
||
apperrors.WithHint("用户取消了授权操作。"),
|
||
)
|
||
|
||
default:
|
||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
|
||
return executor.Result{}, patErr
|
||
}
|
||
}
|
||
|
||
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
|
||
if invocation.Params == nil {
|
||
return false
|
||
}
|
||
value, ok := invocation.Params["retryAfterApproval"]
|
||
if !ok {
|
||
return false
|
||
}
|
||
retry, ok := value.(bool)
|
||
return ok && !retry
|
||
}
|
||
|
||
func enrichPATErrorForHostControl(raw string) string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return raw
|
||
}
|
||
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return raw
|
||
}
|
||
|
||
// Route back through the classifier so host-owned active retry emits the
|
||
// exact same PAT JSON shape as passive classification.
|
||
if patErr := apperrors.ClassifyPatAuthCheck(payload); patErr != nil {
|
||
return patErr.RawJSON
|
||
}
|
||
|
||
apperrors.ApplyHostMutations(payload)
|
||
|
||
// stderr JSON MUST be single-line.
|
||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||
if err != nil {
|
||
return raw
|
||
}
|
||
return string(encoded)
|
||
}
|
||
|
||
// buildPATScopeJSON renders the PAT_SCOPE_AUTH_REQUIRED stderr payload.
|
||
// includeHostControl=true follows the standard host-owned/CLI-owned split
|
||
// (data.hostControl is injected only if HostControlBlock is non-nil).
|
||
// includeHostControl=false is an explicit override used by the CLI-owned
|
||
// branch so that any env-mode misconfiguration cannot leak a host-owned
|
||
// contract into stderr.
|
||
func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string {
|
||
data := map[string]any{
|
||
"identity": scopeErr.Identity,
|
||
"errorType": scopeErr.ErrorType,
|
||
"message": scopeErr.Message,
|
||
"hint": scopeErr.Hint,
|
||
"missingScope": scopeErr.MissingScope,
|
||
"openBrowser": apperrors.PATOpenBrowserValue(),
|
||
}
|
||
if includeHostControl {
|
||
if hostControl := apperrors.HostControlBlock(); hostControl != nil {
|
||
data["hostControl"] = hostControl
|
||
}
|
||
}
|
||
|
||
payload := map[string]any{
|
||
"success": false,
|
||
"code": patScopeAuthRequiredCode,
|
||
"data": data,
|
||
}
|
||
// stderr JSON MUST be single-line.
|
||
b, err := jsonutil.Marshal(payload)
|
||
if err != nil {
|
||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
enc := json.NewEncoder(&buf)
|
||
enc.SetEscapeHTML(false)
|
||
if err := enc.Encode(v); err != nil {
|
||
return nil, err
|
||
}
|
||
out := buf.Bytes()
|
||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||
out = out[:len(out)-1]
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// 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) {
|
||
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
|
||
}
|
||
|
||
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
|
||
if interval <= 0 {
|
||
interval = patPollInterval
|
||
}
|
||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||
|
||
// 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(interval)
|
||
defer ticker.Stop()
|
||
|
||
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] 等待授权中... ", tui.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))
|
||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||
continue
|
||
}
|
||
|
||
pollData := pollResp.EffectiveData()
|
||
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
|
||
switch status {
|
||
case authpkg.StatusApproved:
|
||
fmt.Fprintln(output) // clear the polling line
|
||
return status, pollData.AuthCode, nil
|
||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||
fmt.Fprintln(output) // clear the polling line
|
||
return status, "", nil
|
||
case authpkg.StatusPending:
|
||
default:
|
||
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
|
||
// so this branch handles truly unknown statuses.
|
||
fmt.Fprintln(output)
|
||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||
return status, "", nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func resolvePATPollInterval(seconds int) time.Duration {
|
||
if seconds <= 0 {
|
||
return patPollInterval
|
||
}
|
||
interval := time.Duration(seconds) * time.Second
|
||
if interval < time.Second {
|
||
return time.Second
|
||
}
|
||
if interval > patMaxPollInterval {
|
||
return patMaxPollInterval
|
||
}
|
||
return interval
|
||
}
|
||
|
||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||
switch goos {
|
||
case "darwin":
|
||
return exec.Command("open", rawURL)
|
||
case "linux":
|
||
return exec.Command("xdg-open", rawURL)
|
||
case "windows":
|
||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||
func tryOpenBrowser(rawURL string) error {
|
||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||
if cmd == nil {
|
||
return nil
|
||
}
|
||
return cmd.Start()
|
||
}
|