feat(pat)!: A-core loop for host-owned PAT (#142)
* feat(pat): A-core minimum viable loop for host-owned PAT
Enables third-party agents to integrate PAT via the smallest possible
contract surface. The flow a host needs to ship end-to-end:
1. Export DINGTALK_DWS_AGENTCODE in the spawned shell.
2. Run any `dws ...` business command.
3. On PAT hit, CLI exits with code 4 and writes a single-line stderr
JSON following docs/pat/contract.md §2.
4. Host parses the JSON and invokes `dws pat chmod <scope>...
--agentCode ... --grant-type ...` to grant.
5. Host replays the original command.
This PR intentionally ships ONLY the chmod path plus the host-owned
switch; the async flow (apply / status / scopes + authRequestId
registry + PAT_SCOPE_AUTH_REQUIRED active-request branch) lands in a
follow-up stacked PR (A-ext).
Scope of A-core:
- Host-owned PAT trigger: auth.HostOwnsPATFlow() keyed exclusively on
DINGTALK_DWS_AGENTCODE (DINGTALK_AGENT / DWS_CHANNEL / claw-type do
NOT participate).
- claw-type: hard-wired to "openClaw" in pkg/edition/default.go
MergeHeaders hook, matching historical main behavior and decoupled
from DINGTALK_AGENT.
- dws pat chmod: factory-built (newChmodCommand) so the PAT subcommand
tree has no shared package-level caller.
- stderr JSON classifier: internal/errors/pat.go covers PAT_NO_PERMISSION
/ PAT_LOW_RISK / PAT_MEDIUM_RISK / PAT_HIGH_RISK / PAT_SCOPE_*
selectors and fills data.hostControl for host consumption.
- Env contract: DINGTALK_SESSION_ID / DINGTALK_TRACE_ID /
DINGTALK_MESSAGE_ID carry HTTP trace headers (Chain B);
DWS_SESSION_ID (+ REWIND_SESSION_ID alias) is the lone fallback for
`dws pat chmod --session-id` (Chain A). The two chains are
independent; aliases do not cross-pollinate.
- Docs: docs/pat/{README,contract,host-integration}.md + refreshed
docs/architecture.md "PAT Architecture" chapter + docs/reference.md
PAT section. error-catalog.md folded into contract.md §6.
- Tests: host-owned signal, stderr contract, classifier, chmod factory,
retry / poll loop for the CLI-owned chmod path.
Compatibility: no breaking changes on main. All non-PAT commands are
untouched.
Follow-up PRs (in order):
- codex/pat-ext : dws pat apply / status / scopes + AsyncRegistry.
- codex/pat-oss-refactor (aka PR B) : oauth_helpers / secure_store
refactors split out per code review.
Made-with: Cursor
* fix(pat): unify host-owned stderr contract
* revert: drop PAT docs and changelog from pat core PR
* fix(pat): pin behavior auth endpoint
* fix(pat): recognize legacy tool miss
* fix(pat): fallback on gateway diagnostics
* fix(pat): treat authorization uri as opaque
* fix(pat): accept result envelope in device-flow polling
* feat(pat): add browser policy and poll compatibility
* fix(pat): keep CLI and host PAT contracts separate
* test(pat): isolate opaque uri retry env
* fix(pat): harden PAT result routing
* fix(pat): harden host-owned flow and runtime fallback
* fix(pat): preserve chmod failure on empty grant result
* refactor(pat): drop dead doc anchors, dedupe stderr injection helpers
Address two reviewer findings on top of the PAT-core series.
Fix:
- chmod: resolveSessionIDFromEnv silently selects DWS_SESSION_ID; drop
the slog.Warn that emitted both raw session ids into stderr /
~/.dws/logs.
- Remove every docs/pat/contract.md / error-catalog.md /
host-integration.md anchor from comments, help, and tests; those
files were never added by this branch. Comments are now self-contained
or point to docs/reference.md where applicable.
Refactor:
- internal/errors/pat.go: extract lookupCodeIn so getPATErrorCode and
getDWSGatewayErrorCode share one traversal; have ClassifyPatAuthCheck
delegate to getPATErrorCode instead of repeating the enum walk.
- internal/errors/pat.go + internal/app/pat_auth_retry.go: extract
ApplyHostMutations as the single injection point for data.hostControl
+ data.openBrowser; cleanPATJSON and enrichPATErrorForHostControl now
share it so the two stderr-JSON write paths cannot drift.
- internal/app/pat_auth_retry.go: drop buildPATScopeHostJSON and
buildHostControlState dead 1-line wrappers.
- internal/pat/chmod.go: drop unused patApply/Status/Scopes constants;
derive legacyToolArgs from toolArgs by clone + scopes->scope rename so
the two payloads stay in lock-step on every other field.
Net 15 files, +176 / -193. Verification:
env -u DINGTALK_DWS_AGENTCODE go test \
./internal/errors ./internal/auth ./internal/pat ./test/unit
env -u DINGTALK_DWS_AGENTCODE go test ./internal/app \
-run 'Test(IsPat|ExtractPat|PrintPat|PollPat|HandlePat|RetryWithPat|EnrichPAT|BuildPAT|DirectRuntime|ResolveIdentityHeaders)'
env -u DINGTALK_DWS_AGENTCODE go test -race \
./internal/pat ./internal/errors ./test/unit
env -u DINGTALK_DWS_AGENTCODE go test -race ./internal/app \
-run 'Test(HandlePat|RetryWithPat|EnrichPAT|BuildPAT|ResolveIdentityHeaders|DirectRuntime)'
go vet ./... && go build ./...
All green.
Made-with: Cursor
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
This commit is contained in:
co-authored by
shangguanxuan.sgx
parent
43715e28b0
commit
da2522c8ca
+2
-1
@@ -19,8 +19,9 @@
|
||||
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
|
||||
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
|
||||
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
|
||||
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
|
||||
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
)
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPATMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
switch {
|
||||
case host == "mcp.dingtalk.com":
|
||||
host = "mcp-gw.dingtalk.com"
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
|
||||
case strings.HasPrefix(host, "mcp."):
|
||||
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
@@ -102,6 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
@@ -149,6 +245,14 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == defaultPATProductID {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -158,7 +262,8 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp))
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[key] = true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,74 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
server := defaultPATServerDescriptor()
|
||||
if server.CLI.ID != "pat" {
|
||||
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
|
||||
}
|
||||
if server.DisplayName != "行为授权" {
|
||||
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
|
||||
}
|
||||
if server.Endpoint != defaultPATMCPEndpoint() {
|
||||
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousProducts := dynamicProducts
|
||||
dynamicProducts = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicProducts = previousProducts
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["pat"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
customEndpoint := "https://example.com/server/custom-pat"
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: customEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "pat",
|
||||
Command: "pat",
|
||||
},
|
||||
},
|
||||
})
|
||||
assertEndpoint(t, "pat", "", customEndpoint)
|
||||
}
|
||||
|
||||
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
|
||||
+159
-31
@@ -32,6 +32,7 @@ import (
|
||||
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/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
@@ -43,8 +44,12 @@ const (
|
||||
// PatAuthPollInterval is how often we poll to check if the user has
|
||||
// completed authorization.
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
|
||||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
@@ -182,21 +187,50 @@ func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
|
||||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"identity": scopeErr.Identity,
|
||||
"error": map[string]any{
|
||||
"type": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
},
|
||||
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutput(r *runtimeRunner) bool {
|
||||
if r == nil || r.globalFlags == nil {
|
||||
return false
|
||||
}
|
||||
if scopeErr.MissingScope != "" {
|
||||
payload["missing_scope"] = scopeErr.MissingScope
|
||||
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
rr, ok := runner.(*runtimeRunner)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok || data == nil {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
@@ -258,6 +292,19 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
|
||||
// It waits for the user to complete authorization and then retries the invocation.
|
||||
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "retryWithPatAuthRetry",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, true)}
|
||||
}
|
||||
if wantsStructuredPATOutputFromRunner(runner) {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, false)}
|
||||
}
|
||||
|
||||
// Print the PAT error in human-readable format
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
@@ -293,8 +340,6 @@ const (
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
// patRetryingKey is a context key to prevent recursive PAT auth checks.
|
||||
// After APPROVED, the retry should not trigger another PAT flow.
|
||||
type patRetryingKeyType struct{}
|
||||
@@ -366,9 +411,17 @@ func handlePatAuthCheck(
|
||||
"flowId", patData.Data.FlowID,
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
|
||||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||||
var appCfg *authpkg.AppConfig
|
||||
if patData.Data.ClientID != "" {
|
||||
if patData.Data.ClientSecret != "" {
|
||||
// When both clientId and clientSecret are provided, use direct mode
|
||||
@@ -381,19 +434,32 @@ func handlePatAuthCheck(
|
||||
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
|
||||
}
|
||||
|
||||
// Persist clientId (and optionally secret) to ~/.dws/app.json so that
|
||||
// future process invocations can load it at startup and populate
|
||||
// DWS_CLIENT_ID env before the first MCP request.
|
||||
appCfg := &authpkg.AppConfig{
|
||||
ClientID: patData.Data.ClientID,
|
||||
}
|
||||
// Persist only after an explicit APPROVED result below. Raw PAT
|
||||
// interceptions (host-owned / json / empty-flow pass-through) must not
|
||||
// rewrite the shared ~/.dws/app.json state for unrelated shells or agents.
|
||||
appCfg = &authpkg.AppConfig{ClientID: patData.Data.ClientID}
|
||||
if patData.Data.ClientSecret != "" {
|
||||
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
|
||||
}
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
|
||||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||||
// used for this decision — it is only forwarded on the wire via
|
||||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
if wantsStructuredPATOutput(r) {
|
||||
if openBrowser && patData.Data.URI != "" {
|
||||
_ = openBrowserFunc(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
@@ -410,14 +476,9 @@ func handlePatAuthCheck(
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
// Best-effort browser open.
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
|
||||
// If no flowId, we can't poll — fall back to returning PATError for host-app.
|
||||
if patData.Data.FlowID == "" {
|
||||
fmt.Fprintln(output)
|
||||
return executor.Result{}, patErr
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
@@ -439,6 +500,13 @@ func handlePatAuthCheck(
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||
if authCode != "" {
|
||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||
@@ -503,6 +571,66 @@ func handlePatAuthCheck(
|
||||
}
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Route back through the classifier so host-owned active retry emits the
|
||||
// exact same PAT JSON shape as passive classification.
|
||||
if patErr := apperrors.ClassifyPatAuthCheck(payload); patErr != nil {
|
||||
return patErr.RawJSON
|
||||
}
|
||||
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// buildPATScopeJSON renders the PAT_SCOPE_AUTH_REQUIRED stderr payload.
|
||||
// includeHostControl=true follows the standard host-owned/CLI-owned split
|
||||
// (data.hostControl is injected only if HostControlBlock is non-nil).
|
||||
// includeHostControl=false is an explicit override used by the CLI-owned
|
||||
// branch so that any env-mode misconfiguration cannot leak a host-owned
|
||||
// contract into stderr.
|
||||
func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string {
|
||||
data := map[string]any{
|
||||
"identity": scopeErr.Identity,
|
||||
"errorType": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
"missingScope": scopeErr.MissingScope,
|
||||
"openBrowser": apperrors.PATOpenBrowserValue(),
|
||||
}
|
||||
if includeHostControl {
|
||||
if hostControl := apperrors.HostControlBlock(); hostControl != nil {
|
||||
data["hostControl"] = hostControl
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"code": patScopeAuthRequiredCode,
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
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/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
)
|
||||
|
||||
func TestIsPatScopeError_MissingScope(t *testing.T) {
|
||||
@@ -86,21 +87,6 @@ func TestIsPatScopeError_InsufficientScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_MissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.ErrorType != "missing_scope" {
|
||||
t.Errorf("expected error type 'missing_scope', got %q", scopeErr.ErrorType)
|
||||
}
|
||||
if !strings.Contains(scopeErr.Hint, "dws auth login") {
|
||||
t.Errorf("expected hint to contain 'dws auth login', got %q", scopeErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_ExtractsScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PatScopeError{
|
||||
@@ -142,7 +128,7 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
@@ -153,12 +139,28 @@ func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
}
|
||||
PrintPatAuthJSON(&buf, scopeErr)
|
||||
|
||||
// The payload is required to be single-line; assert by parsing the JSON
|
||||
// rather than by matching pretty-printed substrings.
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"ok": false`) {
|
||||
t.Errorf("expected JSON to contain ok: false, got: %s", output)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
|
||||
t.Fatalf("PrintPatAuthJSON must emit directly-parsable JSON: %v\nraw=%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, `"missing_scope": "mail:send"`) {
|
||||
t.Errorf("expected JSON to contain missing_scope, got: %s", output)
|
||||
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if data == nil {
|
||||
t.Fatalf("expected data object, got: %s", output)
|
||||
}
|
||||
if got, _ := data["missingScope"].(string); got != "mail:send" {
|
||||
t.Errorf("missingScope = %q, want mail:send", got)
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Errorf("openBrowser = %#v, want true", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Errorf("unexpected data.hostControl in CLI-owned JSON output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +468,6 @@ func TestPollPatDeviceFlow_UnknownStatusHidesRawResponseByDefault(t *testing.T)
|
||||
t.Fatalf("expected raw poll response to stay hidden by default, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_ResultEnvelopeCompatibility(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Result: authpkg.DevicePollData{Status: "PENDING"}},
|
||||
@@ -590,6 +591,7 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
@@ -622,6 +624,9 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
if cid := authpkg.ClientID(); cid != "test-client-id" {
|
||||
t.Errorf("expected ClientID 'test-client-id', got %q", cid)
|
||||
@@ -629,6 +634,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
defer server.Close()
|
||||
|
||||
@@ -657,7 +663,132 @@ func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
|
||||
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
|
||||
// hostControl.clawType — the open-source build pins that to the
|
||||
// literal edition.DefaultOSSClawType value ("openClaw").
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-host", "test-client-id")}
|
||||
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in host-controlled mode")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["flowId"].(string); got != "flow-host" {
|
||||
t.Fatalf("data.flowId = %q, want flow-host", got)
|
||||
}
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
|
||||
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
|
||||
}
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract in host-controlled PAT payload: %#v", data["callbacks"])
|
||||
}
|
||||
if _, ok := payload["_meta"]; ok {
|
||||
t.Fatalf("unexpected _meta contract in host-controlled PAT payload: %#v", payload["_meta"])
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in host-controlled mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract when flowId is absent: %#v", data["callbacks"])
|
||||
}
|
||||
if _, ok := payload["_meta"]; ok {
|
||||
t.Fatalf("unexpected _meta contract when flowId is absent: %#v", payload["_meta"])
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
// No poll server needed — empty flowId means no polling, return PATError directly.
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
@@ -686,9 +817,340 @@ func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
|
||||
if _, ok := err.(*apperrors.PATError); !ok {
|
||||
t.Errorf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output for raw PAT passthrough, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
if _, err := pat.SetBrowserPolicy(tmpDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in json PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-json", "test-client-id")}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in json PAT mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Fatalf("unexpected data.hostControl in CLI-owned json PAT mode: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
if _, err := pat.SetBrowserPolicy(tmpDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened string
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = rawURL
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in json PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, &apperrors.PATError{RawJSON: raw}, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in json PAT mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != "https://example.com/pat" {
|
||||
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened bool
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
var retryCalled bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, &apperrors.PATError{RawJSON: raw}, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected retry to still happen in non-json mode")
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("browser should not open when policy disables it")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in scope json mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := retryWithPatAuthRetry(context.Background(), runner, executor.Invocation{}, scopeErr, configDir, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json scope mode, got %q", got)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scope PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Fatalf("unexpected data.hostControl in CLI-owned json scope mode: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPATErrorForHostControl_SingleLineOutput locks in the wire
|
||||
// invariant: the enriched host-controlled PAT payload must be single-line
|
||||
// (no embedded newlines, no indentation), so stderr-line-scanning hosts
|
||||
// stay correct. Regression guard against accidental reintroduction of
|
||||
// json.MarshalIndent.
|
||||
func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
raw := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{"flowId":"flow-1","desc":"授权","callbacks":["cb1","cb2"]}}`
|
||||
out := enrichPATErrorForHostControl(raw)
|
||||
|
||||
if strings.Contains(out, "\n") {
|
||||
t.Fatalf("enrichPATErrorForHostControl output must be single-line, got embedded newline:\n%s", out)
|
||||
}
|
||||
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
|
||||
t.Fatalf("enrichPATErrorForHostControl output must not be indented, got leading whitespace: %q", out)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if hostControl == nil {
|
||||
t.Fatalf("expected data.hostControl injection, got: %s", out)
|
||||
}
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("expected callbacks to be stripped in host-owned contract, got: %v", data["callbacks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
|
||||
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
|
||||
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
out := buildPATScopeJSON(scopeErr, true)
|
||||
|
||||
if strings.Contains(out, "\n") {
|
||||
t.Fatalf("buildPATScopeJSON(host) output must be single-line, got embedded newline:\n%s", out)
|
||||
}
|
||||
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
|
||||
t.Fatalf("buildPATScopeJSON(host) output must not be indented, got leading whitespace: %q", out)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
|
||||
}
|
||||
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_HostControlledReturnsJSON(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled scope mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := retryWithPatAuthRetry(context.Background(), mock, executor.Invocation{}, scopeErr, t.TempDir(), &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output, got %q", got)
|
||||
}
|
||||
patErr, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scope host payload) error = %v\nraw=%s", err, patErr.RawJSON)
|
||||
}
|
||||
if got, _ := payload["code"].(string); got != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Fatalf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", got)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["missingScope"].(string); got != "mail:send" {
|
||||
t.Fatalf("missingScope = %q, want mail:send", got)
|
||||
}
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract in PAT scope host payload: %#v", data["callbacks"])
|
||||
}
|
||||
if strings.Contains(patErr.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("scope host payload should not advertise dws pat callback argv: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
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/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// init wires the PAT classifier's hostControl injection hook. This
|
||||
// guarantees cleanPATJSON emits data.hostControl in host-owned mode
|
||||
// regardless of whether the PAT error was surfaced via the active retry
|
||||
// path or the passive classifier path.
|
||||
//
|
||||
// Decision rule:
|
||||
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
|
||||
// - When triggered, `clawType` in the emitted hostControl block MUST
|
||||
// be the exact value the CLI actually injects on the wire into the
|
||||
// `claw-type` HTTP header. The open-source build pins that to
|
||||
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
|
||||
// is no per-spawn env override.
|
||||
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
|
||||
// HostControlBlock yields nil and no hostControl block is emitted.
|
||||
func init() {
|
||||
apperrors.SetHostControlProvider(hostControlProviderFromEnv)
|
||||
apperrors.SetPATOpenBrowserProvider(func() bool {
|
||||
return pat.EffectiveOpenBrowser(defaultConfigDir())
|
||||
})
|
||||
}
|
||||
|
||||
func hostControlProviderFromEnv() string {
|
||||
if !authpkg.HostOwnsPATFlow() {
|
||||
return ""
|
||||
}
|
||||
return effectiveClawType()
|
||||
}
|
||||
|
||||
// effectiveClawType returns the literal value that MergeHeaders will
|
||||
// inject into outbound `claw-type` headers. Going through the edition
|
||||
// hook (instead of a hard-coded constant) keeps this site correct for
|
||||
// downstream editions that override MergeHeaders.
|
||||
func effectiveClawType() string {
|
||||
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
|
||||
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return edition.DefaultOSSClawType
|
||||
}
|
||||
+40
-6
@@ -59,7 +59,7 @@ func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_AGENT",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-agent 头",
|
||||
Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_TRACE_ID",
|
||||
@@ -93,6 +93,27 @@ const (
|
||||
envDWSChannel = "DWS_CHANNEL"
|
||||
)
|
||||
|
||||
// hostOwnedPATDecisionOnce ensures the host-owned PAT decision is logged at
|
||||
// most once per CLI process. The log line is emitted at Debug level so
|
||||
// `--debug` (or `--verbose`) surfaces it on stderr; the file logger at
|
||||
// ~/.dws/logs/dws.log captures it unconditionally at DEBUG. It records
|
||||
// ONLY the derived booleans — never the env value, token, client-id or
|
||||
// flow-id — so logs remain safe to attach to issues.
|
||||
var hostOwnedPATDecisionOnce sync.Once
|
||||
|
||||
// logHostOwnedPATDecisionOnce emits the single-shot debug trace. It is
|
||||
// called lazily from the runtime Run path (which executes AFTER
|
||||
// PersistentPreRunE has applied --debug / --verbose via configureLogLevel)
|
||||
// so the line actually surfaces when the user asks for it.
|
||||
func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
@@ -132,6 +153,12 @@ type runtimeRunner struct {
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Emit the one-shot host-owned PAT decision log. Placed here (not in
|
||||
// the constructor) so it fires AFTER PersistentPreRunE has configured
|
||||
// slog level per --debug / --verbose. The Once guard makes repeat
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -610,11 +637,17 @@ func resolveIdentityHeaders() map[string]string {
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||
// x-dingtalk-agent header. It does NOT influence claw-type (which the
|
||||
// open-source edition pins to edition.DefaultOSSClawType via the
|
||||
// MergeHeaders hook below) and it does NOT influence the host-owned
|
||||
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
}
|
||||
for k, v := range envHeaders {
|
||||
if v != "" {
|
||||
@@ -622,7 +655,8 @@ func resolveIdentityHeaders() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// Inject third-party channel headers
|
||||
// Inject third-party channel headers. DWS_CHANNEL is forwarded as the
|
||||
// upstream channelCode.
|
||||
if v := os.Getenv(envDWSChannel); v != "" {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -313,6 +314,16 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
}
|
||||
@@ -65,6 +65,10 @@ func (e *Error) Unwrap() error {
|
||||
type Option func(*Error)
|
||||
|
||||
// ExitCode returns the documented process exit code for the error category.
|
||||
// exit=4 is reserved exclusively for PATError (see internal/errors/pat.go
|
||||
// ExitCodePermission and the exit-code table in docs/reference.md);
|
||||
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
||||
// apart from "PAT permission insufficient".
|
||||
func (e *Error) ExitCode() int {
|
||||
switch e.Category {
|
||||
case CategoryAPI:
|
||||
@@ -74,7 +78,7 @@ func (e *Error) ExitCode() int {
|
||||
case CategoryValidation:
|
||||
return 3
|
||||
case CategoryDiscovery:
|
||||
return 4
|
||||
return 6
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestExitCode_AllCategories(t *testing.T) {
|
||||
{CategoryAPI, 1},
|
||||
{CategoryAuth, 2},
|
||||
{CategoryValidation, 3},
|
||||
{CategoryDiscovery, 4},
|
||||
{CategoryDiscovery, 6},
|
||||
{CategoryInternal, 5},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestExitCodeByCategory(t *testing.T) {
|
||||
{err: NewAPI("api"), want: 1},
|
||||
{err: NewAuth("auth"), want: 2},
|
||||
{err: NewValidation("validation"), want: 3},
|
||||
{err: NewDiscovery("discovery"), want: 4},
|
||||
{err: NewDiscovery("discovery"), want: 6},
|
||||
{err: NewInternal("internal"), want: 5},
|
||||
{err: stderrors.New("plain"), want: 5},
|
||||
}
|
||||
|
||||
+167
-29
@@ -18,15 +18,90 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// hostControlProvider returns the host-owned clawType for the current
|
||||
// process, or empty string when CLI is in default (CLI-owned) mode.
|
||||
// Injected lazily via SetHostControlProvider to avoid an
|
||||
// internal/errors → internal/auth import cycle.
|
||||
//
|
||||
// Access is serialized by hostControlMu so that tests can swap the provider
|
||||
// without triggering the race detector against parallel classifier callers.
|
||||
var (
|
||||
hostControlMu sync.RWMutex
|
||||
hostControlProvider func() string
|
||||
patBrowserMu sync.RWMutex
|
||||
patBrowserProvider func() bool
|
||||
)
|
||||
|
||||
// SetHostControlProvider wires up the classifier's hostControl injection.
|
||||
// It MUST be called once during CLI bootstrap (e.g. from internal/app
|
||||
// init()) so that the first cleanPATJSON call observes a valid provider.
|
||||
// Passing nil disables injection (useful for isolated tests).
|
||||
func SetHostControlProvider(fn func() string) {
|
||||
hostControlMu.Lock()
|
||||
defer hostControlMu.Unlock()
|
||||
hostControlProvider = fn
|
||||
}
|
||||
|
||||
// SetPATOpenBrowserProvider wires the PAT JSON serializer to the current
|
||||
// browser policy. Passing nil restores the open-source fallback (true).
|
||||
func SetPATOpenBrowserProvider(fn func() bool) {
|
||||
patBrowserMu.Lock()
|
||||
defer patBrowserMu.Unlock()
|
||||
patBrowserProvider = fn
|
||||
}
|
||||
|
||||
// PATOpenBrowserValue returns the effective browser-open recommendation to
|
||||
// embed in PAT JSON payloads. The open-source fallback is true to preserve
|
||||
// historical behavior when no provider is wired.
|
||||
func PATOpenBrowserValue() bool {
|
||||
patBrowserMu.RLock()
|
||||
provider := patBrowserProvider
|
||||
patBrowserMu.RUnlock()
|
||||
if provider == nil {
|
||||
return true
|
||||
}
|
||||
return provider()
|
||||
}
|
||||
|
||||
// HostControlBlock returns the canonical hostControl map injected into
|
||||
// PAT stderr JSON when the CLI is operating in host-owned mode, or nil
|
||||
// when it is not. The returned map is safe for the caller to mutate
|
||||
// because a new map is constructed on each call.
|
||||
//
|
||||
// callbackOwner is kept as a legacy compatibility key for hosts that adopted
|
||||
// it before the contract converged on the hostControl single injection point.
|
||||
func HostControlBlock() map[string]any {
|
||||
hostControlMu.RLock()
|
||||
provider := hostControlProvider
|
||||
hostControlMu.RUnlock()
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
claw := provider()
|
||||
if claw == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"clawType": claw,
|
||||
"callbackOwner": "host",
|
||||
"mode": "host",
|
||||
"pollingOwner": "host",
|
||||
"retryOwner": "host",
|
||||
}
|
||||
}
|
||||
|
||||
// ExitCodePermission is the process exit code for PAT authorisation failures.
|
||||
const ExitCodePermission = 4
|
||||
|
||||
// PATError represents a PAT (Personal Action Token) authorization failure
|
||||
// that should be passed through to stderr as raw JSON without any CLI-layer
|
||||
// wrapping. The host application (e.g. RewindDesktop) parses the JSON to
|
||||
// display its own authorisation UI.
|
||||
// wrapping. The host application parses the JSON to display its own
|
||||
// authorization UI. The wire schema is fixed: a single-line, directly
|
||||
// json.Unmarshal-able payload of the form
|
||||
// {"success":false,"code":<frozen enum>,"data":{...}}.
|
||||
//
|
||||
// When the payload includes data.uri, that URL is the authoritative
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
@@ -54,9 +129,21 @@ var patNoPermissionCodes = map[string]bool{
|
||||
}
|
||||
|
||||
// patAuthRequiredCodes are error codes that trigger the PAT authorization
|
||||
// flow (e.g. the server auto-created a CLI app and returned auth details).
|
||||
// flow (e.g. the server auto-created a CLI app and returned auth details,
|
||||
// or the caller's OAuth token lacks a scope that must be re-acquired via
|
||||
// `dws auth login --scope <missing>`).
|
||||
//
|
||||
// Keep keys in alphabetical order so diffs are stable. Both codes below are
|
||||
// part of the frozen PAT-family selector and MUST be surfaced as *PATError
|
||||
// (exit=4) so hosts can act on them:
|
||||
// - AGENT_CODE_NOT_EXISTS: data.agentCode tells the host which agent
|
||||
// registration is missing.
|
||||
// - PAT_SCOPE_AUTH_REQUIRED: data.missingScope tells the host which
|
||||
// OAuth scope to re-acquire via
|
||||
// `dws auth login --scope <data.missingScope>`.
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"PAT_SCOPE_AUTH_REQUIRED": true,
|
||||
}
|
||||
|
||||
// IsPATError reports whether err is a *PATError.
|
||||
@@ -70,6 +157,35 @@ func IsPATNoPermissionCode(code string) bool {
|
||||
return patNoPermissionCodes[code]
|
||||
}
|
||||
|
||||
// errCodeKeys is the canonical priority order in which we look up
|
||||
// upstream error code fields. Servers historically rotated between camel
|
||||
// and snake case; we accept all three and pick the first that resolves to
|
||||
// a recognised value.
|
||||
var errCodeKeys = []string{"code", "errorCode", "error_code"}
|
||||
|
||||
// lookupCodeIn returns the first value in body[errCodeKeys] that is a
|
||||
// non-empty string AND is a member of accept. Used by the PAT and DWS
|
||||
// gateway classifiers, which differ only in their accept-set.
|
||||
func lookupCodeIn(body map[string]any, accept map[string]bool) (string, bool) {
|
||||
for _, key := range errCodeKeys {
|
||||
if code, ok := body[key].(string); ok && accept[code] {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getPATErrorCode extracts any PAT-intercept code from a map. PAT
|
||||
// intercepts include both permission denials and auth-required selectors:
|
||||
// callers on the text/tool-result path must preserve both families as
|
||||
// *PATError so exit=4 + raw stderr JSON survives all the way to the host/CLI.
|
||||
func getPATErrorCode(body map[string]any) (string, bool) {
|
||||
if code, ok := lookupCodeIn(body, patNoPermissionCodes); ok {
|
||||
return code, true
|
||||
}
|
||||
return lookupCodeIn(body, patAuthRequiredCodes)
|
||||
}
|
||||
|
||||
// ---- DWS gateway auth errors (shared between PAT & general auth) ----------
|
||||
|
||||
// dwsGatewayErrors is the set of DWS gateway-level auth error codes.
|
||||
@@ -78,20 +194,18 @@ var dwsGatewayErrors = map[string]bool{
|
||||
"DWS_AUTH_SERVICE_FAILED": true,
|
||||
}
|
||||
|
||||
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody
|
||||
// (supports both errorCode and error_code field names).
|
||||
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
|
||||
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
for _, key := range []string{"errorCode", "error_code"} {
|
||||
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return lookupCodeIn(errBody, dwsGatewayErrors)
|
||||
}
|
||||
|
||||
// isNotLoggedInError checks if the error body indicates missing authentication.
|
||||
func isNotLoggedInError(body map[string]any) bool {
|
||||
if errMsg, ok := body["error"].(string); ok {
|
||||
for _, key := range []string{"error", "message", "errorMsg"} {
|
||||
errMsg, ok := body[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(errMsg, "Missing service_id or access_key") {
|
||||
return true
|
||||
}
|
||||
@@ -129,10 +243,8 @@ func ClassifyToolResultContent(content map[string]any) error {
|
||||
WithHint(authExpiredHint()),
|
||||
)
|
||||
}
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
if code, ok := getPATErrorCode(content); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -163,10 +275,8 @@ func ClassifyMCPResponseText(text string) error {
|
||||
)
|
||||
}
|
||||
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
||||
}
|
||||
if code, ok := getPATErrorCode(body); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
||||
}
|
||||
|
||||
if isBusinessError(body) {
|
||||
@@ -219,6 +329,33 @@ var patTopLevelStrip = map[string]bool{
|
||||
"message": true, "error": true, "trace_id": true, "class": true,
|
||||
}
|
||||
|
||||
// ApplyHostMutations writes the two stderr-JSON fields the host integration
|
||||
// contract requires onto out["data"]:
|
||||
// - data.hostControl: present iff the CLI is in host-owned mode (i.e.
|
||||
// HostControlBlock returns non-nil); legacy data.callbacks is stripped
|
||||
// in the same pass so passive classifier and active retry paths stay
|
||||
// byte-for-byte aligned.
|
||||
// - data.openBrowser: always present; reflects the user's PAT browser
|
||||
// policy.
|
||||
//
|
||||
// Centralizing the two writes here is the single-injection invariant —
|
||||
// any caller that produces a PAT-shaped stderr payload (cleanPATJSON,
|
||||
// active-retry enrichers, scope-required builders) MUST go through this
|
||||
// function instead of writing the fields directly. out["data"] is
|
||||
// promoted to map[string]any if missing or of the wrong type.
|
||||
func ApplyHostMutations(out map[string]any) {
|
||||
data, ok := out["data"].(map[string]any)
|
||||
if !ok || data == nil {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
data["hostControl"] = block
|
||||
}
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
func cleanPATJSON(body map[string]any, code string) string {
|
||||
out := map[string]any{
|
||||
"success": false,
|
||||
@@ -240,7 +377,12 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
out["data"] = stripClassFields(fallback)
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
ApplyHostMutations(out)
|
||||
|
||||
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
|
||||
// payload — pretty-printing would break naïve host parsers that read
|
||||
// stderr line-by-line and fail on leading whitespace.
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
@@ -252,15 +394,11 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
// ClassifyPatAuthCheck is the open-source fallback that checks a tool-call
|
||||
// Content map for PAT permission codes and auth-required codes. Returns a
|
||||
// Content map for PAT permission codes and auth-required codes. Returns a
|
||||
// non-nil *PATError when the content carries a recognised PAT/auth error.
|
||||
func ClassifyPatAuthCheck(content map[string]any) *PATError {
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok {
|
||||
if patNoPermissionCodes[code] || patAuthRequiredCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
if code, ok := getPATErrorCode(content); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+272
-2
@@ -144,8 +144,17 @@ func TestIsNotLoggedInError_False(t *testing.T) {
|
||||
func TestIsNotLoggedInError_NoErrorField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"message": "Missing service_id or access_key"}
|
||||
if isNotLoggedInError(body) {
|
||||
t.Fatal("expected false when error field is absent")
|
||||
if !isNotLoggedInError(body) {
|
||||
t.Fatal("expected true when equivalent auth message is present in message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDWSGatewayErrorCode_CodeField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}
|
||||
code, ok := getDWSGatewayErrorCode(body)
|
||||
if !ok || code != "DWS_SERVICE_UNAUTHORIZED" {
|
||||
t.Fatalf("getDWSGatewayErrorCode() = (%q, %t), want DWS_SERVICE_UNAUTHORIZED, true", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +244,44 @@ func TestClassifyToolResultContent_PATPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_PATPermissionLegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"error_code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{"desc": "需要授权"},
|
||||
}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for legacy error_code PAT permission")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_LOW_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain PAT_LOW_RISK_NO_PERMISSION, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_PATAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"errorCode": "AGENT_CODE_NOT_EXISTS",
|
||||
"data": map[string]any{"agentCode": "agt-missing"},
|
||||
}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for PAT auth-required selector")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") {
|
||||
t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_NoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"success": true, "data": "ok"}
|
||||
@@ -295,6 +342,41 @@ func TestClassifyMCPResponseText_PATPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATPermissionLegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"error_code":"PAT_MEDIUM_RISK_NO_PERMISSION","data":{"desc":"legacy"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_MEDIUM_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain legacy code, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_SCOPE_AUTH_REQUIRED") {
|
||||
t.Errorf("RawJSON should contain PAT_SCOPE_AUTH_REQUIRED, got: %s", patErr.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "missingScope") {
|
||||
t.Errorf("RawJSON should preserve missingScope, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
@@ -346,6 +428,18 @@ func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_LegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"error_code": "PAT_HIGH_RISK_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError for legacy error_code")
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain PAT_HIGH_RISK_NO_PERMISSION, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}}
|
||||
@@ -358,6 +452,43 @@ func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPatAuthCheck_scope_auth_required pins the PAT_SCOPE_AUTH_REQUIRED
|
||||
// selector (part of the frozen PAT-family enum; see patAuthRequiredCodes in
|
||||
// internal/errors/pat.go) as a PATError with exit=4 so hosts can kick the
|
||||
// `dws auth login --scope <data.missingScope>` branch.
|
||||
func TestClassifyPatAuthCheck_scope_auth_required(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_SCOPE_AUTH_REQUIRED",
|
||||
"data": map[string]any{"missingScope": "mail:send"},
|
||||
}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError for PAT_SCOPE_AUTH_REQUIRED")
|
||||
}
|
||||
|
||||
// Error value MUST satisfy the ExitCoder contract (exit=4) so the
|
||||
// process exits with the PAT Frozen code regardless of wrapping.
|
||||
var ec interface{ ExitCode() int } = patErr
|
||||
if ec.ExitCode() != ExitCodePermission {
|
||||
t.Errorf("ExitCode() = %d, want %d", ec.ExitCode(), ExitCodePermission)
|
||||
}
|
||||
|
||||
// Host-visible RawStderr must carry the selector and, crucially, the
|
||||
// missingScope field that drives `dws auth login --scope <x>`.
|
||||
raw := patErr.RawStderr()
|
||||
if !strings.Contains(raw, "PAT_SCOPE_AUTH_REQUIRED") {
|
||||
t.Errorf("RawStderr missing selector, got: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "missingScope") {
|
||||
t.Errorf("RawStderr missing missingScope field, got: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "mail:send") {
|
||||
t.Errorf("RawStderr missing missingScope value, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_NoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"}
|
||||
@@ -424,6 +555,9 @@ func TestCleanPATJSON_WithData(t *testing.T) {
|
||||
if strings.Contains(result, "class") {
|
||||
t.Errorf("expected class field to be stripped, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `"openBrowser":true`) {
|
||||
t.Errorf("expected openBrowser default in output, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_WithoutData(t *testing.T) {
|
||||
@@ -444,6 +578,142 @@ func TestCleanPATJSON_WithoutData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_InjectsHostControlWhenClawSet verifies the
|
||||
// single-injection invariant: when the bootstrap wires a non-empty
|
||||
// clawType provider, cleanPATJSON MUST emit data.hostControl.
|
||||
func TestCleanPATJSON_InjectsHostControlWhenClawSet(t *testing.T) {
|
||||
// Not parallel: mutates the package-level provider.
|
||||
t.Cleanup(func() { SetHostControlProvider(nil) })
|
||||
SetHostControlProvider(func() string { return "my-copilot" })
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "需要授权",
|
||||
"flowId": "f-1",
|
||||
"callbacks": []any{"cb1", "cb2"},
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
|
||||
}
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data object, got %T", parsed["data"])
|
||||
}
|
||||
hc, ok := data["hostControl"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data.hostControl to be a map, got %T\nraw=%s", data["hostControl"], raw)
|
||||
}
|
||||
if got, _ := hc["clawType"].(string); got != "my-copilot" {
|
||||
t.Errorf("hostControl.clawType = %q, want %q", got, "my-copilot")
|
||||
}
|
||||
if got, _ := hc["callbackOwner"].(string); got != "host" {
|
||||
t.Errorf("hostControl.callbackOwner = %q, want %q", got, "host")
|
||||
}
|
||||
if got, _ := hc["mode"].(string); got != "host" {
|
||||
t.Errorf("hostControl.mode = %q, want %q", got, "host")
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("cleanPATJSON should strip callbacks in host-owned mode, got: %v", data["callbacks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_OmitsHostControlByDefault verifies that cleanPATJSON
|
||||
// does NOT include a hostControl block when the provider is unset or
|
||||
// returns empty (default CLI-owned mode).
|
||||
func TestCleanPATJSON_OmitsHostControlByDefault(t *testing.T) {
|
||||
// Not parallel: reads the package-level provider.
|
||||
t.Cleanup(func() { SetHostControlProvider(nil) })
|
||||
SetHostControlProvider(nil)
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "need auth",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if strings.Contains(raw, `"hostControl"`) {
|
||||
t.Fatalf("cleanPATJSON should omit hostControl in default mode, got: %s", raw)
|
||||
}
|
||||
|
||||
SetHostControlProvider(func() string { return "" })
|
||||
raw = cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if strings.Contains(raw, `"hostControl"`) {
|
||||
t.Fatalf("cleanPATJSON should omit hostControl when provider returns empty, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_UsesBrowserPolicyProvider(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
SetHostControlProvider(nil)
|
||||
SetPATOpenBrowserProvider(nil)
|
||||
})
|
||||
SetHostControlProvider(nil)
|
||||
SetPATOpenBrowserProvider(func() bool { return false })
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "need auth",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_SingleLineOutput pins down the wire invariant: stderr
|
||||
// JSON MUST be emitted as a single line (no embedded \n, no pretty-print
|
||||
// indentation) so that naïve host parsers reading stderr line-by-line stay
|
||||
// correct. Regression guard against accidental reintroduction of
|
||||
// json.MarshalIndent.
|
||||
func TestCleanPATJSON_SingleLineOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "读取记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_LOW_RISK_NO_PERMISSION")
|
||||
|
||||
if strings.Contains(raw, "\n") {
|
||||
t.Fatalf("cleanPATJSON output must be single-line, got embedded newline:\n%s", raw)
|
||||
}
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
t.Fatalf("cleanPATJSON output must not be indented, got leading whitespace: %q", raw)
|
||||
}
|
||||
|
||||
// Contract: the payload must remain a directly json.Unmarshal-able
|
||||
// object, even after the single-line constraint is enforced.
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, raw)
|
||||
}
|
||||
if code, _ := parsed["code"].(string); code != "PAT_LOW_RISK_NO_PERMISSION" {
|
||||
t.Errorf("code = %q, want %q", code, "PAT_LOW_RISK_NO_PERMISSION")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const browserPolicyFile = "pat_policy.json"
|
||||
|
||||
type browserPolicyValue struct {
|
||||
OpenBrowser bool `json:"openBrowser"`
|
||||
}
|
||||
|
||||
type BrowserPolicy struct {
|
||||
Default *browserPolicyValue `json:"default,omitempty"`
|
||||
Agents map[string]browserPolicyValue `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
type BrowserPolicySelection struct {
|
||||
Scope string `json:"scope"`
|
||||
AgentCode string `json:"agentCode,omitempty"`
|
||||
OpenBrowser bool `json:"openBrowser"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func patPolicyPath(configDir string) string {
|
||||
return filepath.Join(configDir, browserPolicyFile)
|
||||
}
|
||||
|
||||
func patConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".dws"
|
||||
}
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
|
||||
path := patPolicyPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &BrowserPolicy{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading PAT browser policy: %w", err)
|
||||
}
|
||||
|
||||
var policy BrowserPolicy
|
||||
if err := json.Unmarshal(data, &policy); err != nil {
|
||||
return nil, fmt.Errorf("parsing PAT browser policy: %w", err)
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
|
||||
if policy == nil {
|
||||
policy = &BrowserPolicy{}
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
data, err := json.MarshalIndent(policy, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling PAT browser policy: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := helpers.AtomicWriteJSON(patPolicyPath(configDir), data); err != nil {
|
||||
return fmt.Errorf("writing PAT browser policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode, false)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
if agentCode != "" {
|
||||
if entry, ok := policy.Agents[agentCode]; ok {
|
||||
return BrowserPolicySelection{
|
||||
Scope: "agent",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: entry.OpenBrowser,
|
||||
Source: "agent",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if policy.Default != nil {
|
||||
return BrowserPolicySelection{
|
||||
Scope: "default",
|
||||
OpenBrowser: policy.Default.OpenBrowser,
|
||||
Source: "default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return BrowserPolicySelection{
|
||||
Scope: "builtin_default",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: true,
|
||||
Source: "builtin_default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func EffectiveOpenBrowser(configDir string) bool {
|
||||
selection, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return selection.OpenBrowser
|
||||
}
|
||||
|
||||
func resolveBrowserPolicyWriteAgentCode(explicitAgentCode string) (string, error) {
|
||||
agentCode := strings.TrimSpace(explicitAgentCode)
|
||||
if agentCode == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := validateAgentCode(agentCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return agentCode, nil
|
||||
}
|
||||
|
||||
func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
|
||||
if agentCode != "" {
|
||||
policy.Agents[agentCode] = browserPolicyValue{OpenBrowser: enabled}
|
||||
if err := saveBrowserPolicy(configDir, policy); err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
return BrowserPolicySelection{
|
||||
Scope: "agent",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: enabled,
|
||||
Source: "agent",
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy.Default = &browserPolicyValue{OpenBrowser: enabled}
|
||||
if err := saveBrowserPolicy(configDir, policy); err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
return BrowserPolicySelection{
|
||||
Scope: "default",
|
||||
OpenBrowser: enabled,
|
||||
Source: "default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newBrowserPolicyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "browser-policy",
|
||||
Short: "配置 PAT 授权时是否打开浏览器",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !cmd.Flags().Changed("enabled") {
|
||||
return fmt.Errorf("--enabled is required")
|
||||
}
|
||||
|
||||
enabled, err := cmd.Flags().GetBool("enabled")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentCode, err := cmd.Flags().GetString("agentCode")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selection, err := SetBrowserPolicy(patConfigDir(), agentCode, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.WriteJSON(cmd.OutOrStdout(), selection)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("enabled", false, "PAT 撞墙时是否允许本地打开浏览器")
|
||||
cmd.Flags().String("agentCode", "", "Agent 唯一标识(可选;不填则写入全局默认策略,不从 env DINGTALK_DWS_AGENTCODE 回退)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrowserPolicy_DefaultRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
saved, err := SetBrowserPolicy(configDir, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if saved.Scope != "default" {
|
||||
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
|
||||
}
|
||||
if saved.OpenBrowser {
|
||||
t.Fatal("saved.OpenBrowser = true, want false")
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "default" {
|
||||
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetBrowserPolicy_EmptyAgentCodeIgnoresEnvAndWritesDefault(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
configDir := t.TempDir()
|
||||
|
||||
saved, err := SetBrowserPolicy(configDir, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if saved.Scope != "default" {
|
||||
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
|
||||
}
|
||||
if saved.AgentCode != "" {
|
||||
t.Fatalf("saved.AgentCode = %q, want empty", saved.AgentCode)
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", err)
|
||||
}
|
||||
if policy.Default == nil {
|
||||
t.Fatal("policy.Default is nil, want default policy")
|
||||
}
|
||||
if got := len(policy.Agents); got != 0 {
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default under env) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "default" {
|
||||
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
|
||||
}
|
||||
if loaded.AgentCode != "" {
|
||||
t.Fatalf("loaded.AgentCode = %q, want empty", loaded.AgentCode)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicy_AgentOverridesDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "agt-sales", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
|
||||
agentLoaded, err := ResolveBrowserPolicy(configDir, "agt-sales")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
if agentLoaded.Scope != "agent" {
|
||||
t.Fatalf("agentLoaded.Scope = %q, want agent", agentLoaded.Scope)
|
||||
}
|
||||
if agentLoaded.AgentCode != "agt-sales" {
|
||||
t.Fatalf("agentLoaded.AgentCode = %q, want agt-sales", agentLoaded.AgentCode)
|
||||
}
|
||||
if agentLoaded.OpenBrowser {
|
||||
t.Fatal("agentLoaded.OpenBrowser = true, want false")
|
||||
}
|
||||
|
||||
defaultLoaded, err := ResolveBrowserPolicy(configDir, "agt-other")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default fallback) error = %v", err)
|
||||
}
|
||||
if defaultLoaded.Scope != "default" {
|
||||
t.Fatalf("defaultLoaded.Scope = %q, want default", defaultLoaded.Scope)
|
||||
}
|
||||
if !defaultLoaded.OpenBrowser {
|
||||
t.Fatal("defaultLoaded.OpenBrowser = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBrowserPolicy_EnvFallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
configDir := t.TempDir()
|
||||
if _, err := SetBrowserPolicy(configDir, "agt-env", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(env fallback) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "agent" {
|
||||
t.Fatalf("loaded.Scope = %q, want agent", loaded.Scope)
|
||||
}
|
||||
if loaded.AgentCode != "agt-env" {
|
||||
t.Fatalf("loaded.AgentCode = %q, want agt-env", loaded.AgentCode)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBrowserPolicy_FallsBackToOpenSourceDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(fallback) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "builtin_default" {
|
||||
t.Fatalf("loaded.Scope = %q, want builtin_default", loaded.Scope)
|
||||
}
|
||||
if !loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_WritesAgentPolicy(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{"--enabled=false", "--agentCode", "agt-command"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("browser-policy Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if got, _ := payload["scope"].(string); got != "agent" {
|
||||
t.Fatalf("scope = %q, want agent", got)
|
||||
}
|
||||
if got, _ := payload["agentCode"].(string); got != "agt-command" {
|
||||
t.Fatalf("agentCode = %q, want agt-command", got)
|
||||
}
|
||||
if got, _ := payload["openBrowser"].(bool); got {
|
||||
t.Fatal("openBrowser = true, want false")
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "agt-command")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stdout)
|
||||
cmd.SetArgs([]string{"--enabled=false"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("browser-policy Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if got, _ := payload["scope"].(string); got != "default" {
|
||||
t.Fatalf("scope = %q, want default", got)
|
||||
}
|
||||
if _, ok := payload["agentCode"]; ok {
|
||||
t.Fatalf("unexpected agentCode in default policy output: %v", payload["agentCode"])
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", err)
|
||||
}
|
||||
if policy.Default == nil {
|
||||
t.Fatal("policy.Default is nil, want default policy")
|
||||
}
|
||||
if got := len(policy.Agents); got != 0 {
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
+309
-21
@@ -16,8 +16,11 @@ package pat
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -26,13 +29,132 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// resolveSessionIDFromEnv returns the effective session id from environment
|
||||
// variables. Resolution order:
|
||||
// 1. DWS_SESSION_ID (primary, stable env name).
|
||||
// 2. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
|
||||
// already inject the legacy trace triple keep working without code
|
||||
// churn).
|
||||
//
|
||||
// When both are set to different non-empty values, DWS_SESSION_ID wins
|
||||
// silently. We deliberately do NOT log either raw session id value or
|
||||
// any derived fingerprint: this resolver is invoked by `dws pat chmod`
|
||||
// session grants, and any stderr / ~/.dws/logs capture of those
|
||||
// identifiers can land verbatim in attached troubleshooting bundles.
|
||||
// Hosts that need to detect a mismatch between the two env vars must do
|
||||
// so on the host side before invoking the CLI.
|
||||
func resolveSessionIDFromEnv() string {
|
||||
if dws := os.Getenv("DWS_SESSION_ID"); dws != "" {
|
||||
return dws
|
||||
}
|
||||
return os.Getenv("REWIND_SESSION_ID")
|
||||
}
|
||||
|
||||
// agentCodeEnv is the canonical (and only) environment variable name
|
||||
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
|
||||
// commands.
|
||||
//
|
||||
// Why: agent hosts typically set their business agent code once when
|
||||
// spawning a long-lived shell / sub-process; requiring `--agentCode` on
|
||||
// every command in that shell forces the host to rewrite every argv.
|
||||
// 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.
|
||||
//
|
||||
// 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"
|
||||
|
||||
// agentCodePattern is the validation regex for any --agentCode value
|
||||
// resolved from either the flag or the agent-code env var. It matches
|
||||
// documented agent-code generation schemes (e.g. md5 digests, uuid-like
|
||||
// ids, short host-assigned slugs) while rejecting shell metacharacters
|
||||
// and whitespace that would otherwise flow unescaped into an MCP tool
|
||||
// argument.
|
||||
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the
|
||||
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
|
||||
// reports the env name that was consumed (for error attribution); it
|
||||
// is "" when the env is unset or blank. No legacy aliases are honored.
|
||||
func resolveAgentCodeFromEnv() (string, string) {
|
||||
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
|
||||
if primary != "" {
|
||||
return primary, agentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
|
||||
// once spliced into a shell / MCP argv. Allowed character set is
|
||||
// [A-Za-z0-9_-], length 1..64 — see agentCodePattern above.
|
||||
func validateAgentCode(code string) error {
|
||||
if code == "" {
|
||||
return fmt.Errorf("--agentCode must not be empty")
|
||||
}
|
||||
if !agentCodePattern.MatchString(code) {
|
||||
return fmt.Errorf(
|
||||
"invalid agentCode %q: must match %s (A-Z, a-z, 0-9, _, -; 1..64 chars)",
|
||||
code, agentCodePattern.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentCode implements the canonical two-tier 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.
|
||||
//
|
||||
// Any non-empty resolved value is validated via validateAgentCode, so
|
||||
// callers never have to re-validate.
|
||||
func resolveAgentCode(flagVal string, required bool) (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 "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// patGrantToolName is the English-first wire name for the PAT grant tool.
|
||||
patGrantToolName = "pat.grant"
|
||||
|
||||
// patGrantToolNameLegacyAlias is retained for server builds that still
|
||||
// expose only the legacy Chinese display name.
|
||||
patGrantToolNameLegacyAlias = "个人授权"
|
||||
)
|
||||
|
||||
var validGrantTypes = map[string]bool{
|
||||
"once": true,
|
||||
"session": true,
|
||||
"permanent": true,
|
||||
}
|
||||
|
||||
func newChmodCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
// newChmodCommand builds a fresh `dws pat chmod` cobra.Command wired to
|
||||
// the supplied ToolCaller. A factory is used (instead of a package-level
|
||||
// var) so multiple RegisterCommands invocations never share mutable flag /
|
||||
// RunE state across concurrent tests.
|
||||
func newChmodCommand(c edition.ToolCaller) *cobra.Command {
|
||||
chmodCmd := &cobra.Command{
|
||||
Use: "chmod <scope>...",
|
||||
Short: "授予指定权限",
|
||||
@@ -50,9 +172,10 @@ grantType 规则:
|
||||
dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx
|
||||
dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
agentCode, _ := cmd.Flags().GetString("agentCode")
|
||||
if agentCode == "" {
|
||||
return fmt.Errorf("flag --agentCode is required\n hint: dws pat chmod <scope>... --agentCode <id>")
|
||||
flagVal, _ := cmd.Flags().GetString("agentCode")
|
||||
agentCode, err := resolveAgentCode(flagVal, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopes := args
|
||||
grantType, _ := cmd.Flags().GetString("grant-type")
|
||||
@@ -62,14 +185,14 @@ grantType 规则:
|
||||
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
|
||||
}
|
||||
|
||||
if grantType == "session" && sessionID == "" && os.Getenv("DWS_SESSION_ID") == "" {
|
||||
if grantType == "session" && sessionID == "" && resolveSessionIDFromEnv() == "" {
|
||||
return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod <scope> --agentCode <id> --grant-type session --session-id <id>")
|
||||
}
|
||||
|
||||
if caller != nil && caller.DryRun() {
|
||||
if c != nil && c.DryRun() {
|
||||
bold := color.New(color.FgYellow, color.Bold)
|
||||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||||
fmt.Printf("%-16s%s\n", "Tool:", "个人授权")
|
||||
fmt.Printf("%-16s%s\n", "Tool:", patGrantToolName)
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
|
||||
fmt.Printf("%-16s%v\n", "Scope:", scopes)
|
||||
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
|
||||
@@ -79,24 +202,35 @@ grantType 规则:
|
||||
return nil
|
||||
}
|
||||
|
||||
if caller == nil {
|
||||
if c == nil {
|
||||
return fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
|
||||
if sessionID == "" {
|
||||
sessionID = resolveSessionIDFromEnv()
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"agentCode": agentCode,
|
||||
"scope": scopes,
|
||||
"scopes": scopes,
|
||||
"grantType": grantType,
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv("DWS_SESSION_ID")
|
||||
}
|
||||
if sessionID != "" {
|
||||
toolArgs["sessionId"] = sessionID
|
||||
}
|
||||
// Legacy server schema accepted singular "scope"; clone the
|
||||
// canonical argv and rename the key so the two payloads stay
|
||||
// in lock-step on every other field.
|
||||
legacyToolArgs := make(map[string]any, len(toolArgs))
|
||||
for k, v := range toolArgs {
|
||||
if k == "scopes" {
|
||||
legacyToolArgs["scope"] = v
|
||||
continue
|
||||
}
|
||||
legacyToolArgs[k] = v
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs)
|
||||
result, err := callPATToolWithLegacyFallback(ctx, c, "pat", patGrantToolName, patGrantToolNameLegacyAlias, toolArgs, legacyToolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod failed: %w", err)
|
||||
}
|
||||
@@ -105,14 +239,172 @@ grantType 规则:
|
||||
},
|
||||
}
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)")
|
||||
_ = chmodCmd.MarkFlagRequired("agentCode")
|
||||
// --agentCode is required, but we deliberately do NOT call
|
||||
// MarkFlagRequired here. The agent code may also come from the
|
||||
// DINGTALK_DWS_AGENTCODE env var; cobra's MarkFlagRequired would
|
||||
// refuse to run before our resolver has a chance to consume the env.
|
||||
chmodCmd.Flags().String("agentCode", "",
|
||||
"Agent 唯一标识(必填;亦可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先)")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
|
||||
return chmodCmd
|
||||
}
|
||||
|
||||
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
|
||||
// then silently retries the legacy Chinese alias when the server has not
|
||||
// registered the canonical tool yet. The retry intentionally emits no stderr
|
||||
// banner because host-owned PAT callers parse stderr as machine JSON.
|
||||
func callPATToolWithLegacyFallback(ctx context.Context, c edition.ToolCaller, productID, toolName, legacyAlias string, toolArgs, legacyArgs map[string]any) (*edition.ToolResult, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
result, err := c.CallTool(ctx, productID, toolName, toolArgs)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if legacyAlias == "" {
|
||||
return nil, err
|
||||
}
|
||||
if !isToolNotRegisteredError(err) && !isLegacyGrantSchemaMismatchError(err, toolArgs, legacyArgs) {
|
||||
return nil, err
|
||||
}
|
||||
return c.CallTool(ctx, productID, legacyAlias, legacyArgs)
|
||||
}
|
||||
|
||||
func isEmptyToolResult(result *edition.ToolResult) bool {
|
||||
if result == nil || len(result.Content) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, block := range result.Content {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isToolNotRegisteredError reports whether err looks like a server-side
|
||||
// tool-not-registered / tool-not-found classification. We match on a few
|
||||
// conservative substrings rather than a structured error type because the
|
||||
// upstream runner surfaces the server message as plain text.
|
||||
func isToolNotRegisteredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := normalizedPATErrorText(err)
|
||||
needles := []string{
|
||||
"tool_not_found",
|
||||
"mcp_tool_not_found",
|
||||
"tool not found",
|
||||
"tool not registered",
|
||||
"tool not exist",
|
||||
"tool does not exist",
|
||||
"unknown tool",
|
||||
"no such tool",
|
||||
"未找到指定工具",
|
||||
"未找到工具",
|
||||
"工具不存在",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLegacyGrantSchemaMismatchError(err error, toolArgs, legacyArgs map[string]any) bool {
|
||||
if err == nil || !hasScopeKeyShapeMismatch(toolArgs, legacyArgs) {
|
||||
return false
|
||||
}
|
||||
if apperrors.IsPATError(err) {
|
||||
return false
|
||||
}
|
||||
msg := normalizedPATErrorText(err)
|
||||
if containsAny(msg,
|
||||
"pat_no_permission",
|
||||
"pat_low_risk_no_permission",
|
||||
"pat_medium_risk_no_permission",
|
||||
"pat_high_risk_no_permission",
|
||||
"pat_scope_auth_required",
|
||||
"agent_code_not_exists",
|
||||
"requiredscopes",
|
||||
"missingscope",
|
||||
"missing_scope",
|
||||
"insufficient_scope",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if !containsAny(msg, "scope", "scopes") {
|
||||
return false
|
||||
}
|
||||
if !containsAny(msg,
|
||||
"param_error",
|
||||
"参数错误",
|
||||
"parameter",
|
||||
"validation",
|
||||
"required",
|
||||
"missing",
|
||||
"unknown",
|
||||
"unexpected",
|
||||
"invalid",
|
||||
"unmarshal",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if containsAny(msg,
|
||||
"permission denied",
|
||||
"no permission",
|
||||
"forbidden",
|
||||
"unauthorized",
|
||||
"auth required",
|
||||
"无权限",
|
||||
"未授权",
|
||||
"pat_medium_risk_no_permission",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasScopeKeyShapeMismatch(toolArgs, legacyArgs map[string]any) bool {
|
||||
if toolArgs == nil || legacyArgs == nil {
|
||||
return false
|
||||
}
|
||||
_, hasCanonicalPlural := toolArgs["scopes"]
|
||||
_, hasCanonicalSingular := toolArgs["scope"]
|
||||
_, hasLegacyPlural := legacyArgs["scopes"]
|
||||
_, hasLegacySingular := legacyArgs["scope"]
|
||||
return hasCanonicalPlural && !hasCanonicalSingular && hasLegacySingular && !hasLegacyPlural
|
||||
}
|
||||
|
||||
func normalizedPATErrorText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{strings.ToLower(err.Error())}
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) && typed != nil {
|
||||
parts = append(parts,
|
||||
strings.ToLower(typed.Reason),
|
||||
strings.ToLower(typed.ServerDiag.ServerErrorCode),
|
||||
strings.ToLower(typed.ServerDiag.TechnicalDetail),
|
||||
strings.ToLower(typed.Hint),
|
||||
)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func containsAny(msg string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleToolResult processes a ToolResult and writes output to stdout.
|
||||
func handleToolResult(result *edition.ToolResult) error {
|
||||
if result == nil {
|
||||
@@ -128,10 +420,6 @@ func handleToolResult(result *edition.ToolResult) error {
|
||||
fmt.Println(c.Text)
|
||||
return nil
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
data, _ := json.Marshal(result)
|
||||
return fmt.Errorf("empty PAT authorization result: %s", string(data))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type fakeToolCaller struct {
|
||||
mu sync.Mutex
|
||||
dryRun bool
|
||||
gotTool string
|
||||
gotArgs map[string]any
|
||||
callN int
|
||||
resultOK bool
|
||||
}
|
||||
|
||||
func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.callN++
|
||||
f.gotTool = toolName
|
||||
// defensive copy — RunE / runApply may mutate the map after return
|
||||
f.gotArgs = make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
f.gotArgs[k] = v
|
||||
}
|
||||
// Empty success payload keeps handleToolResult / emitApplyResult happy
|
||||
// without triggering PAT classification in errors.ClassifyMCPResponseText.
|
||||
if f.resultOK {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{}}`}}}, nil
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeToolCaller) Format() string { return "json" }
|
||||
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
|
||||
|
||||
type recordedToolCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type fallbackToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return nil, errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackSchemaMismatchToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackSchemaMismatchToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PARAM_ERROR",
|
||||
TechnicalDetail: `input schema validation failed: unknown field "scopes"; missing required field "scope"`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackSchemaMismatchToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackSchemaMismatchToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPermissionDeniedToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPermissionDeniedToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
TechnicalDetail: "permission denied for scope chat.message:send",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *fallbackPermissionDeniedToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPermissionDeniedToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPATErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPATErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, &apperrors.PATError{RawJSON: `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`}
|
||||
}
|
||||
|
||||
func (f *fallbackPATErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPATErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPATContractErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
TechnicalDetail: `missingScope mail:send`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
gotSlice, ok := got.([]string)
|
||||
if !ok || len(gotSlice) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if gotSlice[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildChmod returns a freshly constructed chmod cobra.Command wired to
|
||||
// fake. Using the factory (instead of a package-level var) keeps every
|
||||
// subtest hermetic and matches the upstream shared-state fix in PR #129.
|
||||
func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
|
||||
t.Helper()
|
||||
return newChmodCommand(fake)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1 · Agent-code env fallback tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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.
|
||||
func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
|
||||
// grant-type=once → no session-id needed; keeps the test hermetic.
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v (must not report flag missing)", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode in argv = %v, want %q (env fallback)", got, "qoderwork")
|
||||
}
|
||||
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 _, ok := fake.gotArgs["scope"]; ok {
|
||||
t.Fatalf("unexpected legacy singular scope arg in argv: %#v", fake.gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if !isEmptyToolResult(result) {
|
||||
t.Fatalf("expected original empty canonical result, got %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["scopes"]; !ok {
|
||||
t.Fatalf("canonical args missing scopes: %#v", fake.calls[0].args)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["scope"]; ok {
|
||||
t.Fatalf("canonical args should not use legacy scope: %#v", fake.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_emptyCanonicalResultReturnsError(t *testing.T) {
|
||||
fake := &fallbackToolCaller{}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
_ = cmd.Flags().Set("grant-type", "permanent")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want empty PAT authorization result")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty PAT authorization result") {
|
||||
t.Fatalf("chmod RunE error = %q, want empty PAT authorization result", err.Error())
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_toolNotFoundRetriesLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if isEmptyToolResult(result) {
|
||||
t.Fatalf("fallback result is empty: %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scope"]; !ok {
|
||||
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scopes"]; ok {
|
||||
t.Fatalf("legacy args should not use canonical scopes: %#v", fake.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_schemaMismatchRetriesLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackSchemaMismatchToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if isEmptyToolResult(result) {
|
||||
t.Fatalf("fallback result is empty: %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scope"]; !ok {
|
||||
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_permissionDeniedDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPermissionDeniedToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"chat.message:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"chat.message:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want original permission denial")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "PAT_MEDIUM_RISK_NO_PERMISSION" {
|
||||
t.Fatalf("ServerErrorCode = %q, want PAT_MEDIUM_RISK_NO_PERMISSION", typed.ServerDiag.ServerErrorCode)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_patErrorDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPATErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want PATError")
|
||||
}
|
||||
if !apperrors.IsPATError(err) {
|
||||
t.Fatalf("expected PATError, got %T", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPATContractErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want original PAT contract error")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Fatalf("ServerErrorCode = %q, want PAT_SCOPE_AUTH_REQUIRED", typed.ServerDiag.ServerErrorCode)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
|
||||
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
if !isToolNotRegisteredError(err) {
|
||||
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PARAM_ERROR",
|
||||
TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 未找到指定工具",
|
||||
}),
|
||||
)
|
||||
if !isToolNotRegisteredError(err) {
|
||||
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
|
||||
err := handleToolResult(&edition.ToolResult{})
|
||||
if err == nil {
|
||||
t.Fatal("handleToolResult error = nil, want empty PAT authorization result error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty PAT authorization result") {
|
||||
t.Fatalf("handleToolResult error = %q, want empty PAT authorization result", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_env_invalid verifies that a malformed
|
||||
// DINGTALK_DWS_AGENTCODE value (whitespace, shell metacharacters) is
|
||||
// rejected by the regex gate in validateAgentCode before any MCP call
|
||||
// is attempted.
|
||||
func TestChmod_agentCode_env_invalid(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "bad value with space!")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validateAgentCode error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid agentCode") {
|
||||
t.Fatalf("error = %q, want to mention 'invalid agentCode'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), agentCodeEnv) {
|
||||
t.Fatalf("error = %q, want to attribute to %s env", err.Error(), agentCodeEnv)
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; validator must short-circuit before MCP", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_flag_wins_over_env verifies the Priority-1 contract
|
||||
// of resolveAgentCode: when both the flag and the env are set, the flag
|
||||
// wins and env is silently ignored (no warning needed because the flag is
|
||||
// the explicit, scripted intent).
|
||||
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "envval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("agentCode", "flagval")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if got := fake.gotArgs["agentCode"]; got != "flagval" {
|
||||
t.Fatalf("agentCode in argv = %v, want %q (flag must win over env)", got, "flagval")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 satisfy the --agentCode requirement. The command
|
||||
// is expected to fail with an error that explicitly names the canonical
|
||||
// DINGTALK_DWS_AGENTCODE env, and MUST NOT mention DWS_AGENTCODE as a
|
||||
// usable fallback. No MCP call is permitted.
|
||||
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacyval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error when only legacy DWS_AGENTCODE is set, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DINGTALK_DWS_AGENTCODE") {
|
||||
t.Fatalf("error = %q, want to name canonical DINGTALK_DWS_AGENTCODE env", err.Error())
|
||||
}
|
||||
// Defensive: the canonical env naturally contains the substring
|
||||
// "DWS_AGENTCODE" as part of "DINGTALK_DWS_AGENTCODE"; the above
|
||||
// assertion plus the absence check below precisely guard against
|
||||
// advertising the legacy alias as usable.
|
||||
hint := strings.ReplaceAll(err.Error(), "DINGTALK_DWS_AGENTCODE", "")
|
||||
if strings.Contains(hint, "DWS_AGENTCODE") {
|
||||
t.Fatalf("error = %q must not advertise DWS_AGENTCODE as usable", err.Error())
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy --agentCode", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateAgentCode / resolveAgentCodeFromEnv unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateAgentCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"qoderwork", false},
|
||||
{"agt-abc123", false},
|
||||
{"Agt_Xyz-09", false},
|
||||
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // 64 chars
|
||||
{"", true},
|
||||
{"bad value", true},
|
||||
{"bad!chars", true},
|
||||
{"中文不行", true},
|
||||
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789X", true}, // 65
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateAgentCode(tc.in)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("validateAgentCode(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentCodeFromEnv(t *testing.T) {
|
||||
// Not parallel: mutates process env.
|
||||
|
||||
// DINGTALK_DWS_AGENTCODE is honoured and trimmed.
|
||||
t.Setenv(agentCodeEnv, " qoderwork ")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want (%q, %q)",
|
||||
code, src, "qoderwork", agentCodeEnv)
|
||||
}
|
||||
|
||||
// Empty primary → ("", "").
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
|
||||
}
|
||||
|
||||
// Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the
|
||||
// canonical env is unset — it was hard-removed as a legacy alias.
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacy")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored",
|
||||
code, src)
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -30,10 +30,37 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
|
||||
Long: `管理行为授权(PAT)。
|
||||
|
||||
命令结构:
|
||||
dws pat chmod <scope>... 授予指定权限`,
|
||||
dws pat chmod <scope>... 授予指定权限
|
||||
dws pat browser-policy 配置 PAT 浏览器打开策略
|
||||
|
||||
能力说明:
|
||||
--format 只控制 PAT 撞墙时的输出形态;当 --format json 时,
|
||||
CLI 只返回结构化 JSON,不混入非结构化文本。
|
||||
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
|
||||
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
|
||||
|
||||
Host-owned PAT 开关:
|
||||
当且仅当环境变量 DINGTALK_DWS_AGENTCODE 非空时,CLI 命中 PAT
|
||||
固定以 stderr JSON + exit=4 的 host-owned 形式返回,
|
||||
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
|
||||
CLI 侧不再拉起任何本地浏览器 / 轮询。
|
||||
|
||||
服务端路由标签 claw-type(开源构建硬编码):
|
||||
开源构建在所有出站 MCP 请求上恒定注入 claw-type: openClaw,
|
||||
与 DINGTALK_AGENT / 宿主环境解耦,与历史 main 行为一致。
|
||||
hostControl.clawType 也会回填该值,便于宿主侧审计/路由。
|
||||
|
||||
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
|
||||
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
|
||||
便于上游按业务 Agent 名称区分流量。
|
||||
它不参与 claw-type 派生,也不参与 host-owned PAT 判定。
|
||||
|
||||
DWS_CHANNEL 只用于上游 channelCode。`,
|
||||
RunE: cmdutil.GroupRunE,
|
||||
}
|
||||
|
||||
patCmd.AddCommand(newChmodCommand(c))
|
||||
patCmd.AddCommand(newBrowserPolicyCommand())
|
||||
root.AddCommand(patCmd)
|
||||
}
|
||||
|
||||
+14
-3
@@ -13,9 +13,20 @@
|
||||
|
||||
package edition
|
||||
|
||||
// DefaultOSSClawType is the wire value for request header claw-type in
|
||||
// the open-source build. It is intentionally hard-wired — the open-source
|
||||
// CLI does NOT derive claw-type from DINGTALK_AGENT or any other caller
|
||||
// input, so third-party hosts get a predictable header regardless of
|
||||
// their environment.
|
||||
const DefaultOSSClawType = "openClaw"
|
||||
|
||||
// defaultHooks returns the open-source edition defaults.
|
||||
// All function hooks are nil, which the internal code interprets as
|
||||
// "use standard open-source behaviour".
|
||||
//
|
||||
// MergeHeaders is the only hook that ships with behaviour: it pins the
|
||||
// `claw-type` request header to DefaultOSSClawType so every open-source
|
||||
// MCP request carries the same stable routing tag. All other fields are
|
||||
// nil — the internal code interprets nil as "use standard open-source
|
||||
// behaviour".
|
||||
func defaultHooks() *Hooks {
|
||||
return &Hooks{
|
||||
Name: "open",
|
||||
@@ -23,7 +34,7 @@ func defaultHooks() *Hooks {
|
||||
if base == nil {
|
||||
base = make(map[string]string)
|
||||
}
|
||||
base["claw-type"] = "openClaw"
|
||||
base["claw-type"] = DefaultOSSClawType
|
||||
return base
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenSourceTreeOmitsEmbeddedHostMarkers scans source files for proprietary
|
||||
// markers that must not leak into the public (open-source) tree.
|
||||
//
|
||||
// The scanner covers public docs as well as source code because OSS leakage is
|
||||
// often introduced through documentation first. Only a small set of explicitly
|
||||
// documented compatibility literals (see NOTE below) are allowed to remain in
|
||||
// tree; everything else should trip the guard regardless of whether it appears
|
||||
// in Go, shell, YAML, templates, or markdown.
|
||||
func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
@@ -17,6 +25,14 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
}
|
||||
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
// NOTE: REWIND_SESSION_ID / REWIND_REQUEST_ID / REWIND_MESSAGE_ID are
|
||||
// intentionally NOT on this list. They are accepted by the CLI as
|
||||
// optional backward-compatibility aliases for the primary DWS_* trace
|
||||
// env names. Because they are a documented compatibility surface
|
||||
// rather than an internal coupling to a specific host implementation,
|
||||
// referring to these literals from source code and docs is allowed.
|
||||
// Product names like "RewindDesktop" and other host-implementation
|
||||
// specific symbols remain forbidden.
|
||||
forbidden := []string{
|
||||
"DWS_" + "BUILD_MODE",
|
||||
"com.dingtalk.scenario." + "wukong",
|
||||
@@ -27,12 +43,6 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
"EnablePrivate" + "UtilityCommands",
|
||||
"UseExecutable" + "ConfigDir",
|
||||
"DeleteExeRelative" + "TokenOnAuthErr",
|
||||
"WriteToken" + "Marker",
|
||||
"Token" + "Marker",
|
||||
"tokenJSON" + "File",
|
||||
"REWIND_" + "REQUEST_ID",
|
||||
"REWIND_" + "SESSION_ID",
|
||||
"REWIND_" + "MESSAGE_ID",
|
||||
"MergeWukong" + "MCPHeaders",
|
||||
"buildMode ==" + " \"real\"",
|
||||
"wukong/" + "discovery",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Regression guard: several earlier drafts conflated the two signals,
|
||||
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
|
||||
// to still see a browser popup (CLI-owned fallback). This table locks
|
||||
// in the decoupling.
|
||||
func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
agentCode string
|
||||
agentEnv string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no signal → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "agent code only → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=default → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "default",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=business → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "sales-copilot",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DINGTALK_AGENT=business, no agent code → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "sales-copilot",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DINGTALK_AGENT=default, no agent code → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "default",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace-only agent code → CLI-owned",
|
||||
agentCode: " ",
|
||||
agentEnv: "sales-copilot",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
|
||||
// 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
|
||||
// DingTalkAgentEnv constant (it is not part of the PAT
|
||||
// decision surface).
|
||||
t.Setenv("DINGTALK_AGENT", tc.agentEnv)
|
||||
|
||||
if got := authpkg.HostOwnsPATFlow(); got != tc.want {
|
||||
t.Fatalf(
|
||||
"HostOwnsPATFlow() = %v, want %v (agentCode=%q, DINGTALK_AGENT=%q)",
|
||||
got, tc.want, tc.agentCode, tc.agentEnv,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
errpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// D5 Smoke A — PAT stderr JSON is a single-line, json.Unmarshal-able
|
||||
// payload for every code in the frozen enum.
|
||||
//
|
||||
// This test is the CI-level guard for the wire invariant ("stderr JSON
|
||||
// MUST be single-line and directly unmarshal-able"). It sits in the
|
||||
// public `test/unit` tree — deliberately separate from
|
||||
// internal/errors/pat_test.go — so the contract stays defended even
|
||||
// after internal refactors that might move the JSON assembly code to
|
||||
// another package or collapse helpers into it.
|
||||
//
|
||||
// Wire path exercised: ClassifyPatAuthCheck covers BOTH error families
|
||||
// the SSOT freezes — patNoPermissionCodes (PAT_NO_PERMISSION and the
|
||||
// three risk-tier variants) and patAuthRequiredCodes
|
||||
// (PAT_SCOPE_AUTH_REQUIRED, AGENT_CODE_NOT_EXISTS). Using this single
|
||||
// entry point keeps the test format-agnostic to future code-churn in
|
||||
// ClassifyMCPResponseText's branching while still covering the full
|
||||
// SSOT code enum.
|
||||
//
|
||||
// What we assert for every case:
|
||||
// 1. Non-nil *PATError is returned with ExitCode == 4.
|
||||
// 2. RawJSON has NO '\n' characters (single-line invariant).
|
||||
// 3. RawJSON is directly json.Unmarshal-able into a map.
|
||||
// 4. Parsed body has success=false and code matching the fixture.
|
||||
// 5. Required downstream data shape is present (requiredScopes for
|
||||
// PAT_*_NO_PERMISSION; missingScope for PAT_SCOPE_AUTH_REQUIRED).
|
||||
func TestPATStderrJSON_SingleLineUnmarshalable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type assertFn func(t *testing.T, parsed map[string]any)
|
||||
|
||||
requiredScopesPresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
scopes, ok := data["requiredScopes"].([]any)
|
||||
if !ok || len(scopes) == 0 {
|
||||
t.Fatalf("parsed.data.requiredScopes missing or empty: %v", data["requiredScopes"])
|
||||
}
|
||||
for i, s := range scopes {
|
||||
if _, ok := s.(string); !ok {
|
||||
t.Fatalf("parsed.data.requiredScopes[%d] is not a string: %v", i, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
missingScopePresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
scope, ok := data["missingScope"].(string)
|
||||
if !ok || scope == "" {
|
||||
t.Fatalf("parsed.data.missingScope missing or not a string: %v", data["missingScope"])
|
||||
}
|
||||
}
|
||||
|
||||
agentCodeFieldPresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
if _, ok := data["agentCode"].(string); !ok {
|
||||
t.Fatalf("parsed.data.agentCode missing or not a string: %v", data["agentCode"])
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
body map[string]any
|
||||
extract assertFn
|
||||
}{
|
||||
{
|
||||
name: "PAT_NO_PERMISSION generic baseline",
|
||||
code: "PAT_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "阅读多维表记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_LOW_RISK_NO_PERMISSION",
|
||||
code: "PAT_LOW_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "阅读多维表记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
code: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"chat.group:write"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "发送群消息",
|
||||
"productName": "群聊",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
code: "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"finance.invoice:write"},
|
||||
"grantOptions": []any{"once"},
|
||||
"displayName": "开具发票",
|
||||
"productName": "财务",
|
||||
"authRequestId": "auth-req-xxxx",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
code: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_SCOPE_AUTH_REQUIRED",
|
||||
"data": map[string]any{
|
||||
"missingScope": "Contact.User.Read",
|
||||
"hint": "run `dws auth login --scope Contact.User.Read`",
|
||||
},
|
||||
},
|
||||
extract: missingScopePresent,
|
||||
},
|
||||
{
|
||||
name: "AGENT_CODE_NOT_EXISTS",
|
||||
code: "AGENT_CODE_NOT_EXISTS",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "AGENT_CODE_NOT_EXISTS",
|
||||
"data": map[string]any{
|
||||
"agentCode": "agt-unknown",
|
||||
"hint": "请检查 DINGTALK_DWS_AGENTCODE / agent 注册",
|
||||
},
|
||||
},
|
||||
extract: agentCodeFieldPresent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
patErr := errpkg.ClassifyPatAuthCheck(tc.body)
|
||||
if patErr == nil {
|
||||
t.Fatalf("ClassifyPatAuthCheck(%s) returned nil; expected *PATError", tc.code)
|
||||
}
|
||||
if patErr.ExitCode() != errpkg.ExitCodePermission {
|
||||
t.Fatalf("ExitCode() = %d, want %d (PAT exit_code MUST be 4)",
|
||||
patErr.ExitCode(), errpkg.ExitCodePermission)
|
||||
}
|
||||
|
||||
raw := patErr.RawStderr()
|
||||
if raw == "" {
|
||||
t.Fatalf("RawStderr() is empty; expected single-line JSON")
|
||||
}
|
||||
if strings.ContainsAny(raw, "\n\r") {
|
||||
t.Fatalf("RawStderr() MUST be single-line (no \\n or \\r); got:\n%q", raw)
|
||||
}
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
t.Fatalf("RawStderr() MUST not have leading whitespace; got: %q", raw)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal(RawStderr()) error = %v; raw=%q", err, raw)
|
||||
}
|
||||
|
||||
if success, ok := parsed["success"].(bool); !ok || success {
|
||||
t.Fatalf("parsed.success = %v, want false", parsed["success"])
|
||||
}
|
||||
gotCode, ok := parsed["code"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.code missing or not a string: %v", parsed["code"])
|
||||
}
|
||||
if gotCode != tc.code {
|
||||
t.Fatalf("parsed.code = %q, want %q", gotCode, tc.code)
|
||||
}
|
||||
|
||||
tc.extract(t, parsed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPATStderrJSON_CodeEnumFrozen pins the exact code enum frozen for
|
||||
// PAT-family errors (see patNoPermissionCodes / patAuthRequiredCodes in
|
||||
// internal/errors/pat.go). If a future refactor accidentally renames a
|
||||
// code (e.g. drops the risk-tier prefix) the classifier will stop
|
||||
// producing a *PATError and this table-driven loop will fail with a
|
||||
// clear message — preventing a silent break of the host integration
|
||||
// contract.
|
||||
func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
frozen := []string{
|
||||
"PAT_NO_PERMISSION",
|
||||
"PAT_LOW_RISK_NO_PERMISSION",
|
||||
"PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"PAT_HIGH_RISK_NO_PERMISSION",
|
||||
"PAT_SCOPE_AUTH_REQUIRED",
|
||||
"AGENT_CODE_NOT_EXISTS",
|
||||
}
|
||||
|
||||
for _, code := range frozen {
|
||||
code := code
|
||||
t.Run(code, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": code,
|
||||
"data": map[string]any{"requiredScopes": []any{"stub.entity:read"}},
|
||||
}
|
||||
if patErr := errpkg.ClassifyPatAuthCheck(body); patErr == nil {
|
||||
t.Fatalf("ClassifyPatAuthCheck(%q) returned nil — code was removed from the SSOT-frozen enum", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user