Files

1492 lines
47 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
stderrors "errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func boolPtr(v bool) *bool { return &v }
type LoginRecommendOptions struct {
// ProductCodes limits the recommend plan to service-owned product domains.
ProductCodes []string
// Confirmed grants the server-selected recommend scopes directly. When
// false, recommend authorization starts the batch confirmation page.
Confirmed bool
// ScopeMode controls whether the plan uses the safe recommended set or
// all scopes under the selected product domains. Empty means recommended.
ScopeMode LoginRecommendScopeMode
// ProductSelector lets interactive callers present the service-owned domain
// list before the final plan. It receives products extracted from the
// initial recommend plan and must return the product codes to grant.
ProductSelector func([]LoginRecommendProduct) ([]string, error)
// InitialPlan lets callers preflight the default recommend plan before any
// interactive selector and then reuse the same dry-run result.
InitialPlan *LoginRecommendPlan
}
type LoginRecommendScopeMode string
const (
LoginRecommendScopeRecommended LoginRecommendScopeMode = "recommend"
LoginRecommendScopeAll LoginRecommendScopeMode = "all"
)
type LoginRecommendProduct struct {
ProductCode string
ProductName string
Summary string
ScopeCount int
SelectedScopeCount int
}
type LoginRecommendPlan struct {
Result *edition.ToolResult
Scopes []string
Products []LoginRecommendProduct
AllGranted bool
}
const (
sessionIDEnvDingtalk = "DINGTALK_SESSION_ID"
sessionIDEnvDWS = "DWS_SESSION_ID"
sessionIDEnvRewind = "REWIND_SESSION_ID"
)
// resolveSessionIDFromEnv returns the effective session id from environment
// variables. Resolution order matches the MCP header resolver:
// 1. DINGTALK_SESSION_ID (primary gateway header env).
// 2. DWS_SESSION_ID (stable CLI/session-grant env name).
// 3. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
// already inject the legacy trace triple keep working without code
// churn).
//
// When multiple env vars are set to different non-empty values, the first
// one above wins silently. We deliberately do NOT log either raw session id
// value or any derived fingerprint: this resolver is invoked by `dws pat
// chmod` session grants, and any stderr / ~/.dws/logs capture of those
// identifiers can land verbatim in attached troubleshooting bundles. Hosts
// that need to detect a mismatch must do so before invoking the CLI.
func resolveSessionIDFromEnv() string {
for _, key := range []string{sessionIDEnvDingtalk, sessionIDEnvDWS, sessionIDEnvRewind} {
if value := os.Getenv(key); value != "" {
return value
}
}
return ""
}
// agentCodeEnv is the canonical environment variable name used as a
// per-shell fallback for the --agentCode flag on `dws pat *` commands.
//
// Why: agent hosts may set their business agent code once when spawning
// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets
// the host export the code once and let the CLI resolve it on every pat
// subcommand. The flag always wins when both are set so scripted one-offs
// remain deterministic. When neither flag nor env is set, `pat chmod` omits
// agentCode and lets the PAT server apply its open-source default. Batch PAT
// tools receive the resolved agentCode in arguments when present, while the CLI
// also keeps exporting it through env for older gateway paths.
//
// Namespace note: keep this as a single-spelled public contract. The reversed
// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE /
// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed.
const (
agentCodeEnv = authpkg.AgentCodeEnv
agentCodeEnvCompat = authpkg.AgentCodeEnvCompat
)
// agentCodePattern is the validation regex for any --agentCode value
// resolved from either the flag or the agent-code env var. It matches
// documented agent-code generation schemes (e.g. md5 digests, uuid-like
// ids, short host-assigned slugs) while rejecting shell metacharacters
// and whitespace that would otherwise flow unescaped into an MCP tool
// argument.
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// resolveAgentCodeFromEnv returns the fallback agent code from the canonical
// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name
// that was consumed (for error attribution); it is "" when the env var is unset
// or blank.
func resolveAgentCodeFromEnv() (string, string) {
return authpkg.AgentCodeFromEnv()
}
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
// once spliced into a shell / MCP argv. Allowed character set is
// [A-Za-z0-9_-], length 1..64 — see agentCodePattern above.
func validateAgentCode(code string) error {
if code == "" {
return fmt.Errorf("--agentCode must not be empty")
}
if !agentCodePattern.MatchString(code) {
return fmt.Errorf(
"invalid agentCode %q: must match %s (A-Z, a-z, 0-9, _, -; 1..64 chars)",
code, agentCodePattern.String())
}
return nil
}
// resolveAgentCode implements the canonical optional lookup for --agentCode:
//
// 1. explicit --agentCode flag value (highest priority; wins over env)
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
// 3. empty ("") so PAT-core can apply its open-source default.
//
// Any non-empty resolved value is validated via validateAgentCode, so
// callers never have to re-validate.
func resolveAgentCode(flagVal string) (string, error) {
code := strings.TrimSpace(flagVal)
envSource := ""
if code == "" {
code, envSource = resolveAgentCodeFromEnv()
}
if code == "" {
return "", nil
}
if err := validateAgentCode(code); err != nil {
if envSource != "" {
return "", apperrors.NewValidation(
fmt.Sprintf("%s env: %v", envSource, err),
apperrors.WithReason("invalid_agent_code"),
)
}
return "", apperrors.NewValidation(
err.Error(),
apperrors.WithReason("invalid_agent_code"),
)
}
return code, nil
}
const (
// patGrantToolName is the English-first wire name for the PAT grant tool.
patGrantToolName = "pat.grant"
// patBatchGrantToolName is the English-first wire name for PAT batch grant.
patBatchGrantToolName = "pat.batch_grant"
// patBatchPlanToolName is the English-first wire name for PAT batch plan.
patBatchPlanToolName = "pat.batch_plan"
// patGrantToolNameLegacyAlias is retained for server builds that still
// expose only the legacy Chinese display name.
patGrantToolNameLegacyAlias = "个人授权"
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD"
patForgedIdentityCodeLower = "pat_forged_identity_field"
grantTypePermanent = "permanent"
patCallerAuthLoginRecommend = "auth_login_recommend"
)
var patBatchMetadataContractCodes = map[string]bool{
"pat_batch_auth_metadata_required": true,
"pat_batch_scope_not_declared": true,
"pat_batch_product_not_declared": true,
}
var patBatchIdentityArgumentKeys = map[string]bool{
"agentCode": true,
"sessionId": true,
"orgId": true,
"uid": true,
"source": true,
"caller": true,
}
var validGrantTypes = map[string]bool{
"once": true,
"session": true,
"permanent": true,
}
// newChmodCommand builds a fresh `dws pat chmod` cobra.Command wired to
// the supplied ToolCaller. A factory is used (instead of a package-level
// var) so multiple RegisterCommands invocations never share mutable flag /
// RunE state across concurrent tests.
func newChmodCommand(c edition.ToolCaller) *cobra.Command {
var recommend bool
var productFlags []string
var productsFlag []string
var domainFlags []string
var domainsFlag []string
chmodCmd := &cobra.Command{
Use: "chmod <scope>...",
Short: "授予指定权限",
Long: `授予指定 scope 的操作权限。
scope 格式: <product>.<entity>:<permission>
例: aitable.record:query chat.message:list calendar.event:get
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效,需要 --session-id
permanent 永久有效(默认)
批量授权:
dws pat chmod 支持一次传多个 scope 直接批量授予。
也支持 --products / --product 按产品编码批量展开 scope 模板,
--domains / --domain 按产品域批量展开 scope 模板,
--recommend 使用服务端推荐 scope 集合。
使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认
selected / skipped / pending,再对 selected scopes 执行 batch grant;
--dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式
添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。
agentCode 配置:
可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE
指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`,
Args: func(cmd *cobra.Command, args []string) error {
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
if len(args) > 0 || recommend || len(productCodes) > 0 {
return nil
}
return cobra.MinimumNArgs(1)(cmd, args)
},
Example: ` dws pat chmod aitable.record:query --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once
dws pat chmod aitable.record:query aitable.record:create --grant-type permanent --yes
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
dws pat chmod --products calendar,aitable --grant-type permanent --yes
dws pat chmod --domain calendar --domain chat --grant-type once --yes
dws pat chmod --recommend --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
flagVal, _ := cmd.Flags().GetString("agentCode")
agentCode, err := resolveAgentCode(flagVal)
if err != nil {
return err
}
scopes := args
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
usesPlan := recommend || len(productCodes) > 0
grantType, _ := cmd.Flags().GetString("grant-type")
sessionID, _ := cmd.Flags().GetString("session-id")
if sessionID == "" {
sessionID = resolveSessionIDFromEnv()
}
if c != nil && c.DryRun() {
if usesPlan {
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
if err != nil {
return fmt.Errorf("pat chmod plan failed: %w", err)
}
if err := ensurePATResultAgentCode(result, agentCode); err != nil {
return err
}
return handleToolResult(cmd, c, result)
}
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
if agentCode != "" {
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 c == nil {
return fmt.Errorf("internal error: tool runtime not initialized")
}
if usesPlan {
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
if err != nil {
return fmt.Errorf("pat chmod plan failed: %w", err)
}
if err := ensurePATResultAgentCode(planResult, agentCode); err != nil {
return err
}
scopes, err = extractSelectedScopes(planResult)
if err != nil {
return err
}
if len(scopes) == 0 {
return handleToolResult(cmd, c, planResult)
}
if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil {
return err
}
} else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil {
return err
}
batchArgs := map[string]any{
"scopes": scopes,
"grantType": grantType,
}
toolArgs := map[string]any{
"scopes": scopes,
"grantType": grantType,
}
if agentCode != "" {
batchArgs["agentCode"] = agentCode
toolArgs["agentCode"] = agentCode
}
if sessionID != "" {
batchArgs["sessionId"] = sessionID
toolArgs["sessionId"] = sessionID
}
// Legacy server schema accepted singular "scope"; clone the
// canonical argv and rename the key so the two payloads stay
// in lock-step on every other field.
legacyToolArgs := make(map[string]any, len(toolArgs))
for k, v := range toolArgs {
if k == "scopes" {
legacyToolArgs["scope"] = v
continue
}
legacyToolArgs[k] = v
}
ctx := context.Background()
result, err := callPATBatchGrantWithLegacyFallback(
ctx,
c,
agentCode,
sessionID,
batchArgs,
toolArgs,
legacyToolArgs,
)
if err != nil {
return fmt.Errorf("pat chmod failed: %w", err)
}
if err := ensurePATResultAgentCode(result, agentCode); err != nil {
return err
}
return handleToolResult(cmd, c, result)
},
}
chmodCmd.Flags().String("agentCode", "",
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
chmodCmd.Flags().String("grant-type", grantTypePermanent, "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes")
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes")
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
cli.AttachRuntimeSchema(chmodCmd, "pat", "batch_grant", "hardcoded:pat")
cli.AnnotateRuntimeFlagEnum(chmodCmd, "grant-type", "once", "session", "permanent")
cli.AnnotateRuntimeConstraints(chmodCmd, cli.RuntimeSchemaConstraints{
RequireOneOf: [][]string{{"scope", "product", "products", "domain", "domains", "recommend"}},
})
cli.AnnotateRuntimePositionals(chmodCmd, contract.RuntimeSchemaPositional{
Name: "scope",
Type: "array",
Description: "权限 scope,格式为 <product>.<entity>:<permission>;可重复",
Required: false,
Variadic: true,
Index: 0,
})
helpers.DeclareLeafMetadata(chmodCmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(cmd *cobra.Command, args []string) error {
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
if len(args) == 0 && !recommend && len(productCodes) == 0 {
return fmt.Errorf("accepts 1 arg(s), received 0")
}
grantType, _ := cmd.Flags().GetString("grant-type")
if !validGrantTypes[grantType] {
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
}
sessionID, _ := cmd.Flags().GetString("session-id")
if sessionID == "" {
sessionID = resolveSessionIDFromEnv()
}
if grantType == "session" && sessionID == "" {
return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod <scope> --grant-type session --session-id <id>")
}
return nil
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "pat",
Name: "batch_grant",
CanonicalPath: "pat.batch_grant",
CLIPath: "pat chmod",
PrimaryCLIPath: "pat chmod",
},
Description: "预览或执行 PAT 批量行为授权(支持 dryRun / pending flow)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "pat", RPCName: "pat.batch_grant"},
},
Selection: contract.SelectionSpec{
AgentSummary: "预览或执行 PAT 批量行为授权(支持 dryRun / pending flow)",
UseWhen: []string{
"命令提示缺少行为授权,需要按产品或 scope 批量授权",
"先 --dry-run 查看 selected/skipped/pending,用户确认后再执行",
},
AvoidWhen: []string{
"普通 OAuth 登录用 auth,不要用 pat",
"授权产品、grant-type 或 session-id 未明确时不要执行写入",
"只要改本地浏览器打开策略时用 pat browser-policy",
},
Examples: []string{"dws pat chmod --products calendar,aitable --grant-type session --session-id <SESSION_ID> --dry-run --format json"},
},
// session-id is conditionally required in Validate; publish via
Parameters: []contract.ParamDecl{
{
Name: "session-id",
Required: boolPtr(false),
RequiredWhen: "grant-type is session",
},
},
},
})
return chmodCmd
}
func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error {
if !usesPlan && len(scopes) <= 1 {
return nil
}
if commandBoolFlag(cmd, "yes") {
return nil
}
return apperrors.NewValidation(
"batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant",
apperrors.WithReason("pat_batch_requires_yes"),
apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"),
apperrors.WithActions(
"dws pat chmod <scope1> <scope2> ... --grant-type once --yes",
"dws pat chmod --products <product1,product2> --grant-type once --yes",
"dws pat chmod --recommend --grant-type once --yes",
),
)
}
func collectChmodProductCodes(groups ...[]string) []string {
seen := map[string]bool{}
result := make([]string, 0)
for _, group := range groups {
for _, raw := range group {
for _, part := range strings.Split(raw, ",") {
code := strings.TrimSpace(part)
if code == "" || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
}
}
return result
}
func withPATContextEnv(agentCode, sessionID string, fn func() (*edition.ToolResult, error)) (*edition.ToolResult, error) {
restore := map[string]*string{}
setEnv := func(key, value string) {
if value == "" {
return
}
if _, seen := restore[key]; !seen {
if old, ok := os.LookupEnv(key); ok {
oldCopy := old
restore[key] = &oldCopy
} else {
restore[key] = nil
}
}
_ = os.Setenv(key, value)
}
setEnv(agentCodeEnv, agentCode)
setEnv(sessionIDEnvDingtalk, sessionID)
setEnv(sessionIDEnvDWS, sessionID)
defer func() {
for key, old := range restore {
if old == nil {
_ = os.Unsetenv(key)
continue
}
_ = os.Setenv(key, *old)
}
}()
return fn()
}
func callPATBatchGrantWithLegacyFallback(
ctx context.Context,
c edition.ToolCaller,
agentCode string,
sessionID string,
batchArgs map[string]any,
canonicalGrantArgs map[string]any,
legacyGrantArgs map[string]any,
) (*edition.ToolResult, error) {
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
if err == nil && !isPATBatchFallbackResult(result) {
return result, nil
}
if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
return nil, err
}
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return callPATToolWithLegacyFallback(
ctx,
c,
"pat",
patGrantToolName,
patGrantToolNameLegacyAlias,
canonicalGrantArgs,
legacyGrantArgs,
)
})
}
func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sessionID string, args map[string]any) (*edition.ToolResult, error) {
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
}
func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", toolName, args)
})
if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
return result, err
}
compatArgs := cloneWithoutPATIdentityArgs(args)
result, err = withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", toolName, compatArgs)
})
if err != nil {
return result, err
}
if err := ensurePATIdentityFallbackAgentCode(result, agentCode); err != nil {
return result, err
}
return result, nil
}
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any {
args := map[string]any{
"scopes": scopes,
"productCodes": productCodes,
"recommend": recommend,
"grantType": grantType,
"dryRun": dryRun,
}
if agentCode != "" {
args["agentCode"] = agentCode
}
if sessionID != "" {
args["sessionId"] = sessionID
}
return args
}
// RunLoginRecommendAuthorization plans the server-owned recommended scope set
// after a successful dws auth login, then starts a batch Device Code Flow for
// the server-selected grantable scopes. The actual browser open, polling, and
// retry are handled by the shared PAT runner once PAT_BATCH_AUTH_PENDING is
// classified as a PAT auth check.
func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, output io.Writer) error {
return RunLoginRecommendAuthorizationWithOptions(ctx, c, output, LoginRecommendOptions{})
}
func PlanLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller) (*LoginRecommendPlan, error) {
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
planResult, scopes, allGranted, err := planLoginRecommend(ctx, c, nil, true)
if err != nil {
return nil, err
}
products, _ := extractLoginRecommendProducts(planResult)
return &LoginRecommendPlan{
Result: planResult,
Scopes: scopes,
Products: products,
AllGranted: allGranted,
}, nil
}
func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.ToolCaller, output io.Writer, opts LoginRecommendOptions) error {
if c == nil {
return fmt.Errorf("internal error: tool runtime not initialized")
}
recommendPlan := opts.ScopeMode != LoginRecommendScopeAll
productCodes := normalizeProductCodes(opts.ProductCodes)
initialRecommendPlan := recommendPlan
if opts.ProductSelector != nil && len(productCodes) == 0 {
// The first plan only discovers the service-owned product-domain list
// for the TUI. The server contract rejects an empty all-scope request
// (recommend=false without productCodes), so all-scope mode is applied
// only after the user selects concrete productCodes.
initialRecommendPlan = true
}
var planResult *edition.ToolResult
var scopes []string
var allGranted bool
if opts.InitialPlan != nil && initialRecommendPlan && len(productCodes) == 0 {
planResult = opts.InitialPlan.Result
scopes = append([]string(nil), opts.InitialPlan.Scopes...)
allGranted = opts.InitialPlan.AllGranted
} else {
var err error
planResult, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, initialRecommendPlan)
if err != nil {
return err
}
}
if recommendPlan && (allGranted || len(scopes) == 0) {
if output != nil {
_, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项")
}
return nil
}
if opts.ProductSelector != nil {
products, err := extractLoginRecommendProducts(planResult)
if err != nil {
return err
}
if len(products) > 0 {
selectedProducts, err := opts.ProductSelector(products)
if err != nil {
return err
}
productCodes = normalizeProductCodes(selectedProducts)
if len(productCodes) == 0 {
return fmt.Errorf("至少选择一个授权业务域")
}
_, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, recommendPlan)
if err != nil {
return err
}
}
if len(products) == 0 && !recommendPlan {
return fmt.Errorf("全部授权需要服务端返回可选授权业务域")
}
}
if allGranted || len(scopes) == 0 {
if output != nil {
_, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项")
}
return nil
}
grantArgs := map[string]any{
"scopes": scopes,
"grantType": grantTypePermanent,
"caller": patCallerAuthLoginRecommend,
"clientRequestId": newBatchClientRequestID("auth-login-recommend"),
}
if !opts.Confirmed {
grantArgs["startFlow"] = true
grantArgs["noWait"] = true
}
result, err := callPATBatchToolWithIdentityFallback(ctx, c, "", "", patBatchGrantToolName, grantArgs)
if err != nil {
return fmt.Errorf("pat login recommend grant failed: %w", err)
}
return handleToolResultForWriter(output, result, c)
}
func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, bool, error) {
productCodes = normalizeProductCodes(productCodes)
if !recommend && len(productCodes) == 0 {
return nil, nil, false, fmt.Errorf("全部授权需要至少一个授权业务域")
}
planArgs := buildBatchPlanArgs(nil, productCodes, recommend, grantTypePermanent, "", "", true)
planArgs["caller"] = patCallerAuthLoginRecommend
planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs)
if err != nil {
return nil, nil, false, fmt.Errorf("pat login recommend plan failed: %w", err)
}
if err := classifyToolResultText(planResult); err != nil {
return planResult, nil, false, err
}
scopes, err := extractSelectedScopesAllowEmpty(planResult)
if err != nil {
return planResult, nil, false, err
}
allGranted, _ := extractBatchPlanAllGranted(planResult)
return planResult, scopes, allGranted, nil
}
func newBatchClientRequestID(prefix string) string {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = "batch"
}
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
func extractLoginRecommendProducts(result *edition.ToolResult) ([]LoginRecommendProduct, error) {
text := firstToolResultText(result)
if text == "" {
return nil, fmt.Errorf("empty PAT batch plan result")
}
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return nil, fmt.Errorf("parsing PAT batch plan result: %w", err)
}
data, _ := body["data"].(map[string]any)
if data == nil {
return nil, fmt.Errorf("PAT batch plan result missing data.items")
}
selectedSet := map[string]bool{}
if rawSelected, _ := data["selectedScopes"].([]any); len(rawSelected) > 0 {
for _, raw := range rawSelected {
if scope, ok := raw.(string); ok && strings.TrimSpace(scope) != "" {
selectedSet[strings.TrimSpace(scope)] = true
}
}
}
rawItems, _ := data["items"].([]any)
type productGroup struct {
item LoginRecommendProduct
summarySeen map[string]bool
summaryParts []string
}
groups := map[string]*productGroup{}
order := make([]string, 0)
for _, raw := range rawItems {
item, _ := raw.(map[string]any)
if item == nil {
continue
}
scope := stringField(item, "scope")
code := stringField(item, "productCode")
if code == "" {
code = productCodeFromScope(scope)
}
if code == "" {
continue
}
group := groups[code]
if group == nil {
group = &productGroup{
item: LoginRecommendProduct{
ProductCode: code,
ProductName: stringField(item, "productName"),
},
summarySeen: map[string]bool{},
}
if group.item.ProductName == "" {
group.item.ProductName = code
}
groups[code] = group
order = append(order, code)
}
group.item.ScopeCount++
if selectedSet[scope] {
group.item.SelectedScopeCount++
}
summary := stringField(item, "operationSummary")
if summary == "" {
summary = stringField(item, "displayName")
}
if summary != "" && !group.summarySeen[summary] && len(group.summaryParts) < 3 {
group.summarySeen[summary] = true
group.summaryParts = append(group.summaryParts, summary)
}
}
if len(order) == 0 {
scopes, _ := extractSelectedScopesAllowEmpty(result)
for _, scope := range scopes {
code := productCodeFromScope(scope)
if groups[code] == nil {
groups[code] = &productGroup{
item: LoginRecommendProduct{
ProductCode: code,
ProductName: code,
ScopeCount: 0,
SelectedScopeCount: 0,
},
}
order = append(order, code)
}
groups[code].item.ScopeCount++
groups[code].item.SelectedScopeCount++
}
}
products := make([]LoginRecommendProduct, 0, len(order))
for _, code := range order {
group := groups[code]
group.item.Summary = strings.Join(group.summaryParts, "、")
products = append(products, group.item)
}
return products, nil
}
func normalizeProductCodes(codes []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(codes))
for _, raw := range codes {
for _, part := range strings.Split(raw, ",") {
code := strings.TrimSpace(part)
if code == "" || seen[code] {
continue
}
seen[code] = true
out = append(out, code)
}
}
return out
}
func productCodeFromScope(scope string) string {
scope = strings.TrimSpace(scope)
if scope == "" {
return ""
}
end := len(scope)
if idx := strings.Index(scope, "."); idx >= 0 && idx < end {
end = idx
}
if idx := strings.Index(scope, ":"); idx >= 0 && idx < end {
end = idx
}
return strings.TrimSpace(scope[:end])
}
func extractBatchPlanAllGranted(result *edition.ToolResult) (bool, error) {
text := firstToolResultText(result)
if text == "" {
return false, fmt.Errorf("empty PAT batch plan result")
}
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return false, fmt.Errorf("parsing PAT batch plan result: %w", err)
}
data, _ := body["data"].(map[string]any)
if data == nil {
return false, fmt.Errorf("PAT batch plan result missing data")
}
allGranted, _ := data["allGranted"].(bool)
return allGranted, nil
}
func extractSelectedScopes(result *edition.ToolResult) ([]string, error) {
text := firstToolResultText(result)
if text == "" {
return nil, fmt.Errorf("empty PAT batch plan result")
}
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return nil, fmt.Errorf("parsing PAT batch plan result: %w", err)
}
data, _ := body["data"].(map[string]any)
if data == nil {
return nil, fmt.Errorf("PAT batch plan result missing data.selectedScopes")
}
rawScopes, _ := data["selectedScopes"].([]any)
if len(rawScopes) == 0 {
if allGranted, _ := data["allGranted"].(bool); allGranted {
return []string{}, nil
}
return nil, fmt.Errorf("PAT batch plan selectedScopes is empty")
}
scopes := make([]string, 0, len(rawScopes))
for _, raw := range rawScopes {
scope, ok := raw.(string)
if ok && strings.TrimSpace(scope) != "" {
scopes = append(scopes, scope)
}
}
if len(scopes) == 0 {
if allGranted, _ := data["allGranted"].(bool); allGranted {
return []string{}, nil
}
return nil, fmt.Errorf("PAT batch plan selectedScopes is empty")
}
return scopes, nil
}
func extractSelectedScopesAllowEmpty(result *edition.ToolResult) ([]string, error) {
text := firstToolResultText(result)
if text == "" {
return nil, fmt.Errorf("empty PAT batch plan result")
}
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return nil, fmt.Errorf("parsing PAT batch plan result: %w", err)
}
data, _ := body["data"].(map[string]any)
if data == nil {
return nil, fmt.Errorf("PAT batch plan result missing data.selectedScopes")
}
rawScopes, _ := data["selectedScopes"].([]any)
scopes := make([]string, 0, len(rawScopes))
for _, raw := range rawScopes {
scope, ok := raw.(string)
if ok && strings.TrimSpace(scope) != "" {
scopes = append(scopes, scope)
}
}
return scopes, nil
}
func firstToolResultText(result *edition.ToolResult) string {
if result == nil {
return ""
}
for _, c := range result.Content {
if c.Type == "text" && strings.TrimSpace(c.Text) != "" {
return strings.TrimSpace(c.Text)
}
}
return ""
}
func ensurePATResultAgentCode(result *edition.ToolResult, expectedAgentCode string) error {
expectedAgentCode = strings.TrimSpace(expectedAgentCode)
if expectedAgentCode == "" {
return nil
}
actualAgentCode := patResultAgentCode(result)
if actualAgentCode == "" || actualAgentCode == expectedAgentCode {
return nil
}
return fmt.Errorf(
"pat chmod returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent",
actualAgentCode,
expectedAgentCode,
agentCodeEnv,
)
}
func ensurePATIdentityFallbackAgentCode(result *edition.ToolResult, expectedAgentCode string) error {
expectedAgentCode = strings.TrimSpace(expectedAgentCode)
if expectedAgentCode == "" {
return nil
}
actualAgentCode := patResultAgentCode(result)
if actualAgentCode == expectedAgentCode {
return nil
}
if actualAgentCode == "" {
return fmt.Errorf(
"pat chmod identity fallback did not return agentCode %q; authorization target cannot be verified",
expectedAgentCode,
)
}
return fmt.Errorf(
"pat chmod identity fallback returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent",
actualAgentCode,
expectedAgentCode,
agentCodeEnv,
)
}
func patResultAgentCode(result *edition.ToolResult) string {
text := firstToolResultText(result)
if text == "" {
return ""
}
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return ""
}
if code := stringField(body, "agentCode"); code != "" {
return code
}
data, _ := body["data"].(map[string]any)
if data != nil {
if code := stringField(data, "agentCode"); code != "" {
return code
}
}
resultBody, _ := body["result"].(map[string]any)
if resultBody != nil {
if code := stringField(resultBody, "agentCode"); code != "" {
return code
}
resultData, _ := resultBody["data"].(map[string]any)
if resultData != nil {
return stringField(resultData, "agentCode")
}
}
return ""
}
func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
return strings.EqualFold(code, patBatchUnsupportedCode)
})
}
func isPATBatchFallbackResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
normalized := strings.ToLower(strings.TrimSpace(code))
return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized]
})
}
func isPATForgedIdentityResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
return strings.EqualFold(code, patForgedIdentityCode)
})
}
func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool {
text := firstToolResultText(result)
if text == "" {
return false
}
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return false
}
for _, key := range []string{"code", "errorCode", "error_code"} {
if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
return true
}
}
return false
}
func isPATBatchUnsupportedError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
}
func isPATBatchFallbackError(err error) bool {
if isPATBatchUnsupportedError(err) {
return true
}
text := normalizedPATErrorText(err)
for code := range patBatchMetadataContractCodes {
if strings.Contains(text, code) {
return true
}
}
return false
}
func isPATForgedIdentityError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
}
func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
if !hasPATIdentityArgs(args) {
return false
}
if err != nil {
return isPATForgedIdentityError(err)
}
return isPATForgedIdentityResult(result)
}
func hasPATIdentityArgs(args map[string]any) bool {
for key := range args {
if patBatchIdentityArgumentKeys[key] {
return true
}
}
return false
}
func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
out := make(map[string]any, len(args))
for key, value := range args {
if patBatchIdentityArgumentKeys[key] {
continue
}
out[key] = value
}
return out
}
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
// then silently retries the legacy Chinese alias when the server has not
// registered the canonical tool yet. The retry intentionally emits no stderr
// banner because host-owned PAT callers parse stderr as machine JSON.
func callPATToolWithLegacyFallback(ctx context.Context, c edition.ToolCaller, productID, toolName, legacyAlias string, toolArgs, legacyArgs map[string]any) (*edition.ToolResult, error) {
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
result, err := c.CallTool(ctx, productID, toolName, toolArgs)
if err == nil {
return result, nil
}
if legacyAlias == "" {
return nil, err
}
if !isToolNotRegisteredError(err) && !isLegacyGrantSchemaMismatchError(err, toolArgs, legacyArgs) {
return nil, err
}
return c.CallTool(ctx, productID, legacyAlias, legacyArgs)
}
func isEmptyToolResult(result *edition.ToolResult) bool {
if result == nil || len(result.Content) == 0 {
return true
}
for _, block := range result.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return false
}
}
return true
}
// isToolNotRegisteredError reports whether err looks like a server-side
// tool-not-registered / tool-not-found classification. We match on a few
// conservative substrings rather than a structured error type because the
// upstream runner surfaces the server message as plain text.
func isToolNotRegisteredError(err error) bool {
if err == nil {
return false
}
msg := normalizedPATErrorText(err)
needles := []string{
"tool_not_found",
"mcp_tool_not_found",
"tool not found",
"tool not registered",
"tool not exist",
"tool does not exist",
"unknown tool",
"no such tool",
"未找到指定工具",
"未找到工具",
"工具不存在",
}
for _, needle := range needles {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
func isLegacyGrantSchemaMismatchError(err error, toolArgs, legacyArgs map[string]any) bool {
if err == nil || !hasScopeKeyShapeMismatch(toolArgs, legacyArgs) {
return false
}
if apperrors.IsPATError(err) {
return false
}
msg := normalizedPATErrorText(err)
if containsAny(msg,
"pat_no_permission",
"pat_low_risk_no_permission",
"pat_medium_risk_no_permission",
"pat_high_risk_no_permission",
"pat_scope_auth_required",
"agent_code_not_exists",
"requiredscopes",
"missingscope",
"missing_scope",
"insufficient_scope",
) {
return false
}
if !containsAny(msg, "scope", "scopes") {
return false
}
if !containsAny(msg,
"param_error",
"参数错误",
"parameter",
"validation",
"required",
"missing",
"unknown",
"unexpected",
"invalid",
"unmarshal",
) {
return false
}
if containsAny(msg,
"permission denied",
"no permission",
"forbidden",
"unauthorized",
"auth required",
"无权限",
"未授权",
"pat_medium_risk_no_permission",
) {
return false
}
return true
}
func hasScopeKeyShapeMismatch(toolArgs, legacyArgs map[string]any) bool {
if toolArgs == nil || legacyArgs == nil {
return false
}
_, hasCanonicalPlural := toolArgs["scopes"]
_, hasCanonicalSingular := toolArgs["scope"]
_, hasLegacyPlural := legacyArgs["scopes"]
_, hasLegacySingular := legacyArgs["scope"]
return hasCanonicalPlural && !hasCanonicalSingular && hasLegacySingular && !hasLegacyPlural
}
func normalizedPATErrorText(err error) string {
if err == nil {
return ""
}
parts := []string{strings.ToLower(err.Error())}
var typed *apperrors.Error
if stderrors.As(err, &typed) && typed != nil {
parts = append(parts,
strings.ToLower(typed.Reason),
strings.ToLower(typed.ServerDiag.ServerErrorCode),
strings.ToLower(typed.ServerDiag.TechnicalDetail),
strings.ToLower(typed.Hint),
)
}
return strings.Join(parts, " ")
}
func containsAny(msg string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
// handleToolResult processes a ToolResult and writes output to stdout.
func handleToolResult(cmd *cobra.Command, caller edition.ToolCaller, 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
}
if !shouldEmitRawPATResult(cmd) {
if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" {
fmt.Print(summary)
return nil
}
}
fmt.Println(c.Text)
return nil
}
data, _ := json.Marshal(result)
return fmt.Errorf("empty PAT authorization result: %s", string(data))
}
func handleToolResultForWriter(w io.Writer, result *edition.ToolResult, caller edition.ToolCaller) error {
if result == nil {
return fmt.Errorf("empty tool result")
}
for _, c := range result.Content {
if c.Type != "text" || c.Text == "" {
continue
}
if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil {
return respErr
}
if w == nil {
return nil
}
if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" {
_, _ = fmt.Fprint(w, summary)
return nil
}
_, _ = fmt.Fprintln(w, c.Text)
return nil
}
data, _ := json.Marshal(result)
return fmt.Errorf("empty PAT authorization result: %s", string(data))
}
func classifyToolResultText(result *edition.ToolResult) error {
if result == nil {
return fmt.Errorf("empty tool result")
}
for _, c := range result.Content {
if c.Type != "text" || c.Text == "" {
continue
}
return apperrors.ClassifyMCPResponseText(c.Text)
}
return nil
}
func shouldEmitRawPATResult(cmd *cobra.Command) bool {
if commandBoolFlag(cmd, "verbose") {
return true
}
if !commandFlagChanged(cmd, "format") {
return false
}
format := strings.ToLower(strings.TrimSpace(commandStringFlag(cmd, "format")))
return format == "json" || format == "raw"
}
func commandBoolFlag(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
if flag := cmd.Flags().Lookup(name); flag != nil {
value, err := cmd.Flags().GetBool(name)
return err == nil && value
}
root := cmd.Root()
value, err := root.PersistentFlags().GetBool(name)
return err == nil && value
}
func commandFlagChanged(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
if flag := cmd.Flags().Lookup(name); flag != nil && flag.Changed {
return true
}
root := cmd.Root()
flag := root.PersistentFlags().Lookup(name)
return flag != nil && flag.Changed
}
func commandStringFlag(cmd *cobra.Command, name string) string {
if cmd == nil {
return ""
}
if flag := cmd.Flags().Lookup(name); flag != nil {
value, _ := cmd.Flags().GetString(name)
return value
}
root := cmd.Root()
value, _ := root.PersistentFlags().GetString(name)
return value
}
func formatPATAuthorizationSummary(text string, caller edition.ToolCaller) string {
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return ""
}
data, _ := body["data"].(map[string]any)
if data == nil {
return ""
}
lines := []string{"PAT authorization"}
if code := stringField(body, "code"); code != "" {
lines = append(lines, "status: "+code)
} else if success, ok := body["success"].(bool); ok {
lines = append(lines, fmt.Sprintf("success: %v", success))
}
if agentCode := stringField(data, "agentCode"); agentCode != "" {
lines = append(lines, "agentCode: "+agentCode)
}
if grantType := stringField(data, "grantType"); grantType != "" {
lines = append(lines, "grantType: "+grantType)
}
if allGranted, ok := data["allGranted"].(bool); ok {
lines = append(lines, fmt.Sprintf("allGranted: %v", allGranted))
}
appendCountLine := func(label, key string) {
if count, ok := countField(data, key); ok {
lines = append(lines, fmt.Sprintf("%s: %d", label, count))
}
}
appendCountLine("items", "items")
appendCountLine("selected", "selectedScopes")
appendCountLine("granted", "grantedScopes")
appendCountLine("alreadyGranted", "alreadyGrantedScopes")
appendCountLine("skipped", "skippedScopes")
appendCountLine("pending", "pendingScopes")
lines = append(lines, "suggestion: "+patAuthorizationSuggestion(data, caller))
lines = append(lines, "hint: use --format json or --verbose for full scope details")
return strings.Join(lines, "\n") + "\n"
}
func patAuthorizationSuggestion(data map[string]any, caller edition.ToolCaller) string {
if allGranted, ok := data["allGranted"].(bool); ok && allGranted {
return "no action needed"
}
if count, ok := countField(data, "selectedScopes"); ok && count > 0 {
if caller != nil && caller.DryRun() {
return "rerun this command without --dry-run to grant selected scopes"
}
return "selected scopes are ready to grant"
}
if count, ok := countField(data, "pendingScopes"); ok && count > 0 {
return "complete authorization, then retry the command"
}
return "check auth status or rerun with --format json for details"
}
func countField(data map[string]any, key string) (int, bool) {
raw, ok := data[key]
if !ok {
return 0, false
}
switch v := raw.(type) {
case []any:
return len(v), true
case []string:
return len(v), true
}
return 0, false
}
func stringField(data map[string]any, key string) string {
if value, ok := data[key].(string); ok {
return strings.TrimSpace(value)
}
return ""
}