Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd8414891 | ||
|
|
2a82d07311 |
@@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.37] - 2026-06-11
|
||||
|
||||
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
|
||||
|
||||
## [1.0.36] - 2026-06-10
|
||||
|
||||
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+179
-51
@@ -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 <scope>... --agentCode <id>\n hint: export %s=<id>",
|
||||
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 格式: <product>.<entity>:<permission>
|
||||
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
|
||||
}
|
||||
@@ -239,8 +270,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)
|
||||
@@ -267,6 +296,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,
|
||||
@@ -315,18 +349,37 @@ grantType 规则:
|
||||
}
|
||||
|
||||
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 <scope1> <scope2> ... --grant-type once --yes",
|
||||
"dws pat chmod --products <product1,product2> --grant-type once --yes",
|
||||
"dws pat chmod --recommend --grant-type once --yes",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func collectChmodProductCodes(groups ...[]string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0)
|
||||
@@ -388,13 +441,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,8 +465,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
|
||||
}
|
||||
|
||||
func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", toolName, args)
|
||||
})
|
||||
if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
|
||||
return result, err
|
||||
}
|
||||
compatArgs := cloneWithoutPATIdentityArgs(args)
|
||||
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
|
||||
return c.CallTool(ctx, "pat", toolName, compatArgs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -485,6 +547,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 +575,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 +586,53 @@ func isPATBatchUnsupportedError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
|
||||
}
|
||||
|
||||
func isPATBatchFallbackError(err error) bool {
|
||||
if isPATBatchUnsupportedError(err) {
|
||||
return true
|
||||
}
|
||||
text := normalizedPATErrorText(err)
|
||||
for code := range patBatchMetadataContractCodes {
|
||||
if strings.Contains(text, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPATForgedIdentityError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
|
||||
}
|
||||
|
||||
func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
|
||||
if !hasPATIdentityArgs(args) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return isPATForgedIdentityError(err)
|
||||
}
|
||||
return isPATForgedIdentityResult(result)
|
||||
}
|
||||
|
||||
func hasPATIdentityArgs(args map[string]any) bool {
|
||||
for key := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
|
||||
// then silently retries the legacy Chinese alias when the server has not
|
||||
// registered the canonical tool yet. The retry intentionally emits no stderr
|
||||
|
||||
+759
-29
@@ -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_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
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_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,98 @@ 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":{"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":{"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +1043,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 +1053,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 +1068,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 +1079,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 +1102,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 +1168,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 +1188,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 +1198,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 +1212,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 +1453,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 +1514,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 +1533,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 +1698,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 +1767,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)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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 开关:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user