Files
dingtalk-workspace-cli/internal/transport/client.go
T
audanye-sudo c8f1a9a912 chore: fix staticcheck warnings and restore missing .env.example
- Replace nil context with context.TODO() in logger calls
- Fix import ordering and struct field alignment
- Restore .env.example required by open-source audit tests
2026-03-29 00:30:27 +08:00

889 lines
26 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 transport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"io"
"log/slog"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
)
const (
trustedDomainsEnv = "DWS_TRUSTED_DOMAINS"
defaultTrustedDomains = "*.dingtalk.com"
// defaultHTTPTimeout is the default timeout for HTTP transport requests.
defaultHTTPTimeout = 30 * time.Second
// Default retry parameters for JSON-RPC calls.
defaultMaxRetries = 2
defaultRetryDelay = 10 * time.Millisecond
defaultRetryMaxDelay = 80 * time.Millisecond
// Security headers
HeaderSource = "X-Cli-Source"
HeaderVersion = "X-Cli-Version"
HeaderExecutionId = "X-Cli-Execution-Id"
SourceValue = "dws-cli"
)
// Supported MCP protocol versions, ordered from newest to oldest.
var supportedProtocolVersions = []string{
"2025-03-26",
"2024-11-05",
"2024-06-18",
}
type Client struct {
HTTPClient *http.Client
MaxRetries int
RetryDelay time.Duration
RetryMaxDelay time.Duration
AuthToken string
ExtraHeaders map[string]string
SnapshotRecorder SnapshotRecorder
TrustedDomains []string
ExecutionId string // Request tracing ID for debugging
FileLogger *slog.Logger // Structured file logger for diagnostics (nil-safe).
sleep func(context.Context, time.Duration) error
wildcardOnce sync.Once
// Stderr is the writer for warning messages. Defaults to os.Stderr.
Stderr io.Writer
}
type SnapshotRecorder interface {
RecordJSONRPC(method, endpoint string, requestBody, responseBody []byte) string
}
type requestEnvelope struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type responseEnvelope struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *RPCError `json:"error"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
type InitializeResult struct {
RequestedProtocolVersion string `json:"requested_protocol_version"`
ProtocolVersion string `json:"protocol_version"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo map[string]any `json:"server_info"`
}
type ToolDescriptor struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
OutputSchema map[string]any `json:"outputSchema,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
}
type ToolsListResult struct {
Tools []ToolDescriptor `json:"tools"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type ToolCallResult struct {
Content map[string]any `json:"-"`
StructuredContent map[string]any `json:"structuredContent,omitempty"`
Blocks []ContentBlock `json:"content,omitempty"`
IsError bool `json:"isError,omitempty"`
}
func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
type rawResult struct {
Content json.RawMessage `json:"content"`
StructuredContent map[string]any `json:"structuredContent"`
IsError bool `json:"isError,omitempty"`
}
var raw rawResult
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
r.StructuredContent = cloneAnyMap(raw.StructuredContent)
r.IsError = raw.IsError
if len(bytes.TrimSpace(raw.Content)) == 0 {
if len(r.StructuredContent) > 0 {
r.Content = cloneAnyMap(r.StructuredContent)
}
return nil
}
var object map[string]any
if err := json.Unmarshal(raw.Content, &object); err == nil {
r.Content = object
return nil
}
var blocks []ContentBlock
if err := json.Unmarshal(raw.Content, &blocks); err == nil {
r.Blocks = blocks
if len(r.StructuredContent) > 0 {
r.Content = cloneAnyMap(r.StructuredContent)
return nil
}
for _, block := range blocks {
text := strings.TrimSpace(block.Text)
if text == "" {
continue
}
var parsed map[string]any
if err := json.Unmarshal([]byte(text), &parsed); err == nil {
r.Content = parsed
return nil
}
}
return nil
}
return fmt.Errorf("unsupported tools/call content shape")
}
func NewClient(httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{
Timeout: defaultHTTPTimeout,
CheckRedirect: safeRedirectPolicy,
}
} else if httpClient.CheckRedirect == nil {
// Wrap existing client with safe redirect policy
httpClient.CheckRedirect = safeRedirectPolicy
}
return &Client{
HTTPClient: httpClient,
MaxRetries: defaultMaxRetries,
RetryDelay: defaultRetryDelay,
RetryMaxDelay: defaultRetryMaxDelay,
}
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. API 302 → CDN).
// Strips Authorization, x-user-access-token on cross-host redirects;
// other headers like X-Cli-* pass through.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
// Cross-host redirect: strip sensitive headers to prevent credential leakage
req.Header.Del("Authorization")
req.Header.Del("x-user-access-token")
}
return nil
}
// WithAuth returns a shallow copy of c with the given auth token and extra
// headers. The returned client shares the underlying HTTP client but is safe
// to use concurrently with the original.
func (c *Client) WithAuth(token string, headers map[string]string) *Client {
return &Client{
HTTPClient: c.HTTPClient,
MaxRetries: c.MaxRetries,
RetryDelay: c.RetryDelay,
RetryMaxDelay: c.RetryMaxDelay,
AuthToken: token,
ExtraHeaders: headers,
SnapshotRecorder: c.SnapshotRecorder,
TrustedDomains: c.TrustedDomains,
ExecutionId: c.ExecutionId,
FileLogger: c.FileLogger,
sleep: c.sleep,
Stderr: c.Stderr,
}
}
// WithExecutionId returns a shallow copy of c with the given execution ID.
// The execution ID is included in requests for tracing and debugging.
func (c *Client) WithExecutionId(executionId string) *Client {
copy := c.WithAuth(c.AuthToken, c.ExtraHeaders)
copy.ExecutionId = executionId
return copy
}
func SupportedProtocolVersions() []string {
return append([]string(nil), supportedProtocolVersions...)
}
func (c *Client) Initialize(ctx context.Context, endpoint string) (InitializeResult, error) {
for _, version := range SupportedProtocolVersions() {
params := map[string]any{
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "dws",
"version": "0.0.0-dev",
},
"protocolVersion": version,
}
var payload InitializeResult
if err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
Params: params,
}, true, &payload); err == nil {
if payload.ProtocolVersion == "" {
payload.ProtocolVersion = version
}
payload.RequestedProtocolVersion = version
return payload, nil
}
}
return InitializeResult{}, apperrors.NewDiscovery(fmt.Sprintf("initialize failed for all supported protocol versions at %s", RedactURL(endpoint)))
}
func (c *Client) NotifyInitialized(ctx context.Context, endpoint string) error {
return c.callJSONRPC(ctx, endpoint, requestEnvelope{
JSONRPC: "2.0",
Method: "notifications/initialized",
Params: map[string]any{},
}, false, nil)
}
func (c *Client) ListTools(ctx context.Context, endpoint string) (ToolsListResult, error) {
var payload ToolsListResult
if err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
JSONRPC: "2.0",
ID: 2,
Method: "tools/list",
}, true, &payload); err != nil {
return ToolsListResult{}, err
}
return payload, nil
}
func (c *Client) CallTool(ctx context.Context, endpoint, tool string, arguments map[string]any) (ToolCallResult, error) {
// Validate tool name to prevent injection via control chars or path traversal.
if err := validate.RejectControlChars(tool, "tool name"); err != nil {
return ToolCallResult{}, apperrors.NewValidation(err.Error())
}
// Validate string arguments at the transport boundary.
if err := validateCallArguments(arguments); err != nil {
return ToolCallResult{}, apperrors.NewValidation(err.Error())
}
var payload ToolCallResult
if err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
JSONRPC: "2.0",
ID: 3,
Method: "tools/call",
Params: map[string]any{
"name": tool,
"arguments": arguments,
},
}, true, &payload); err != nil {
return ToolCallResult{}, err
}
return payload, nil
}
func cloneAnyMap(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
out := make(map[string]any, len(src))
for key, value := range src {
out[key] = value
}
return out
}
func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request requestEnvelope, expectResponse bool, out any) error {
body, err := json.Marshal(request)
if err != nil {
return apperrors.NewInternal("failed to encode JSON-RPC request")
}
logging.LogRequest(c.FileLogger, request.Method, endpoint, c.ExecutionId, len(body))
callStart := time.Now()
resp, err := c.doWithRetry(ctx, endpoint, body)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
logging.LogResponse(c.FileLogger, request.Method, endpoint, resp.StatusCode, len(data), time.Since(callStart), err)
if err != nil {
return apperrors.NewDiscovery(
"failed to read JSON-RPC response",
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "response_read_failed")),
apperrors.WithHint(i18n.T("检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。")),
apperrors.WithActions(discoveryActions("")...),
)
}
snapshotPath := ""
if c.SnapshotRecorder != nil && (expectResponse || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || len(bytes.TrimSpace(data)) > 0) {
snapshotPath = c.SnapshotRecorder.RecordJSONRPC(request.Method, endpoint, body, data)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath)
}
if !expectResponse {
return nil
}
var envelope responseEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
return apperrors.NewDiscovery(
fmt.Sprintf("unexpected protocol response from %s", RedactURL(endpoint)),
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "invalid_response")),
apperrors.WithHint(i18n.T("MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
)
}
if envelope.Error != nil {
return jsonrpcEnvelopeError(request.Method, envelope.Error, snapshotPath)
}
if len(envelope.Result) == 0 {
return apperrors.NewDiscovery(
fmt.Sprintf("JSON-RPC %s returned an empty result payload", request.Method),
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "empty_result")),
apperrors.WithHint(i18n.T("服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
)
}
if out == nil {
return nil
}
if err := json.Unmarshal(envelope.Result, out); err != nil {
return apperrors.NewDiscovery(
fmt.Sprintf("failed to decode JSON-RPC %s result", request.Method),
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "result_decode_failed")),
apperrors.WithHint(i18n.T("结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
)
}
return nil
}
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
// Strip any query/fragment from the endpoint to prevent parameter injection.
endpoint = validate.StripQueryFragment(endpoint)
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, apperrors.NewDiscovery(
"failed to create JSON-RPC request",
apperrors.WithOperation("jsonrpc"),
apperrors.WithReason("request_build_failed"),
apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")),
apperrors.WithCause(&CallError{
Stage: CallStageRequest,
Cause: err,
}),
)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Set security headers for request tracing
req.Header.Set(HeaderSource, SourceValue)
if c.ExecutionId != "" {
req.Header.Set(HeaderExecutionId, c.ExecutionId)
}
if token := sanitizeBearerToken(c.AuthToken); token != "" {
if c.isEndpointTrusted(endpoint) {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("x-user-access-token", token)
}
}
for key, value := range c.ExtraHeaders {
if key != "" && value != "" {
req.Header.Set(key, value)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
lastErr = err
} else if !retryable(resp.StatusCode) || attempt == c.MaxRetries {
return resp, nil
} else {
lastErr = fmt.Errorf("retryable HTTP %d", resp.StatusCode)
resp.Body.Close()
}
if attempt < c.MaxRetries {
delay := c.retryDelayForAttempt(attempt, respRetryAfter(resp))
if err := c.sleepForRetry(ctx, delay); err != nil {
return nil, apperrors.NewDiscovery(
"request cancelled during retry",
apperrors.WithOperation("jsonrpc"),
apperrors.WithReason("request_cancelled"),
apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")),
apperrors.WithCause(&CallError{
Stage: CallStageRequest,
Cause: err,
}),
)
}
}
}
return nil, apperrors.NewDiscovery(
fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr),
apperrors.WithOperation("jsonrpc"),
apperrors.WithReason("request_failed"),
apperrors.WithRetryable(true),
apperrors.WithHint(i18n.T("请检查网络连通性和 MCP 服务状态后重试。")),
apperrors.WithActions(discoveryActions("")...),
apperrors.WithCause(&CallError{
Stage: CallStageRequest,
Cause: lastErr,
}),
)
}
func retryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
}
func respRetryAfter(resp *http.Response) string {
if resp == nil {
return ""
}
return strings.TrimSpace(resp.Header.Get("Retry-After"))
}
func (c *Client) retryDelayForAttempt(attempt int, retryAfter string) time.Duration {
base := c.RetryDelay
if base <= 0 {
base = 10 * time.Millisecond
}
maxDelay := c.RetryMaxDelay
if maxDelay <= 0 {
maxDelay = base * 8
}
if delay, ok := parseRetryAfter(retryAfter); ok {
if delay > maxDelay {
return maxDelay
}
if delay > 0 {
return delay
}
}
delay := base << attempt
if delay > maxDelay {
return maxDelay
}
return delay
}
func (c *Client) sleepForRetry(ctx context.Context, delay time.Duration) error {
if c != nil && c.sleep != nil {
return c.sleep(ctx, delay)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func parseRetryAfter(raw string) (time.Duration, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
if seconds, err := time.ParseDuration(raw + "s"); err == nil && seconds > 0 {
return seconds, true
}
deadline, err := http.ParseTime(raw)
if err != nil {
return 0, false
}
delay := time.Until(deadline)
if delay < 0 {
return 0, true
}
return delay, true
}
// isEndpointTrusted checks whether the endpoint is HTTPS and belongs to a
// trusted domain. When no auth token is set this is a no-op.
// Set DWS_ALLOW_HTTP_ENDPOINTS=1 for development/testing to allow HTTP, but
// only for loopback addresses (127.0.0.1, ::1, localhost).
// Set DWS_TRUSTED_DOMAINS=* to trust all domains.
func (c *Client) isEndpointTrusted(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
}
if !strings.EqualFold(parsed.Scheme, "https") {
if os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" {
return false
}
// Even with the bypass, only allow loopback addresses.
host := parsed.Hostname()
if host != "127.0.0.1" && host != "::1" && host != "localhost" {
return false
}
}
host := parsed.Hostname()
domains := c.trustedDomainsList()
if len(domains) == 0 {
return true
}
for _, pattern := range domains {
if pattern == "*" {
c.warnWildcardDomains()
return true
}
if matchDomain(host, pattern) {
return true
}
}
return false
}
func (c *Client) trustedDomainsList() []string {
if len(c.TrustedDomains) > 0 {
return c.TrustedDomains
}
if envVal := strings.TrimSpace(os.Getenv(trustedDomainsEnv)); envVal != "" {
return strings.Split(envVal, ",")
}
return strings.Split(defaultTrustedDomains, ",")
}
func (c *Client) warnWildcardDomains() {
c.wildcardOnce.Do(func() {
w := c.Stderr
if w == nil {
w = os.Stderr
}
fmt.Fprintln(w, "[WARN] DWS_TRUSTED_DOMAINS=* sends bearer token to ANY HTTPS endpoint. Use only for development.")
})
}
func matchDomain(host, pattern string) bool {
host = strings.ToLower(strings.TrimSpace(host))
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == "" {
return false
}
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:] // e.g. ".dingtalk.com"
return strings.HasSuffix(host, suffix) || host == pattern[2:]
}
return host == pattern
}
func RedactURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
query := parsed.Query()
for key := range query {
query.Set(key, "REDACTED")
}
parsed.RawQuery = query.Encode()
return parsed.String()
}
func sanitizeBearerToken(raw string) string {
token := strings.TrimSpace(raw)
if token == "" {
return ""
}
for _, r := range token {
if r < 0x20 || r == 0x7f {
return ""
}
}
return token
}
func httpStatusError(method, endpoint string, statusCode int, snapshotPath string) error {
message := fmt.Sprintf("request to %s returned HTTP %d", RedactURL(endpoint), statusCode)
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(fmt.Sprintf("http_%d", statusCode)),
apperrors.WithRetryable(retryable(statusCode)),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithCause(&CallError{
Stage: CallStageHTTP,
HTTPStatus: statusCode,
Cause: errors.New(message),
}),
}
switch {
case statusCode == http.StatusUnauthorized:
opts = append(opts,
apperrors.WithHint(i18n.T("认证失败;请检查登录状态或产品 URL 覆盖。")),
apperrors.WithActions(authActions(snapshotPath)...),
)
return apperrors.NewAuth(message, opts...)
case statusCode == http.StatusForbidden:
opts = append(opts,
apperrors.WithHint(i18n.T("权限不足;请检查当前身份是否有权限访问该服务或工具。")),
apperrors.WithActions(authActions(snapshotPath)...),
)
return apperrors.NewAuth(message, opts...)
case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError:
opts = append(opts,
apperrors.WithHint(i18n.T("请求被上游服务拒绝;请检查参数、认证和权限配置。")),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
if method != "tools/call" {
opts = append(opts, apperrors.WithActions(discoveryActions(snapshotPath)...))
return apperrors.NewDiscovery(message, opts...)
}
return apperrors.NewAPI(message, opts...)
default:
opts = append(opts,
apperrors.WithHint(i18n.T("上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。")),
apperrors.WithActions(actionsForMethod(method, snapshotPath)...),
)
if method == "tools/call" {
return apperrors.NewAPI(message, opts...)
}
return apperrors.NewDiscovery(message, opts...)
}
}
func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath string) error {
message := fmt.Sprintf("JSON-RPC %s failed with code %d: %s", method, rpcErr.Code, rpcErr.Message)
reason := reasonForMethod(method, "jsonrpc_"+jsonrpcCodeLabel(rpcErr.Code))
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(reason),
apperrors.WithRPCCode(rpcErr.Code),
apperrors.WithRPCData(rpcErr.Data),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithCause(&CallError{
Stage: CallStageJSONRPC,
RPCCode: rpcErr.Code,
Cause: errors.New(message),
}),
}
if rpcErr.Code == -32602 {
opts = append(opts,
apperrors.WithHint(i18n.T("参数不符合工具输入 schema;请检查 --json/--params/flags。")),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
return apperrors.NewValidation(message, opts...)
}
if looksAuthRPCError(rpcErr) {
opts = append(opts,
apperrors.WithHint(i18n.T("调用被拒绝;请检查认证状态、租户身份或访问权限。")),
apperrors.WithActions(authActions(snapshotPath)...),
)
return apperrors.NewAuth(message, opts...)
}
if method == "tools/call" {
if rpcErr.Code == -32600 || rpcErr.Code == -32601 {
opts = append(opts,
apperrors.WithHint(i18n.T("工具协议不兼容;请检查服务版本、工具名或刷新发现缓存。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
)
return apperrors.NewDiscovery(message, opts...)
}
opts = append(opts,
apperrors.WithHint(i18n.T("工具调用失败;请检查参数和上游服务状态。")),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
return apperrors.NewAPI(message, opts...)
}
opts = append(opts,
apperrors.WithHint(i18n.T("服务发现/协商失败;请检查网络、服务版本或执行缓存刷新。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
)
return apperrors.NewDiscovery(message, opts...)
}
func looksAuthRPCError(rpcErr *RPCError) bool {
if rpcErr == nil {
return false
}
if rpcErr.Code == http.StatusUnauthorized || rpcErr.Code == http.StatusForbidden {
return true
}
return looksAuthRelated(rpcErr.Message)
}
func jsonrpcCodeLabel(code int) string {
switch code {
case -32700:
return "parse_error"
case -32600:
return "invalid_request"
case -32601:
return "method_not_found"
case -32602:
return "invalid_params"
case -32603:
return "internal_error"
default:
if code < 0 {
return fmt.Sprintf("server_error_%d", -code)
}
return fmt.Sprintf("error_%d", code)
}
}
func reasonForMethod(method, suffix string) string {
replacer := strings.NewReplacer("/", "_", "-", "_", " ", "_")
method = replacer.Replace(strings.TrimSpace(method))
if method == "" {
method = "jsonrpc"
}
return method + "_" + suffix
}
func looksAuthRelated(message string) bool {
normalized := strings.ToLower(strings.TrimSpace(message))
for _, token := range []string{
"unauthorized",
"forbidden",
"permission",
"access denied",
"auth",
"token",
"credential",
"login",
} {
if strings.Contains(normalized, token) {
return true
}
}
return false
}
func actionsForMethod(method, snapshotPath string) []string {
if method == "tools/call" {
return runtimeActions(snapshotPath)
}
return discoveryActions(snapshotPath)
}
func authActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查登录状态后重试"),
}
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
func runtimeActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查认证、权限和参数后重试原命令"),
}
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
// validateCallArguments checks string values in tool call arguments for
// control characters and dangerous Unicode at the transport boundary.
func validateCallArguments(args map[string]any) error {
for key, value := range args {
switch typed := value.(type) {
case string:
if err := validate.RejectControlChars(typed, key); err != nil {
return err
}
case map[string]any:
if err := validateCallArguments(typed); err != nil {
return err
}
case []any:
for i, item := range typed {
if s, ok := item.(string); ok {
if err := validate.RejectControlChars(s, fmt.Sprintf("%s[%d]", key, i)); err != nil {
return err
}
}
if m, ok := item.(map[string]any); ok {
if err := validateCallArguments(m); err != nil {
return err
}
}
}
}
}
return nil
}
func discoveryActions(snapshotPath string) []string {
actions := []string{
"dws cache refresh",
i18n.T("检查服务连通性和协议版本后重试"),
}
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}