593 lines
19 KiB
Go
593 lines
19 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 errors
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
|
)
|
|
|
|
// 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 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/authUrl/authorizationUrl, that value is
|
|
// the authoritative server-provided authorization link. The CLI accepts all
|
|
// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a
|
|
// single data.uri field so terminals and hosts do not need to deduplicate links.
|
|
type PATError struct {
|
|
RawJSON string
|
|
}
|
|
|
|
func (e *PATError) Error() string { return e.RawJSON }
|
|
|
|
// ExitCode returns the documented exit code for PAT permission errors (4).
|
|
func (e *PATError) ExitCode() int { return ExitCodePermission }
|
|
|
|
// RawStderr returns the raw JSON to be written directly to stderr.
|
|
func (e *PATError) RawStderr() string { return e.RawJSON }
|
|
|
|
// patNoPermissionCodes are PAT error codes that should be passed through
|
|
// as transparent PATError without CLI-level wrapping. Some of these are
|
|
// grantable by user authorization; PAT_ORG_POLICY_DENIED is terminal until an
|
|
// org admin changes the policy, but it still belongs to the PAT permission
|
|
// channel so hosts see exit=4 and the raw PAT JSON contract.
|
|
var patNoPermissionCodes = map[string]bool{
|
|
"PAT_NO_PERMISSION": true,
|
|
"PAT_LOW_RISK_NO_PERMISSION": true,
|
|
"PAT_MEDIUM_RISK_NO_PERMISSION": true,
|
|
"PAT_HIGH_RISK_NO_PERMISSION": true,
|
|
"PAT_ORG_POLICY_DENIED": true,
|
|
}
|
|
|
|
// patAuthRequiredCodes are error codes that trigger the PAT authorization
|
|
// 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,
|
|
"PAT_BATCH_AUTH_PENDING": true,
|
|
"PAT_SCOPE_AUTH_REQUIRED": true,
|
|
}
|
|
|
|
// IsPATError reports whether err is a *PATError.
|
|
func IsPATError(err error) bool {
|
|
_, ok := err.(*PATError)
|
|
return ok
|
|
}
|
|
|
|
// IsPATNoPermissionCode reports whether code is a known PAT permission error code.
|
|
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.
|
|
var dwsGatewayErrors = map[string]bool{
|
|
"DWS_SERVICE_UNAUTHORIZED": true,
|
|
"DWS_AUTH_SERVICE_FAILED": true,
|
|
}
|
|
|
|
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
|
|
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
|
return lookupCodeIn(errBody, dwsGatewayErrors)
|
|
}
|
|
|
|
// isNotLoggedInError checks if the error body indicates missing authentication.
|
|
func isNotLoggedInError(body map[string]any) bool {
|
|
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
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isBusinessError checks if a parsed JSON body represents a business-level error.
|
|
func isBusinessError(body map[string]any) bool {
|
|
if _, ok := body["error"].(string); ok {
|
|
return true
|
|
}
|
|
if v, ok := body["success"].(bool); ok && !v {
|
|
return true
|
|
}
|
|
if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- Classification functions -----------------------------------------------
|
|
|
|
// ClassifyToolResultContent checks a raw MCP tool result content map for
|
|
// DWS gateway auth errors and PAT permission error codes. This is intended
|
|
// for use as the edition.Hooks.ClassifyToolResult callback so the framework's
|
|
// runner returns a typed error before its generic business-error classification.
|
|
//
|
|
// Check order: DWS gateway auth > PAT permission.
|
|
func ClassifyToolResultContent(content map[string]any) error {
|
|
if _, ok := getDWSGatewayErrorCode(content); ok {
|
|
raw, _ := jsonutil.Marshal(content)
|
|
return NewAuth(string(raw),
|
|
WithReason("gateway_auth_expired"),
|
|
WithHint(authExpiredHint()),
|
|
)
|
|
}
|
|
if code, ok := getPATErrorCode(content); ok {
|
|
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
|
|
// Returns a typed error for known gateway auth failures, PAT interceptions,
|
|
// and business-level errors embedded in HTTP-200 JSON bodies.
|
|
//
|
|
// Check order: DWS gateway > PAT permission > generic business error.
|
|
func ClassifyMCPResponseText(text string) error {
|
|
var body map[string]any
|
|
if json.Unmarshal([]byte(text), &body) != nil {
|
|
return nil
|
|
}
|
|
|
|
if _, ok := getDWSGatewayErrorCode(body); ok {
|
|
return NewAuth(text,
|
|
WithReason("gateway_auth_expired"),
|
|
WithHint(authExpiredHint()),
|
|
)
|
|
}
|
|
|
|
if isNotLoggedInError(body) {
|
|
return NewAuth("当前未登录",
|
|
WithReason("not_configured"),
|
|
WithHint(notLoggedInHint()),
|
|
WithActions("dws auth login"),
|
|
)
|
|
}
|
|
|
|
if code, ok := getPATErrorCode(body); ok {
|
|
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
|
}
|
|
|
|
if isBusinessError(body) {
|
|
return NewAPI(text,
|
|
WithReason("business_error"),
|
|
WithHint(suggestForBusinessErrorText(body)),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---- Hints -----------------------------------------------------------------
|
|
|
|
func authExpiredHint() string {
|
|
return "Re-authenticate: dws auth login"
|
|
}
|
|
|
|
func notLoggedInHint() string {
|
|
return "请先登录:dws auth login"
|
|
}
|
|
|
|
func suggestForBusinessErrorText(body map[string]any) string {
|
|
msg := ""
|
|
if v, ok := body["errorMsg"].(string); ok {
|
|
msg = v
|
|
} else if v, ok := body["message"].(string); ok {
|
|
msg = v
|
|
} else if v, ok := body["error"].(string); ok {
|
|
msg = v
|
|
}
|
|
switch {
|
|
case strings.Contains(msg, "搜索内容不能为空"):
|
|
return "请提供非空搜索关键词: dws doc search --query \"关键词\""
|
|
case strings.Contains(msg, "User has no permission to access this email"):
|
|
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
|
|
case strings.Contains(msg, "频率超限") || strings.Contains(msg, "rate limit"):
|
|
return "API rate limit exceeded, wait a moment and retry"
|
|
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
|
|
return "Check input parameters. Use --help for available flags"
|
|
default:
|
|
return "MCP tool returned a business error; check parameters and refer to skill documentation."
|
|
}
|
|
}
|
|
|
|
// ---- PAT JSON helpers ------------------------------------------------------
|
|
|
|
var patTopLevelStrip = map[string]bool{
|
|
"success": true, "code": true, "errorCode": true, "error_code": true,
|
|
"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 := ensurePATData(out)
|
|
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
|
authURL := PATAuthorizationURL(rawURI)
|
|
data["uri"] = authURL
|
|
delete(data, "authUrl")
|
|
delete(data, "authorizationUrl")
|
|
}
|
|
if block := HostControlBlock(); block != nil {
|
|
delete(data, "callbacks")
|
|
data["hostControl"] = block
|
|
}
|
|
data["openBrowser"] = PATOpenBrowserValue()
|
|
}
|
|
|
|
func ensurePATData(out map[string]any) map[string]any {
|
|
data, ok := out["data"].(map[string]any)
|
|
if !ok || data == nil {
|
|
data = map[string]any{}
|
|
out["data"] = data
|
|
}
|
|
return data
|
|
}
|
|
|
|
func patAuthorizationURIFromData(data map[string]any) string {
|
|
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
|
value, _ := data[key].(string)
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
|
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
|
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
|
// hash query and decoded fragment route used by the working authorization page.
|
|
func PATAuthorizationURL(rawURI string) string {
|
|
rawURI = strings.TrimSpace(rawURI)
|
|
if rawURI == "" {
|
|
return ""
|
|
}
|
|
|
|
parsed, err := url.Parse(rawURI)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return rawURI
|
|
}
|
|
if !strings.HasSuffix(parsed.Path, "/fe/old") {
|
|
return rawURI
|
|
}
|
|
if parsed.Query().Get("hash") != "" && strings.Contains(parsed.Fragment, "personalAuthorization") {
|
|
return rawURI
|
|
}
|
|
|
|
routeQuery := patAuthorizationRouteQuery(parsed)
|
|
if routeQuery.Get("flowId") == "" || routeQuery.Get("userCode") == "" {
|
|
return rawURI
|
|
}
|
|
|
|
route := "/personalAuthorization?" + routeQuery.Encode()
|
|
|
|
next := *parsed
|
|
query := next.Query()
|
|
query.Set("hash", "#"+route)
|
|
next.RawQuery = query.Encode()
|
|
next.Fragment = route
|
|
next.RawFragment = ""
|
|
return next.String()
|
|
}
|
|
|
|
func patAuthorizationRouteQuery(parsed *url.URL) url.Values {
|
|
candidates := []string{
|
|
parsed.Fragment,
|
|
parsed.RawFragment,
|
|
parsed.Query().Get("hash"),
|
|
}
|
|
for _, candidate := range candidates {
|
|
if values := parsePersonalAuthorizationRouteQuery(candidate); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
|
return values
|
|
}
|
|
if decoded, err := url.QueryUnescape(candidate); err == nil && decoded != candidate {
|
|
if values := parsePersonalAuthorizationRouteQuery(decoded); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
|
return values
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parsePersonalAuthorizationRouteQuery(route string) url.Values {
|
|
route = strings.TrimSpace(route)
|
|
route = strings.TrimPrefix(route, "#")
|
|
idx := strings.Index(route, "personalAuthorization?")
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
rawQuery := route[idx+len("personalAuthorization?"):]
|
|
if cut := strings.IndexAny(rawQuery, "?#"); cut >= 0 {
|
|
rawQuery = rawQuery[:cut]
|
|
}
|
|
values, err := url.ParseQuery(rawQuery)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return values
|
|
}
|
|
|
|
func cleanPATJSON(body map[string]any, code string) string {
|
|
out := map[string]any{
|
|
"success": false,
|
|
"code": code,
|
|
}
|
|
if data, ok := body["data"]; ok {
|
|
// ApplyHostMutations canonicalizes PAT URL aliases into one data.uri
|
|
// before JSON encoding, while stripClassFields keeps the rest of the
|
|
// service payload intact.
|
|
out["data"] = stripClassFields(data)
|
|
} else {
|
|
fallback := map[string]any{}
|
|
for k, v := range body {
|
|
if !patTopLevelStrip[k] {
|
|
fallback[k] = v
|
|
}
|
|
}
|
|
if len(fallback) > 0 {
|
|
out["data"] = stripClassFields(fallback)
|
|
}
|
|
}
|
|
ApplyHostMutations(out)
|
|
if code == "PAT_ORG_POLICY_DENIED" {
|
|
applyOrgPolicyDeniedHint(out, body)
|
|
}
|
|
|
|
// 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 := marshalSingleLineJSONNoHTMLEscape(out)
|
|
if err != nil {
|
|
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func applyOrgPolicyDeniedHint(out map[string]any, body map[string]any) {
|
|
data := ensurePATData(out)
|
|
if stringValue(data, "policy") == "" {
|
|
data["policy"] = "OPEN_SOURCE_ORG_SCOPE_FORBIDDEN"
|
|
}
|
|
if stringValue(data, "message") == "" {
|
|
if msg := stringValue(body, "message", "errorMsg", "error"); msg != "" {
|
|
data["message"] = msg
|
|
}
|
|
}
|
|
if stringValue(data, "hint") == "" {
|
|
if desc := stringValue(data, "policyDesc", "message"); desc != "" {
|
|
data["hint"] = "组织策略已禁止当前工具所需的开源数据权限:" + desc +
|
|
"。请联系组织管理员在 DWS/PAT 权限管控中放开对应 scope 后重试。"
|
|
} else {
|
|
data["hint"] = "组织策略已禁止当前工具所需的开源数据权限,请联系组织管理员在 DWS/PAT 权限管控中放开对应 scope 后重试。"
|
|
}
|
|
}
|
|
data["action"] = "contact_org_admin"
|
|
data["openBrowser"] = false
|
|
data["retryable"] = false
|
|
}
|
|
|
|
func stringValue(body map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
value, ok := body[key].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
out := buf.Bytes()
|
|
if len(out) > 0 && out[len(out)-1] == '\n' {
|
|
out = out[:len(out)-1]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- Runner adapter functions ------------------------------------------------
|
|
// These match the function signatures referenced by runner.go's PAT check
|
|
// 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
|
|
// non-nil *PATError when the content carries a recognised PAT/auth error.
|
|
func ClassifyPatAuthCheck(content map[string]any) *PATError {
|
|
if code, ok := getPATErrorCode(content); ok {
|
|
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AsPatAuthCheckError extracts a *PATError from an error chain.
|
|
func AsPatAuthCheckError(err error) *PATError {
|
|
var patErr *PATError
|
|
if stderrors.As(err, &patErr) {
|
|
return patErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stripClassFields(v any) any {
|
|
switch val := v.(type) {
|
|
case map[string]any:
|
|
clean := make(map[string]any, len(val))
|
|
for k, item := range val {
|
|
if k == "class" {
|
|
continue
|
|
}
|
|
clean[k] = stripClassFields(item)
|
|
}
|
|
return clean
|
|
case []any:
|
|
clean := make([]any, len(val))
|
|
for i, item := range val {
|
|
clean[i] = stripClassFields(item)
|
|
}
|
|
return clean
|
|
default:
|
|
return v
|
|
}
|
|
}
|