Files
dingtalk-workspace-cli/pkg/edition/edition.go
T
shangguanxuan633-labandshangguanxuan.sgx 725577103d feat: PAT scope error visualization and auto-retry with authorization polling (#113)
* feat: PAT scope error visualization and auto-retry with authorization polling

1. PAT error result visualization (non-JSON)
   - Human-readable error output matching lark-cli style
   - Shows error type, message, hint, and authorization command
   - JSON output also available via --format json

2. Auto-retry with polling after authorization wall
   - Detects missing_scope / insufficient_scope / permission errors
   - Polls every 5s for token update after user completes auth
   - 10 minute timeout before giving up
   - Automatically retries the original command after successful auth

Changes:
- internal/app/pat_auth_retry.go: Core PAT error handling and retry logic
- internal/app/pat_auth_retry_test.go: 11 unit tests
- internal/app/runner.go: Integration with executeInvocation error path
- internal/auth/device_flow.go: Add SetScope() method

* feat: migrate PAT (Permission Authorization) infrastructure to open-source core

Move PAT permission management code from dws-wukong overlay to the
open-source dingtalk-workspace-cli core. This enables PAT handling
in the public distribution while keeping dws-wukong fully compatible
through the edition.Hooks mechanism.

Changes:
- pkg/edition: add 6 new Hooks fields (AuthClientID, AuthClientFromMCP,
  SaveToken, LoadToken, DeleteToken, ClassifyToolResult) for overlay
  extensions to inject custom auth/token/error behaviour
- internal/errors/pat: new PATError type with ExitCode()=4 and
  RawStderr() for embedded-mode passthrough, plus ClassifyToolResultContent
  and ClassifyMCPResponseText classification functions
- internal/pat: new command group (pat chmod) with tool result handling
- internal/app/root: register PAT commands before RegisterExtraCommands hook
- internal/app/runner: integrate ClassifyToolResult hook in the
  callResult.IsError branch so overlays can intercept PAT/gateway errors
  before generic handling

The dws-wukong overlay remains unchanged — its existing pat/ package and
RegisterExtraCommands hook continue to work. Through the go.mod replace
directive and deduplicateCommands mechanism, both codebases coexist
without conflict.

* feat(pat): add PAT auth check with device flow polling and auto-retry

- Add ClassifyPatAuthCheck / AsPatAuthCheckError in pat.go for
  AGENT_CODE_NOT_EXISTS error detection
- Add handlePatAuthCheck in pat_auth_retry.go: inject clientId as
  x-robot-uid header, poll device flow endpoint, auto-retry on APPROVED
- Integrate PAT check into runner.go executeInvocation pipeline
  (edition hook + open-source fallback)
- Add patAuthRequiredCodes map for extensible auth-required code matching
- Remove test_parse.go (temporary test script)

* fix(pat): update poll path to /cli/oauth/device/poll and add x-user-access-token header

- Change DevicePollPath from /api/dingtalk-workspace-cli/oauth/device/poll
  to /cli/oauth/device/poll (aligning with backend endpoint convention)
- Add x-user-access-token header to poll requests (loaded from stored token)

* feat(auth): PAT device flow improvements and flowId empty guard

- pat_auth_retry: fix JSON field parsing (result -> data) using DevicePollResponse
- pat_auth_retry: fix elapsed time calculation based on start timestamp
- device_flow: add flowId empty guard - skip polling and show auth URL for manual handling
- device_flow: unify terminalBaseURL to GetMCPBaseURL for polling endpoint
- device_flow: remove unused PollFlowApproval dead code
- runner: integrate DWS_CLIENT_ID env and app.json persistence
- oauth_provider: add x-robot-uid header removal on logout
- client: add request/response logging for transport debugging

* feat(env): switch all service discovery URLs from pre-release to production

- endpoints: pre-login/pre-api/pre-mcp/pre-open-dev -> login/api/mcp/open-dev
- loader: DefaultMarketBaseURL -> mcp.dingtalk.com
- registry: defaultBaseURL -> mcp.dingtalk.com
- constants: DefaultTerminalBaseURL -> open-dev.dingtalk.com

* fix: remove residual merge conflict markers and duplicate Hooks fields in edition.go; unify developer-settings URL via config helper in device_flow.go

* chore: remove lark-cli reference from PrintPatAuthError comment

* test: add comprehensive unit tests for PAT auth modules

- Create internal/errors/pat_test.go (38 tests): covers ClassifyToolResultContent,
  ClassifyMCPResponseText, ClassifyPatAuthCheck, AsPatAuthCheckError, cleanPATJSON,
  stripClassFields, getDWSGatewayErrorCode, isNotLoggedInError, isBusinessError,
  IsPATError, IsPATNoPermissionCode, suggestForBusinessErrorText
- Supplement pat_auth_retry_test.go (+7 tests): IsPatRetrying context checks,
  pollPatDeviceFlow edge cases (server error fallback, SSO redirect skip),
  extractPatScopeError nil/identity extraction

* feat(pat): exchange authCode for fresh token after PAT APPROVED

Previously pollPatDeviceFlow discarded the authCode from the poll
response, so handlePatAuthCheck retried with the stale token after
APPROVED. This mirrors device_flow.go loginOnce which calls
exchangeCode → SaveTokenData.

Changes:
- pollPatDeviceFlow: return (status, authCode, error) instead of
  (status, error); extract Data.AuthCode on APPROVED
- handlePatAuthCheck APPROVED path: call ExchangeCodeForToken +
  SaveTokenData before ResetRuntimeTokenCache, with graceful
  fallback on exchange failure
- Update all 6 poll test cases for new 3-return signature; add
  authCode assertions for APPROVED/REJECTED/EXPIRED/CANCELLED/
  ServerError scenarios

* fix(pat): CR round-2 must-fix items

- scopeValueRegex: support multi-segment scopes (mail:a.b:send)
  Updated regex to ([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)

- handlePatAuthCheck: add 3 integration tests (APPROVED/REJECTED/EmptyFlowID)
  Covers the 132-line main orchestrator with mock runner + httptest server

- Extract ParseDeviceFlowStatus + status constants to auth package
  Eliminates string literal duplication across device_flow.go and pat_auth_retry.go

* fix(pat): use direct OAuth path when clientSecret is provided

When PAT error response includes both clientId and clientSecret,
use SetClientID() (direct DingTalk API mode) instead of
SetClientIDFromMCP() (MCP proxy mode). The MCP proxy does not
hold the secret for the PAT-assigned app, causing HTTP 400
'invalidParameter.idOrSecret.notFound' on token exchange.

Now: clientSecret present → direct mode; absent → MCP proxy mode.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-04-19 19:12:34 +08:00

125 lines
4.4 KiB
Go

// 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 edition provides an extension-point mechanism that allows private
// overlay modules (e.g. an internal distribution) to customise CLI behaviour
// without modifying the open-source core. The open-source build uses the
// zero-value defaults; an overlay calls Override before Execute.
package edition
import (
"context"
"sync"
"github.com/spf13/cobra"
)
// ServerInfo describes a static MCP server endpoint.
type ServerInfo struct {
ID string
Name string
Endpoint string
Prefixes []string
}
// ContentBlock is a single content item in a ToolResult.
type ContentBlock struct {
Type string
Text string
}
// ToolResult holds the response from an MCP tool call.
type ToolResult struct {
Content []ContentBlock
}
// ToolCaller abstracts MCP tool invocation so private overlays can call MCP
// tools without importing internal packages. The open-source core provides a
// concrete adapter wrapping executor.Runner.
type ToolCaller interface {
// CallTool invokes an MCP tool by product ID and tool name.
CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error)
// Format returns the current output format ("json", "table", "raw").
Format() string
// DryRun returns true when --dry-run is active.
DryRun() bool
}
// Hooks groups all edition-specific behavioural overrides. Zero values
// fall back to open-source defaults so the struct is safe to use as-is.
type Hooks struct {
// --- identity ---
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// --- runtime mode ---
IsEmbedded bool // true when running inside a host application
HideAuthLogin bool // true suppresses the "dws auth login" command
AutoPurgeToken bool // true deletes local token data on expiry
// --- paths ---
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string
// --- auth ---
AuthClientID string // OAuth client ID for device-flow authorisation
AuthClientFromMCP bool // true → fetch client ID from MCP at runtime
OnAuthError func(configDir string, err error) error
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
// --- token persistence (overlay-managed keychain / encrypted storage) ---
SaveToken func(configDir string, data []byte) error // persist token blob
LoadToken func(configDir string) ([]byte, error) // retrieve token blob
DeleteToken func(configDir string) error // remove persisted token
// --- MCP result classification ---
// ClassifyToolResult inspects raw MCP tool-call content and returns a typed
// error (e.g. PATError, CLIError) when the response contains a known
// gateway-auth or PAT-permission failure. nil → no special handling.
ClassifyToolResult func(content map[string]any) error
// --- product & endpoint ---
StaticServers func() []ServerInfo // non-nil → skip Market discovery
VisibleProducts func() []string // non-nil → override help visibility
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
// AfterPersistentPreRun runs at the end of the root PersistentPreRunE after
// global setup (OAuth flag overrides, log level, output sink). Overlays use
// this for clients that bypass the MCP runner (e.g. A2A gateway).
AfterPersistentPreRun func(cmd *cobra.Command, args []string) error
}
var (
mu sync.RWMutex
current = defaultHooks()
)
// Get returns the active edition hooks (never nil).
func Get() *Hooks {
mu.RLock()
defer mu.RUnlock()
return current
}
// Override replaces the active edition hooks. Must be called before Execute.
func Override(h *Hooks) {
if h == nil {
return
}
mu.Lock()
defer mu.Unlock()
current = h
}