diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
index c3ca976b..02b796f7 100644
--- a/.github/badges/coverage.svg
+++ b/.github/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/internal/app/runner.go b/internal/app/runner.go
index eb026b15..bc4b48dd 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
hostOwnedPATDecisionOnce.Do(func() {
slog.Debug("runtime.host_owned_pat",
"hostOwned", authpkg.HostOwnsPATFlow(),
- "agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
+ "agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
)
})
}
@@ -687,9 +687,10 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
+ agentCode, _ := authpkg.AgentCodeFromEnv()
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
- "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
+ "x-dingtalk-dws-agent-code": agentCode,
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": sessionID,
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go
index fd3fd673..1327e567 100644
--- a/internal/app/runner_test.go
+++ b/internal/app/runner_test.go
@@ -328,6 +328,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
+func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
+ setupRuntimeCommandTest(t)
+ t.Setenv(authpkg.AgentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
+
+ headers := resolveIdentityHeaders()
+ if got := headers["x-dingtalk-dws-agent-code"]; got != "" {
+ t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got)
+ }
+}
+
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(envDingtalkSessionID, "ding-session")
diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go
index 53e0decb..263f8941 100644
--- a/internal/app/skill_command.go
+++ b/internal/app/skill_command.go
@@ -40,14 +40,14 @@ func init() {
Name: "DWS_SKILL_API_HOST",
Category: configmeta.CategoryNetwork,
Description: "覆盖 Skill API 地址",
- DefaultValue: "https://mcp.dingtalk.com",
+ DefaultValue: "https://pre-mcp.dingtalk.com",
Example: "https://custom-mcp.example.com",
})
}
const (
// legacySkillAPIHost is the legacy skill market host used by the old cli.
- legacySkillAPIHost = "https://mcp.dingtalk.com"
+ legacySkillAPIHost = "https://pre-mcp.dingtalk.com"
// skillDownloadEndpoint is the API endpoint for downloading skills.
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
diff --git a/internal/auth/channel.go b/internal/auth/channel.go
index e9c1f49d..4b8315a3 100644
--- a/internal/auth/channel.go
+++ b/internal/auth/channel.go
@@ -19,19 +19,38 @@ import (
)
const (
- // AgentCodeEnv is the sole per-spawn environment variable the host injects
- // to declare "this process is driven by a third-party Agent host, render
- // authorization UI yourselves".
+ // AgentCodeEnv is the primary per-spawn environment variable the host
+ // injects to declare "this process is driven by a third-party Agent host,
+ // render authorization UI yourselves".
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
)
+// AgentCodeFromEnv returns the effective host agent code and the env name that
+// supplied it.
+//
+// Keep the public env surface intentionally single-spelled. The reversed
+// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
+// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
+// the same stable signal: DINGTALK_DWS_AGENTCODE.
+func AgentCodeFromEnv() (string, string) {
+ if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
+ return value, AgentCodeEnv
+ }
+ return "", ""
+}
+
+func AgentCodeEnvPresent() bool {
+ value, _ := AgentCodeFromEnv()
+ return value != ""
+}
+
// HostOwnsPATFlow reports whether the current process is running under a
// third-party Agent host that will render the PAT authorization card
-// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
-// non-empty. The CLI deliberately does not consult any other signal
-// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
-// decision so that server-side routing tags and the host-owned UI contract
-// remain independent concerns.
+// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
+// deliberately does not consult any other signal (DINGTALK_AGENT /
+// DWS_CHANNEL / the wire claw-type header) for this decision so that
+// server-side routing tags and the host-owned UI contract remain independent
+// concerns.
func HostOwnsPATFlow() bool {
- return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
+ return AgentCodeEnvPresent()
}
diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go
index 3f3b3303..24d5204a 100644
--- a/internal/auth/endpoints.go
+++ b/internal/auth/endpoints.go
@@ -129,7 +129,7 @@ func GetDeveloperSettingsURL() string {
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for pre-release environment)
-// 2. Default value (https://mcp.dingtalk.com)
+// 2. Default value (https://pre-mcp.dingtalk.com)
func GetMCPBaseURL() string {
return config.GetMCPBaseURL()
}
diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go
index 2bd5e0b6..7e009431 100644
--- a/internal/cli/canonical_test.go
+++ b/internal/cli/canonical_test.go
@@ -1285,7 +1285,7 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) {
degradedErr := &CatalogDegraded{
Reason: DegradedMarketUnreachable,
- Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络",
+ Hint: "无法连接 MCP 市场,请检查网络",
}
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
diff --git a/internal/cli/loader.go b/internal/cli/loader.go
index b67edce9..3810be5e 100644
--- a/internal/cli/loader.go
+++ b/internal/cli/loader.go
@@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
if embedded {
return "无法连接 MCP 市场,请检查网络"
}
- return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络"
+ return "无法连接 MCP 市场,请检查网络"
case DegradedRuntimeAllFailed:
if embedded {
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go
index c6c474b3..5667c9fc 100644
--- a/internal/pat/browser_policy.go
+++ b/internal/pat/browser_policy.go
@@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
}
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
- agentCode, err := resolveAgentCode(explicitAgentCode, false)
+ agentCode, err := resolveAgentCode(explicitAgentCode)
if err != nil {
return BrowserPolicySelection{}, err
}
diff --git a/internal/pat/browser_policy_test.go b/internal/pat/browser_policy_test.go
index 7819f2c3..146fb352 100644
--- a/internal/pat/browser_policy_test.go
+++ b/internal/pat/browser_policy_test.go
@@ -16,6 +16,7 @@ package pat
import (
"bytes"
"encoding/json"
+ "strings"
"testing"
)
@@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.
t.Fatalf("len(policy.Agents) = %d, want 0", got)
}
}
+
+func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) {
+ configDir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+
+ cmd := newBrowserPolicyCommand()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stdout)
+ cmd.SetArgs([]string{"--agentCode", "agt-command"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("browser-policy Execute() error = nil, want missing --enabled error")
+ }
+ if !strings.Contains(err.Error(), "--enabled is required") {
+ t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error())
+ }
+
+ policy, loadErr := LoadBrowserPolicy(configDir)
+ if loadErr != nil {
+ t.Fatalf("LoadBrowserPolicy error = %v", loadErr)
+ }
+ if policy.Default != nil || len(policy.Agents) != 0 {
+ t.Fatalf("policy was modified despite missing --enabled: %#v", policy)
+ }
+}
diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go
index c5cafdcb..c7cb18ac 100644
--- a/internal/pat/chmod.go
+++ b/internal/pat/chmod.go
@@ -25,6 +25,7 @@ import (
"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"
)
@@ -58,23 +59,24 @@ func resolveSessionIDFromEnv() string {
return ""
}
-// agentCodeEnv is the canonical (and only) environment variable name
-// used as a per-shell fallback for the --agentCode flag on `dws pat *`
-// commands.
+// 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, the request is
-// sent without agentCode and lippi-pat-core applies its default agentCode.
+// 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: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
-// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
-// hard-removed once the public integration surface landed on
-// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
-// silent fallback.
-const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
+// 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
@@ -84,16 +86,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// 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 is unset or blank. No legacy aliases are honored.
+// 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) {
- primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
- if primary != "" {
- return primary, agentCodeEnv
- }
- return "", ""
+ return authpkg.AgentCodeFromEnv()
}
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
@@ -111,34 +109,34 @@ func validateAgentCode(code string) error {
return nil
}
-// resolveAgentCode implements the canonical two-tier lookup for
-// --agentCode:
+// 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 ("") when required=false; typed error when required=true.
+// 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, required bool) (string, error) {
+func resolveAgentCode(flagVal string) (string, error) {
code := strings.TrimSpace(flagVal)
envSource := ""
if code == "" {
code, envSource = resolveAgentCodeFromEnv()
}
if code == "" {
- if required {
- return "", fmt.Errorf(
- "flag --agentCode is required (or set env %s)\n hint: dws pat chmod ... --agentCode \n hint: export %s=",
- agentCodeEnv, agentCodeEnv)
- }
return "", nil
}
if err := validateAgentCode(code); err != nil {
if envSource != "" {
- return "", fmt.Errorf("%s env: %w", envSource, err)
+ return "", apperrors.NewValidation(
+ fmt.Sprintf("%s env: %v", envSource, err),
+ apperrors.WithReason("invalid_agent_code"),
+ )
}
- return "", err
+ return "", apperrors.NewValidation(
+ err.Error(),
+ apperrors.WithReason("invalid_agent_code"),
+ )
}
return code, nil
}
@@ -159,8 +157,25 @@ const (
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,
@@ -189,7 +204,21 @@ scope 格式: .:
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
- permanent 永久有效`,
+ 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 {
@@ -199,12 +228,14 @@ grantType 规则:
},
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 --products calendar,aitable --grant-type session --session-id session-xxx
- dws pat chmod --recommend --grant-type session --session-id session-xxx`,
+ 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, false)
+ agentCode, err := resolveAgentCode(flagVal)
if err != nil {
return err
}
@@ -232,6 +263,9 @@ grantType 规则:
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)
@@ -239,8 +273,6 @@ grantType 规则:
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
if agentCode != "" {
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
- } else {
- fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)")
}
fmt.Printf("%-16s%v\n", "Scope:", scopes)
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
@@ -260,6 +292,9 @@ grantType 规则:
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
@@ -267,6 +302,11 @@ grantType 规则:
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,
@@ -309,24 +349,46 @@ grantType 规则:
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 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)")
+ "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 集合批量授权")
+ 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 ... --grant-type once --yes",
+ "dws pat chmod --products --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)
@@ -388,13 +450,11 @@ func callPATBatchGrantWithLegacyFallback(
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
- result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
- return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs)
- })
- if err == nil && !isPATBatchUnsupportedResult(result) {
+ result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
+ if err == nil && !isPATBatchFallbackResult(result) {
return result, nil
}
- if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) {
+ if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
return nil, err
}
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
@@ -414,9 +474,27 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
- return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
- return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
+ 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 {
@@ -485,6 +563,25 @@ func firstToolResultText(result *edition.ToolResult) string {
}
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
@@ -494,7 +591,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return false
}
for _, key := range []string{"code", "errorCode", "error_code"} {
- if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
+ if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
return true
}
}
@@ -505,6 +602,124 @@ 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
+}
+
+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 ""
+}
+
// 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
diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go
index 57443096..34d46a50 100644
--- a/internal/pat/chmod_test.go
+++ b/internal/pat/chmod_test.go
@@ -29,8 +29,8 @@ import (
)
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
-// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
-// resolver feeds into the outgoing MCP argv.
+// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
+// into the outgoing batch request.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
@@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
type recordedToolCall struct {
- tool string
- args map[string]any
+ tool string
+ args map[string]any
+ agentEnv string
+ sessionEnv string
+ dingSessionEnv string
}
type fallbackToolCaller struct {
@@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
type sequenceToolCaller struct {
calls []recordedToolCall
responses []string
+ errs []error
dryRun bool
}
@@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
copied[k] = v
}
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
+ s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
+ s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
+ s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
+ if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
+ return nil, s.errs[len(s.calls)-1]
+ }
response := `{"success":true,"data":{}}`
if len(s.responses) >= len(s.calls) {
response = s.responses[len(s.calls)-1]
@@ -257,6 +267,37 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
return newChmodCommand(fake)
}
+func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
+ t.Helper()
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().Bool("yes", false, "skip confirmation")
+ root.AddCommand(cmd)
+ if yes {
+ if err := root.PersistentFlags().Set("yes", "true"); err != nil {
+ t.Fatalf("set root --yes: %v", err)
+ }
+ }
+}
+
+func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
+ t.Helper()
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().Bool("yes", false, "skip confirmation")
+ root.PersistentFlags().String("format", "json", "")
+ root.PersistentFlags().Bool("verbose", false, "")
+ root.AddCommand(cmd)
+ if yes {
+ if err := root.PersistentFlags().Set("yes", "true"); err != nil {
+ t.Fatalf("set root --yes: %v", err)
+ }
+ }
+ if formatChanged {
+ if err := root.PersistentFlags().Set("format", "json"); err != nil {
+ t.Fatalf("set root --format: %v", err)
+ }
+ }
+}
+
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
@@ -279,6 +320,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
}
}
+func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
+ root := &cobra.Command{Use: "dws"}
+ RegisterCommands(root, &fakeToolCaller{})
+
+ patCmd, _, err := root.Find([]string{"pat"})
+ if err != nil {
+ t.Fatalf("pat command not found: %v", err)
+ }
+ var out strings.Builder
+ patCmd.SetOut(&out)
+ patCmd.SetErr(&out)
+ if err := patCmd.Help(); err != nil {
+ t.Fatalf("pat help error = %v", err)
+ }
+ patHelp := out.String()
+ for _, want := range []string{
+ "支持批量授权",
+ "--products / --product",
+ "--domains / --domain",
+ "--recommend",
+ "DINGTALK_DWS_AGENTCODE",
+ "未传 agentCode 时由服务端默认兜底",
+ } {
+ if !strings.Contains(patHelp, want) {
+ t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
+ }
+ }
+
+ chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
+ if err != nil {
+ t.Fatalf("pat chmod command not found: %v", err)
+ }
+ out.Reset()
+ chmodCmd.SetOut(&out)
+ chmodCmd.SetErr(&out)
+ if err := chmodCmd.Help(); err != nil {
+ t.Fatalf("pat chmod help error = %v", err)
+ }
+ chmodHelp := out.String()
+ for _, want := range []string{
+ "批量授权:",
+ "一次传多个 scope",
+ "batch plan",
+ "--dry-run 只返回授权计划",
+ "执行批量授权必须显式",
+ "由服务端默认兜底",
+ "aitable.record:read aitable.record:write --grant-type permanent --yes",
+ "dws pat chmod --product calendar --product aitable",
+ "dws pat chmod --domain calendar --domain chat",
+ } {
+ if !strings.Contains(chmodHelp, want) {
+ t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
+ }
+ }
+}
+
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
@@ -288,6 +385,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -305,8 +403,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[0].args["recommend"]; got != false {
t.Fatalf("recommend = %#v, want false", got)
}
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
@@ -314,12 +415,56 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("grant agent env = %q, want qoderwork", got)
+ }
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
+ }
+}
+
+func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+
+ err := cmd.RunE(cmd, nil)
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want plan only", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+}
+
+func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) {
+ fake := &fakeToolCaller{resultOK: true}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"})
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if fake.callN != 0 {
+ t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN)
}
}
-func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
+func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
@@ -328,6 +473,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("products", "calendar")
_ = cmd.Flags().Set("session-id", "session-123")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -339,20 +485,423 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
if got := fake.calls[0].args["grantType"]; got != "session" {
t.Fatalf("plan grantType = %#v, want session", got)
}
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
t.Fatalf("plan sessionId = %#v, want session-123", got)
}
- if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[0].dingSessionEnv != "session-123" {
+ t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
if got := fake.calls[1].args["grantType"]; got != "session" {
t.Fatalf("grant grantType = %#v, want session", got)
}
+ if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
t.Fatalf("grant sessionId = %#v, want session-123", got)
}
- if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("grant agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[1].dingSessionEnv != "session-123" {
+ t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
+ }
+}
+
+func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
+ t.Setenv(agentCodeEnv, "")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, []string{"contact.user:get-self"})
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if _, ok := fake.calls[0].args["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
+ }
+ if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
+ t.Fatalf("summary output missing server default agentCode:\n%s", output)
+ }
+}
+
+func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
+ t.Setenv(agentCodeEnv, "envshouldlose")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("agentCode", "qoderwork")
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, []string{"chat.bot:search"})
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") {
+ t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
+ }
+}
+
+func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ setFlags func(*cobra.Command)
+ wantPlanProducts []string
+ wantRecommend bool
+ wantCallCount int
+ }{
+ {
+ name: "direct multi scope",
+ args: []string{"calendar.event:list", "calendar.event:create"},
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ },
+ wantCallCount: 1,
+ },
+ {
+ name: "product repeated",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("product", "calendar")
+ _ = cmd.Flags().Set("product", "aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ wantCallCount: 2,
+ },
+ {
+ name: "products comma list",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ wantCallCount: 2,
+ },
+ {
+ name: "domain repeated",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domain", "calendar")
+ _ = cmd.Flags().Set("domain", "chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ wantCallCount: 2,
+ },
+ {
+ name: "domains comma list",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domains", "calendar,chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ wantCallCount: 2,
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantRecommend: true,
+ wantCallCount: 2,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ responses := []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }
+ if tc.wantCallCount == 2 {
+ responses = []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }
+ }
+ fake := &sequenceToolCaller{responses: responses}
+ cmd := newChmodCommand(fake)
+ tc.setFlags(cmd)
+ attachRootPATFlags(t, cmd, true, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, tc.args)
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != tc.wantCallCount {
+ t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
+ }
+ if tc.wantCallCount == 1 {
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
+ t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
+ }
+ } else {
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+ if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
+ t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
+ }
+ if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
+ t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
+ }
+ if fake.calls[1].tool != patBatchGrantToolName {
+ t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
+ t.Fatalf("grant scopes = %#v, want selected scopes", got)
+ }
+ }
+ last := fake.calls[len(fake.calls)-1]
+ if got := last.args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") {
+ t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
+ }
+ })
+ }
+}
+
+func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
+ cases := []struct {
+ name string
+ setFlags func(*cobra.Command)
+ wantPlanProducts []string
+ wantRecommend bool
+ }{
+ {
+ name: "product",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ },
+ {
+ name: "domain",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("domains", "calendar,chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantRecommend: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ dryRun: true,
+ responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ tc.setFlags(cmd)
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, nil)
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+ if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
+ t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
+ }
+ if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
+ t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
+ t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
+ }
+ })
+ }
+}
+
+func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ setFlags func(*cobra.Command)
+ wantPlan bool
+ }{
+ {
+ name: "direct multi scope",
+ args: []string{"calendar.event:list", "calendar.event:create"},
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ },
+ },
+ {
+ name: "product",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar")
+ },
+ wantPlan: true,
+ },
+ {
+ name: "domain",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domains", "calendar")
+ },
+ wantPlan: true,
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantPlan: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ tc.setFlags(cmd)
+
+ err := cmd.RunE(cmd, tc.args)
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if tc.wantPlan {
+ if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls)
+ }
+ return
+ }
+ if len(fake.calls) != 0 {
+ t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls))
+ }
+ })
+ }
+}
+
+func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
+ cases := []struct {
+ name string
+ grantType string
+ sessionFlag string
+ sessionEnv string
+ wantSessionID string
+ wantErr string
+ }{
+ {name: "once no session", grantType: "once"},
+ {name: "permanent no session", grantType: "permanent"},
+ {name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
+ {name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
+ {name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
+ {name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ if tc.sessionEnv != "" {
+ t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
+ }
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", tc.grantType)
+ if tc.sessionFlag != "" {
+ _ = cmd.Flags().Set("session-id", tc.sessionFlag)
+ }
+
+ err := cmd.RunE(cmd, []string{"aitable.record:read"})
+ if tc.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
+ }
+ if len(fake.calls) != 0 {
+ t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if got := fake.calls[0].args["grantType"]; got != tc.grantType {
+ t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
+ }
+ if tc.wantSessionID == "" {
+ if _, ok := fake.calls[0].args["sessionId"]; ok {
+ t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
+ }
+ return
+ }
+ if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
+ t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
+ }
+ if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
+ t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
+ }
+ })
}
}
@@ -378,11 +927,167 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
}
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[0].dingSessionEnv != "env-session-123" {
+ t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
+ }
+}
+
+func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"data":{"agentCode":"qoderwork","allGranted":true,"selectedScopes":[]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar")
+
+ if err := cmd.RunE(cmd, nil); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
+ t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
+ }
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
+ }
+ if _, ok := fake.calls[1].args["agentCode"]; ok {
+ t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
+ }
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"data":{"agentCode":"qoderwork","grantedScopes":["calendar.event:read"]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
+ t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
+ }
+ if _, ok := fake.calls[1].args["agentCode"]; ok {
+ t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
+ }
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_batchGrantIdentityFallbackRejectsMismatchedAgentCode(t *testing.T) {
+ t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"result":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "permanent")
+
+ err := cmd.RunE(cmd, []string{"chat.message:send"})
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want identity fallback agentCode mismatch")
+ }
+ if !strings.Contains(err.Error(), "identity fallback returned agentCode") ||
+ !strings.Contains(err.Error(), "dingmbw5n9ktkkbbjv3g") ||
+ !strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") {
+ t.Fatalf("error = %q, want fallback mismatch details", err.Error())
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
+ }
+ if _, ok := fake.calls[1].args["agentCode"]; ok {
+ t.Fatalf("compat retry should still omit agentCode arg, got %#v", fake.calls[1].args)
+ }
+ if got := fake.calls[1].agentEnv; got != "dinglqdkz3mmw2xwvend" {
+ t.Fatalf("compat retry %s = %q, want requested agentCode", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_batchGrantIdentityFallbackRejectsMissingAgentCode(t *testing.T) {
+ t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"data":{"grantedScopes":["chat.message:send"]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "permanent")
+
+ err := cmd.RunE(cmd, []string{"chat.message:send"})
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want unverifiable fallback error")
+ }
+ if !strings.Contains(err.Error(), "authorization target cannot be verified") {
+ t.Fatalf("error = %q, want unverifiable fallback details", err.Error())
}
}
@@ -407,6 +1112,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
}
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
fake := &fakeToolCaller{resultOK: true}
@@ -416,6 +1122,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
+ if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
}
@@ -428,6 +1137,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
}
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "stale-session")
fake := &fakeToolCaller{resultOK: true}
@@ -438,6 +1148,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
+ if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
t.Fatalf("sessionId arg = %#v, want flag-session", got)
}
@@ -458,6 +1171,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -523,7 +1237,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
-// the env value up and forwards it verbatim in the MCP argv.
+// the env value up for both batch arguments and gateway-compatible env.
func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
@@ -543,7 +1257,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
- t.Fatalf("batch argv agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
@@ -553,8 +1267,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
}
}
-func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
+func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
@@ -566,14 +1281,35 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
+ if _, ok := fake.gotArgs["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
+ }
if got := fake.gotAgentEnv; got != "" {
- t.Fatalf("agent env = %q, want empty so server default agentCode is used", got)
+ t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
+ t.Setenv(agentCodeEnv, "")
+
+ fake := &fakeToolCaller{resultOK: true}
+ cmd := buildChmod(t, fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
+ t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
+ }
+ if fake.callN != 1 {
+ t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
+ }
+ if fake.gotTool != patBatchGrantToolName {
+ t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
- t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs)
+ t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
}
- if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
- t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
+ if got := fake.gotAgentEnv; got != "" {
+ t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
}
}
@@ -786,6 +1522,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t
}
}
+func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
+ fake := &sequenceToolCaller{
+ responses: []string{
+ `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
+ `{"success":true,"data":{"authRequestId":"req-ok"}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("agentCode", "qoderwork")
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if fake.calls[1].tool != patGrantToolName {
+ t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("batch agentCode = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
+ t.Fatalf("fallback scopes = %#v, want mail:send", got)
+ }
+}
+
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
if !isToolNotRegisteredError(err) {
@@ -813,6 +1583,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
}
}
+func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
+ result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
+ if !isPATBatchFallbackResult(result) {
+ t.Fatal("isPATBatchFallbackResult() = false, want true")
+ }
+}
+
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
@@ -825,6 +1602,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
}
}
+func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
+ err := apperrors.NewAPI("business error: success=false",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
+ }),
+ )
+ if !isPATBatchFallbackError(err) {
+ t.Fatal("isPATBatchFallbackError() = false, want true")
+ }
+}
+
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(nil, nil, &edition.ToolResult{})
if err == nil {
@@ -978,34 +1767,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
- t.Fatalf("batch argv agentCode = %#v, want flagval", got)
+ t.Fatalf("batch agentCode = %#v, want flagval", got)
}
}
-// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
-// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
-// legacy env MUST NOT be consumed. The command is still allowed to run,
-// omits agentCode, and lets lippi-pat-core write its default agentCode.
+// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
+// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
+// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
+// apply its open-source default.
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_AGENTCODE", "legacyval")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
- t.Fatalf("chmod RunE error = %v", err)
+ t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
- t.Fatalf("CallTool was invoked %d times, want 1", fake.callN)
+ t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
+ }
+ if _, ok := fake.gotArgs["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
}
- if _, ok := fake.gotArgs["agentCode"]; ok {
- t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs)
- }
}
// ---------------------------------------------------------------------------
@@ -1046,8 +1836,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
code, src, "qoderwork", agentCodeEnv)
}
+ // Reverse-guard: the draft reversed spelling is intentionally ignored.
+ t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
+ if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
+ t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
+ code, src)
+ }
+
// Empty primary → ("", "").
t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
}
diff --git a/internal/pat/pat.go b/internal/pat/pat.go
index 3d17b11c..5e535f00 100644
--- a/internal/pat/pat.go
+++ b/internal/pat/pat.go
@@ -36,8 +36,17 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
能力说明:
pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时,
才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。
+ pat chmod 支持批量授权:可一次传多个 scope,也可通过
+ --products / --product、--domains / --domain 或 --recommend
+ 让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。
+ 批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划,
+ 不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes
+ 时 CLI 会阻断并提示 agent 先确认。
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
- 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
+ pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE;
+ CLI 会把显式 agentCode 放入 batch 请求参数,
+ 并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。
+ 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
Host-owned PAT 开关:
diff --git a/pkg/config/constants.go b/pkg/config/constants.go
index c986e45a..8f2bbe65 100644
--- a/pkg/config/constants.go
+++ b/pkg/config/constants.go
@@ -163,7 +163,7 @@ const (
const (
// DefaultMCPBaseURL is the DingTalk MCP base URL.
// Override at runtime via ~/.dws/mcp_url file.
- DefaultMCPBaseURL = "https://mcp.dingtalk.com"
+ DefaultMCPBaseURL = "https://pre-mcp.dingtalk.com"
// DefaultTerminalBaseURL is the DingTalk developer platform base URL.
// Override at runtime via ~/.dws/terminal_url file.
@@ -189,7 +189,7 @@ func DefaultConfigDir() string {
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for custom environment)
-// 2. Default value (https://mcp.dingtalk.com)
+// 2. Default value (https://pre-mcp.dingtalk.com)
func GetMCPBaseURL() string {
mcpURLPath := filepath.Join(DefaultConfigDir(), "mcp_url")
if data, err := os.ReadFile(mcpURLPath); err == nil {
diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go
index c9e538b0..88aa4698 100644
--- a/pkg/config/constants_test.go
+++ b/pkg/config/constants_test.go
@@ -3,7 +3,6 @@ package config
import (
"os"
"path/filepath"
- "strings"
"testing"
"time"
)
@@ -195,16 +194,13 @@ func TestDefaultFetchServersLimit(t *testing.T) {
}
}
-func TestGetMCPBaseURLDefaultsToProduction(t *testing.T) {
+func TestGetMCPBaseURLDefaultsToPrepub(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
got := GetMCPBaseURL()
- if got != "https://mcp.dingtalk.com" {
- t.Fatalf("GetMCPBaseURL() = %q, want production MCP URL", got)
- }
- if strings.Contains(got, "pre-mcp") {
- t.Fatalf("GetMCPBaseURL() = %q, must not default to prepub MCP URL", got)
+ if got != "https://pre-mcp.dingtalk.com" {
+ t.Fatalf("GetMCPBaseURL() = %q, want prepub MCP URL", got)
}
}
diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go
index 77de05d3..095a2f21 100644
--- a/test/unit/pat_host_owned_signal_test.go
+++ b/test/unit/pat_host_owned_signal_test.go
@@ -22,8 +22,8 @@ import (
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
// "custom authorization card" contract: the CLI switches to host-owned
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
-// claw-type is purely a server-side routing tag and must NOT influence
-// the decision, in either direction.
+// claw-type is purely a server-side routing tag and must NOT influence the
+// decision, in either direction.
//
// Regression guard: several earlier drafts conflated the two signals,
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
@@ -33,6 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
cases := []struct {
name string
agentCode string
+ reversed string
agentEnv string
want bool
}{
@@ -48,6 +49,13 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
agentEnv: "",
want: true,
},
+ {
+ name: "reversed draft agent code only → CLI-owned",
+ agentCode: "",
+ reversed: "agt-compat",
+ agentEnv: "",
+ want: false,
+ },
{
name: "agent code + DINGTALK_AGENT=default → host-owned",
agentCode: "agt-cursor",
@@ -84,6 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
+ t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed)
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
// influence the host-owned decision. The literal env name is
// used here because the auth package no longer exports a
@@ -97,6 +106,15 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
got, tc.want, tc.agentCode, tc.agentEnv,
)
}
+ if tc.want {
+ gotCode, gotSource := authpkg.AgentCodeFromEnv()
+ wantCode := tc.agentCode
+ wantSource := authpkg.AgentCodeEnv
+ if gotCode != wantCode || gotSource != wantSource {
+ t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)",
+ gotCode, gotSource, wantCode, wantSource)
+ }
+ }
})
}
}