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 @@ -coverage: 57.5%coverage57.5% \ No newline at end of file +coverage: 57.5%coverage57.5% 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) + } + } }) } }