Files
dingtalk-workspace-cli/internal/pat/chmod.go
T
2026-06-25 11:10:25 +08:00

1463 lines
44 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 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"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type LoginRecommendOptions struct {
// ProductCodes limits the recommend plan to service-owned product domains.
ProductCodes []string
// 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
)
// 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"
patCallerBatchChmodInteractive = "batch_chmod_interactive"
)
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:read chat.group:write calendar.event:read
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 时才直接写入服务端选中的权限。批量授权未显式指定
--grant-type 且没有 session-id 时,会以 permanent 作为本次页面确认后的授权时长。
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:read --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent
dws pat chmod --recommend --product minutes --grant-type permanent --dry-run
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx
dws pat chmod --recommend --grant-type session --session-id session-xxx`,
RunE: func(cmd *cobra.Command, args []string) error {
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 || len(scopes) > 1
grantType, _ := cmd.Flags().GetString("grant-type")
sessionID, _ := cmd.Flags().GetString("session-id")
if sessionID == "" {
sessionID = resolveSessionIDFromEnv()
}
interactiveBatchFlow := usesPlan && !batchAuthorizationConfirmed(cmd)
if interactiveBatchFlow && grantType == "session" && sessionID == "" && !cmd.Flags().Changed("grant-type") {
grantType = grantTypePermanent
}
if !validGrantTypes[grantType] {
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
}
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>")
}
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)
if interactiveBatchFlow {
planArgs["caller"] = patCallerBatchChmodInteractive
}
planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
if err != nil {
return fmt.Errorf("pat chmod plan failed: %w", err)
}
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)
}
}
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
}
if interactiveBatchFlow {
batchArgs["startFlow"] = true
batchArgs["noWait"] = true
batchArgs["caller"] = patCallerBatchChmodInteractive
batchArgs["clientRequestId"] = newBatchClientRequestID("chmod")
}
// Legacy server schema accepted singular "scope"; clone the
// canonical argv and rename the key so the two payloads stay
// in lock-step on every other field.
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()
var result *edition.ToolResult
if interactiveBatchFlow {
result, err = callPATBatchToolWithIdentityFallback(
ctx,
c,
agentCode,
sessionID,
patBatchGrantToolName,
batchArgs,
)
} else {
result, err = callPATBatchGrantWithLegacyFallback(
ctx,
c,
agentCode,
sessionID,
batchArgs,
toolArgs,
legacyToolArgs,
)
}
if err != nil {
return fmt.Errorf("pat chmod failed: %w", err)
}
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", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价")
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔")
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权")
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔")
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权")
return chmodCmd
}
// batchAuthorizationConfirmed reads the inherited root --yes flag without
// making chmod own that flag. Only batch/product grants use this gate; explicit
// single-scope chmod keeps the historical non-interactive behavior.
func batchAuthorizationConfirmed(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
if yes, err := cmd.Flags().GetBool("yes"); err == nil {
return yes
}
if yes, err := cmd.InheritedFlags().GetBool("yes"); err == nil {
return yes
}
return false
}
func collectChmodProductCodes(groups ...[]string) []string {
seen := map[string]bool{}
result := make([]string, 0)
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, err := extractLoginRecommendProducts(planResult)
if err != nil {
return nil, err
}
return &LoginRecommendPlan{
Result: planResult,
Scopes: scopes,
Products: products,
AllGranted: allGranted,
}, nil
}
func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.ToolCaller, output io.Writer, opts LoginRecommendOptions) error {
if c == nil {
return fmt.Errorf("internal error: tool runtime not initialized")
}
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, err := extractBatchPlanAllGranted(planResult)
if err != nil {
return planResult, nil, false, err
}
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, err := extractSelectedScopesAllowEmpty(result)
if err != nil {
return nil, err
}
for _, scope := range scopes {
code := productCodeFromScope(scope)
if code == "" {
continue
}
if groups[code] == nil {
groups[code] = &productGroup{
item: LoginRecommendProduct{
ProductCode: code,
ProductName: code,
ScopeCount: 0,
SelectedScopeCount: 0,
},
}
order = append(order, code)
}
groups[code].item.ScopeCount++
groups[code].item.SelectedScopeCount++
}
}
products := make([]LoginRecommendProduct, 0, len(order))
for _, code := range order {
group := groups[code]
if group == nil {
continue
}
group.item.Summary = strings.Join(group.summaryParts, "、")
products = append(products, group.item)
}
return products, nil
}
func normalizeProductCodes(codes []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(codes))
for _, raw := range codes {
for _, part := range strings.Split(raw, ",") {
code := strings.TrimSpace(part)
if code == "" || seen[code] {
continue
}
seen[code] = true
out = append(out, code)
}
}
return out
}
func productCodeFromScope(scope string) string {
scope = strings.TrimSpace(scope)
if scope == "" {
return ""
}
end := len(scope)
if idx := strings.Index(scope, "."); idx >= 0 && idx < end {
end = idx
}
if idx := strings.Index(scope, ":"); idx >= 0 && idx < end {
end = idx
}
return strings.TrimSpace(scope[:end])
}
func 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()
if root == nil {
return false
}
value, err := root.PersistentFlags().GetBool(name)
return err == nil && value
}
func commandFlagChanged(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
if flag := cmd.Flags().Lookup(name); flag != nil && flag.Changed {
return true
}
root := cmd.Root()
if root == nil {
return false
}
flag := root.PersistentFlags().Lookup(name)
return flag != nil && flag.Changed
}
func commandStringFlag(cmd *cobra.Command, name string) string {
if cmd == nil {
return ""
}
if flag := cmd.Flags().Lookup(name); flag != nil {
value, _ := cmd.Flags().GetString(name)
return value
}
root := cmd.Root()
if root == nil {
return ""
}
value, _ := root.PersistentFlags().GetString(name)
return value
}
func formatPATAuthorizationSummary(text string, caller edition.ToolCaller) string {
var body map[string]any
if err := json.Unmarshal([]byte(text), &body); err != nil {
return ""
}
data, _ := body["data"].(map[string]any)
if data == nil {
return ""
}
lines := []string{"PAT authorization"}
if code := stringField(body, "code"); code != "" {
lines = append(lines, "status: "+code)
} else if success, ok := body["success"].(bool); ok {
lines = append(lines, fmt.Sprintf("success: %v", success))
}
if agentCode := stringField(data, "agentCode"); agentCode != "" {
lines = append(lines, "agentCode: "+agentCode)
}
if grantType := stringField(data, "grantType"); grantType != "" {
lines = append(lines, "grantType: "+grantType)
}
if allGranted, ok := data["allGranted"].(bool); ok {
lines = append(lines, fmt.Sprintf("allGranted: %v", allGranted))
}
appendCountLine := func(label, key string) {
if count, ok := countField(data, key); ok {
lines = append(lines, fmt.Sprintf("%s: %d", label, count))
}
}
appendCountLine("items", "items")
appendCountLine("selected", "selectedScopes")
appendCountLine("granted", "grantedScopes")
appendCountLine("alreadyGranted", "alreadyGrantedScopes")
appendCountLine("skipped", "skippedScopes")
appendCountLine("pending", "pendingScopes")
lines = append(lines, "suggestion: "+patAuthorizationSuggestion(data, caller))
lines = append(lines, "hint: use --format json or --verbose for full scope details")
return strings.Join(lines, "\n") + "\n"
}
func patAuthorizationSuggestion(data map[string]any, caller edition.ToolCaller) string {
if allGranted, ok := data["allGranted"].(bool); ok && allGranted {
return "no action needed"
}
if count, ok := countField(data, "selectedScopes"); ok && count > 0 {
if caller != nil && caller.DryRun() {
return "rerun this command without --dry-run to grant selected scopes"
}
return "selected scopes are ready to grant"
}
if count, ok := countField(data, "pendingScopes"); ok && count > 0 {
return "complete authorization, then retry the command"
}
return "check auth status or rerun with --format json for details"
}
func countField(data map[string]any, key string) (int, bool) {
raw, ok := data[key]
if !ok {
return 0, false
}
switch v := raw.(type) {
case []any:
return len(v), true
case []string:
return len(v), true
}
return 0, false
}
func stringField(data map[string]any, key string) string {
if value, ok := data[key].(string); ok {
return strings.TrimSpace(value)
}
return ""
}