Files
dingtalk-workspace-cli/internal/pat/chmod.go
T
修雨 f522a9c2c2 Merge upstream/main into feat/dws-devapp
Sync with DingTalk-Real-AI/main (1.0.35–1.0.40):
- 1.0.35–1.0.39: chat @-mentions, sheet export, doc strip unsafe chars,
  PAT batch grants, devdoc RAG, agent_code detection, AI-tag opt-in
- 1.0.40 (#486): China mirror via Gitee + npmmirror
- (#488) embed skills/ into binary
- (#464) strip duplicate H1 on JSONML doc-create

# Conflicts:
#	.github/workflows/release.yml
#	CHANGELOG.md
#	README.md
#	README_zh.md
#	docs/command-index.md
#	internal/app/runner.go
#	internal/app/runner_test.go
#	internal/auth/channel.go
#	internal/helpers/devdoc.go
#	internal/helpers/devdoc_test.go
#	internal/pat/chmod.go
#	internal/pat/chmod_test.go
#	internal/pat/pat.go
#	internal/transport/client.go
#	scripts/install-skills.sh
#	scripts/install.ps1
#	scripts/install.sh
#	skills/mono/references/products/devdoc.md
#	skills/mono/references/products/simple.md
#	skills/multi/dingtalk-devdoc/references/devdoc.md
#	skills/multi/dingtalk-skill/references/skill.md
#	test/cli_compat/devdoc_test.go
#	test/cli_compat/helpers_test.go
#	test/skill_tests.md
#	test/skill_tests_results.md
#	test/unit/pat_host_owned_signal_test.go
2026-06-24 20:15:53 +08:00

953 lines
30 KiB
Go
Raw 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"
"os"
"regexp"
"strings"
"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"
)
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"
)
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 时 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: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 --yes
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
dws pat chmod --domain calendar --domain chat --grant-type once --yes
dws pat chmod --recommend --grant-type session --session-id session-xxx --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 !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)
}
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)
}
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)
}
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 等价;执行批量授权需 --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")
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)
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", toolName, compatArgs)
})
}
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
}
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 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 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 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 ""
}