// 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 ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" 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/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) func init() { runnerHandlePatAuthCheck = handlePatAuthCheck runnerRetryWithPatAuthRetry = retryWithPatAuthRetry configmeta.Register(configmeta.ConfigItem{ Name: "DWS_RUNTIME_CONTENT_SCAN", Category: configmeta.CategoryRuntime, Description: "启用 MCP 响应内容安全扫描", Example: "true", }) configmeta.Register(configmeta.ConfigItem{ Name: "DWS_RUNTIME_CONTENT_SCAN_ENFORCE", Category: configmeta.CategoryRuntime, Description: "内容安全扫描发现问题时阻断响应", Example: "true", }) configmeta.Register(configmeta.ConfigItem{ Name: "DWS_RUNTIME_CONTENT_SCAN_REPORT", Category: configmeta.CategoryRuntime, Description: "在 JSON 输出中包含安全扫描报告", Example: "true", }) configmeta.Register(configmeta.ConfigItem{ Name: "DINGTALK_AGENT", Category: configmeta.CategoryExternal, Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关", }) configmeta.Register(configmeta.ConfigItem{ Name: "DINGTALK_TRACE_ID", Category: configmeta.CategoryExternal, Description: "MCP 请求 x-dingtalk-trace-id 头", }) configmeta.Register(configmeta.ConfigItem{ Name: "DINGTALK_SESSION_ID", Category: configmeta.CategoryExternal, Description: "MCP 请求 x-dingtalk-session-id 头", }) configmeta.Register(configmeta.ConfigItem{ Name: "DINGTALK_MESSAGE_ID", Category: configmeta.CategoryExternal, Description: "MCP 请求 x-dingtalk-message-id 头", }) } const ( runtimeContentScanEnv = "DWS_RUNTIME_CONTENT_SCAN" runtimeContentScanEnforceEnv = "DWS_RUNTIME_CONTENT_SCAN_ENFORCE" runtimeContentScanReportOutputEnv = "DWS_RUNTIME_CONTENT_SCAN_REPORT" // Environment variables for MCP request headers (passed from caller) envDingtalkAgent = "DINGTALK_AGENT" envDingtalkTraceID = "DINGTALK_TRACE_ID" envDingtalkSessionID = "DINGTALK_SESSION_ID" envDingtalkMessageID = "DINGTALK_MESSAGE_ID" envDWSSessionID = "DWS_SESSION_ID" envRewindSessionID = "REWIND_SESSION_ID" // Environment variables for third-party channel integration 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", authpkg.AgentCodeEnvPresent(), ) }) } func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner { // Ensure DWS_CLIENT_ID env is populated from persisted config before // resolveIdentityHeaders reads it. This covers fresh-process cold starts // where no env var has been inherited from a parent process. if os.Getenv("DWS_CLIENT_ID") == "" { if cid := authpkg.ClientID(); cid != "" { _ = os.Setenv("DWS_CLIENT_ID", cid) } } var httpClient *http.Client if flags != nil && flags.Timeout > 0 { httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second} } transportClient := transport.NewClient(httpClient) transportClient.FileLogger = FileLoggerInstance() return &runtimeRunner{ transport: transportClient, globalFlags: flags, scanner: newRuntimeContentScanner(), enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false), includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false), } } type runtimeRunner struct { transport *transport.Client globalFlags *GlobalFlags fallback executor.Runner scanner safety.Scanner enforceContentScan bool includeScanReport bool auditSink audit.Sink agentMetadata *agentMetadataSnapshot } var ( runnerResolveMultiProfileSelections = resolveMultiProfileSelections runnerResolveProfile = authpkg.ResolveProfile runnerGetCachedRuntimeToken = getCachedRuntimeToken runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerCallTool = (*transport.Client).CallTool runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized runnerStdioListTools = (*transport.StdioClient).ListTools runnerStdioCallTool = (*transport.StdioClient).CallTool runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) runnerCaptureRuntimeFailure = captureRuntimeFailure ) func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { // Global dry-run is an execution barrier, not merely a transport option. // Return a deterministic local preview before profile resolution, catalog // discovery, Keychain/token prefetch, auth, stateful preflight or transport. // Use the non-injectable EchoRunner rather than r.fallback so tests and // edition overlays cannot accidentally turn this path into real execution. if invocation.DryRun || (r != nil && r.globalFlags != nil && r.globalFlags.DryRun) { invocation.DryRun = true return (executor.EchoRunner{}).Run(ctx, invocation) } if r == nil { return executor.Result{}, fmt.Errorf("runtime runner is not configured") } // 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() rawProfile := authpkg.RuntimeProfile() selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), rawProfile) if err != nil { return executor.Result{}, apperrors.NewValidation(err.Error()) } if multi { return r.runMultiProfile(ctx, invocation, selections) } if strings.TrimSpace(rawProfile) != "" { profile, err := runnerResolveProfile(defaultConfigDir(), rawProfile) if err != nil { return executor.Result{}, apperrors.NewValidation(err.Error()) } if profile == nil { return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile)) } resolvedSelector := profileRuntimeSelector(*profile, rawProfile) authpkg.SetRuntimeProfile(resolvedSelector) defer authpkg.SetRuntimeProfile(rawProfile) } return r.runSingle(ctx, invocation, true) } // RunReadOnly executes one already-classified read lookup for a semantic // Shortcut that is building a dry-run plan. It clones the runtime flags and // clears DryRun only on that clone: the process-wide caller and every ordinary // ToolCaller invocation retain the global execution barrier. func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { if r == nil { return executor.Result{}, fmt.Errorf("runtime runner is not configured") } clone := *r if r.globalFlags != nil { flags := *r.globalFlags flags.DryRun = false clone.globalFlags = &flags } invocation.DryRun = false return clone.Run(ctx, invocation) } func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) { if r.transport == nil { return r.fallback.Run(ctx, invocation) } // Mock mode: skip endpoint resolution, use a placeholder endpoint. if r.globalFlags != nil && r.globalFlags.Mock { endpoint := fmt.Sprintf("https://mock-mcp-%s.dingtalk.com", invocation.CanonicalProduct) if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok { endpoint = override } return r.executeInvocation(ctx, endpoint, invocation) } // Prefetch the Keychain token in the background. Keychain access costs // ~70ms on macOS; starting it here lets the load overlap with endpoint // resolution below. if prefetchToken { go func() { _, _ = runnerGetCachedRuntimeToken(ctx) }() } if shouldUseDirectRuntime(invocation) { if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok { return r.executeInvocation(ctx, endpoint, invocation) } } // Discovery is retired: endpoint resolution is the dynamic server // registry only, so a direct-runtime miss is terminal. return r.handleCatalogMiss(ctx, invocation, "no dynamic endpoint registered for product or tool") } // profileRuntimeSelector resolves the runtime profile selector for an // invocation. It preserves a unique local-name selector for an unresolved // account (empty UserID): reducing it to corpId can select a different exact // account through the organization's current-account pointer. Shared by the // single-profile path in Run and the multi-profile path in runMultiProfile. func profileRuntimeSelector(profile authpkg.Profile, rawSelector string) string { if strings.TrimSpace(profile.UserID) == "" { return rawSelector } return authpkg.ProfileSelector(profile) } type multiProfileSelection struct { Selector string Profile authpkg.Profile } func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) { rawSelector = strings.TrimSpace(rawSelector) if rawSelector == "" || !strings.Contains(rawSelector, ",") { return nil, false, nil } if p, err := runnerResolveProfile(configDir, rawSelector); err == nil && p != nil { return nil, false, nil } parts := strings.Split(rawSelector, ",") selections := make([]multiProfileSelection, 0, len(parts)) seen := make(map[string]bool, len(parts)) for _, part := range parts { selector := strings.TrimSpace(part) if selector == "" { return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector) } profile, err := runnerResolveProfile(configDir, selector) if err != nil { return nil, false, err } if profile == nil { return nil, false, fmt.Errorf("profile %q not found", selector) } identitySelector := authpkg.ProfileSelector(*profile) if seen[identitySelector] { continue } seen[identitySelector] = true selections = append(selections, multiProfileSelection{ Selector: selector, Profile: *profile, }) } return selections, true, nil } func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) { previousProfile := authpkg.RuntimeProfile() defer authpkg.SetRuntimeProfile(previousProfile) entries := make([]any, 0, len(selections)) succeeded := 0 failed := 0 for _, selection := range selections { resolvedSelector := profileRuntimeSelector(selection.Profile, selection.Selector) authpkg.SetRuntimeProfile(resolvedSelector) result, err := r.runSingle(ctx, cloneInvocation(invocation), false) entry := map[string]any{ "selector": selection.Selector, "profile": resolvedSelector, "corpId": selection.Profile.CorpID, "corpName": selection.Profile.CorpName, "userId": selection.Profile.UserID, "userName": selection.Profile.UserName, "ok": err == nil, } if err != nil { failed++ entry["error"] = multiProfileErrorPayload(err) } else { succeeded++ if payload := multiProfileResultPayload(result); payload != nil { entry["result"] = payload } if result.Response != nil { if endpoint, ok := result.Response["endpoint"]; ok { entry["endpoint"] = endpoint } } } entries = append(entries, entry) } invocation.Implemented = true return executor.Result{ Invocation: invocation, Response: map[string]any{ "content": map[string]any{ "success": failed == 0, "multiProfile": true, "summary": map[string]any{ "total": len(selections), "succeeded": succeeded, "failed": failed, }, "profiles": entries, }, }, }, nil } func cloneInvocation(invocation executor.Invocation) executor.Invocation { cloned := invocation if invocation.Params != nil { cloned.Params = make(map[string]any, len(invocation.Params)) for key, value := range invocation.Params { cloned.Params[key] = value } } return cloned } func multiProfileResultPayload(result executor.Result) any { if result.Response == nil { return nil } if content, ok := result.Response["content"]; ok { return content } return result.Response } func multiProfileErrorPayload(err error) map[string]any { payload := map[string]any{ "message": err.Error(), } var typed *apperrors.Error if errors.As(err, &typed) { payload["category"] = string(typed.Category) if typed.Reason != "" { payload["reason"] = typed.Reason } if typed.Operation != "" { payload["operation"] = typed.Operation } if typed.Origin != "" { payload["origin"] = typed.Origin } if typed.FailureStage != "" { payload["stage"] = typed.FailureStage } if typed.ExecutionStarted != nil { payload["execution_started"] = *typed.ExecutionStarted } if typed.RetryableSet { payload["retryable"] = typed.Retryable } if typed.Hint != "" { payload["hint"] = typed.Hint } if len(typed.Actions) > 0 { payload["actions"] = append([]string(nil), typed.Actions...) } if len(typed.Details) > 0 { payload["details"] = typed.Details } if typed.ServerDiag.TraceID != "" { payload["trace_id"] = typed.ServerDiag.TraceID } if typed.ServerDiag.ServerErrorCode != "" { payload["server_error_code"] = typed.ServerDiag.ServerErrorCode } if code := typed.ExitCode(); code != 0 { payload["exitCode"] = code } } return payload } // handleCatalogMiss decides what to do when the dynamic server registry has // no endpoint for the requested product / tool and no `directRuntimeEndpoint` // match fired earlier. The discovery catalog is retired; endpoint resolution // is the dynamic server registry only, so a registry miss here is terminal. // // Previously every miss silently fell through to EchoRunner, which // returns an empty `executor.Result{Response: nil}`. The helper-invocation // adapter then converted that into `&edition.ToolResult{}`, whose `Content` // marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no // signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace. // // Contract: return an explicit apperrors.NewAPI("endpoint_not_resolved") // with the offending product/tool attached. This fails fast to stderr and // makes missing envelopes / supplement gaps immediately visible. Dry-run // invocations never reach this path in production: Run enforces the dry-run // barrier before endpoint resolution, and runSingle is only re-entered via // Run (multi-profile and PAT retry both route back through it). func (r *runtimeRunner) handleCatalogMiss(_ context.Context, invocation executor.Invocation, detail string) (executor.Result, error) { return executor.Result{}, endpointNotResolvedError(invocation.CanonicalProduct, invocation.Tool, detail) } // endpointNotResolvedError builds the shared terminal error for a dynamic // server registry miss. Both the runtime runner (handleCatalogMiss) and the // recovery runtime (resolveEndpoint) use it so endpoint misses classify // identically: CategoryAPI, operation "discovery.resolve", reason // "endpoint_not_resolved", with the product ID as the server key. func endpointNotResolvedError(productID, toolName, detail string) error { hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。" actions := []string{ "确认 internal/syncdata.StaticServers() 是否包含该 product/server", "运行 sync-oss 重新生成静态端点与路由", "若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令", } if strings.TrimSpace(productID) == devappProductID { hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。" actions = []string{ "检查 StaticServers/SupplementServers 是否包含 devapp endpoint", "本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试", } } return apperrors.NewAPI( fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", productID, toolName, detail), apperrors.WithOperation("discovery.resolve"), apperrors.WithReason("endpoint_not_resolved"), apperrors.WithServerKey(productID), apperrors.WithHint(hint), apperrors.WithActions(actions...), ) } func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) { // Route stdio:// endpoints to the local StdioClient — no HTTP, no auth. if IsStdioEndpoint(endpoint) { return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation) } // Constructing the Cobra tree is also used for help, schema, and command // discovery. Open the process-wide audit writer only when a real invocation // reaches the execution boundary so read-only command inspection does not // leave an audit lock handle behind (which prevents TempDir cleanup on // Windows). Keep an injected sink when tests or editions provide one. auditSink := r.auditSink if auditSink == nil { auditSink = setupAuditSink() } invokeStart := time.Now() execID := generateExecutionID() r.transport.ExecutionId = execID // Lazy bind FileLogger: it may be nil at construction time because // configureLogLevel runs later in PersistentPreRunE. if r.transport.FileLogger == nil { r.transport.FileLogger = FileLoggerInstance() } fl := r.transport.FileLogger defer func() { var errCat, errReason string if retErr != nil { var typed *apperrors.Error if errors.As(retErr, &typed) { errCat = string(typed.Category) errReason = typed.Reason } else { errCat = "unknown" errReason = retErr.Error() } } logging.LogCommandEnd(fl, execID, invocation.CanonicalProduct, invocation.Tool, retErr == nil, time.Since(invokeStart), errCat, errReason) emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version) }() // Check whether this product belongs to an HTTP plugin. Every accepted // plugin has an ownership record; credentials within that record are // optional. Plugin requests never fall back to the default DingTalk OAuth. pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct) authToken := "" if hasPluginAuth { authToken = pluginAuth.Token } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx) if tokenErr != nil { return executor.Result{}, tokenResolutionError(tokenErr) } authToken = snapshot.AccessToken if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) && isDingTalkMCPGatewayEndpoint(endpoint) && (snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") { endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion) } } var timeoutSec int if r.globalFlags != nil { timeoutSec = r.globalFlags.Timeout } logging.LogCommandStart(fl, execID, invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec) if invocation.DryRun { // Emit a wukong-aligned human-readable preview on stderr so the dry-run // surface advertises the resolved MCP arguments without polluting the // stdout payload (which stays valid JSON in --format json mode). Mirrors // wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the // machine-readable channel. if argsJSON, err := json.Marshal(invocation.Params); err == nil { fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON) } return executor.Result{ Invocation: invocation, Response: map[string]any{ "dry_run": true, "endpoint": transport.RedactURL(endpoint), "request": executor.ToolCallRequest(invocation.Tool, invocation.Params), "note": "execution skipped by --dry-run", }, }, nil } // Mock mode: return predefined mock response without network call. if r.globalFlags != nil && r.globalFlags.Mock { invocation.Implemented = true return executor.Result{ Invocation: invocation, Response: map[string]any{ "endpoint": transport.RedactURL(endpoint), "content": map[string]any{ "success": true, "result": []any{}, "_mock": true, "_tool": invocation.Tool, }, }, }, nil } // Preserve a final execution-boundary guard even though the built-in token // resolver normally returns either a non-empty token or an error. HTTP // plugins are ownership-scoped separately and may intentionally be anonymous. if !hasPluginAuth && strings.TrimSpace(authToken) == "" { return executor.Result{}, apperrors.NewAuth( "未登录,请先执行 dws auth login", apperrors.WithReason("not_authenticated"), apperrors.WithHint("运行 'dws auth login' 完成登录后重试"), apperrors.WithActions("dws auth login"), ) } var tc *transport.Client if hasPluginAuth { // Plugin ownership is authoritative even when the plugin is anonymous. // Copy and sanitize manifest headers so plugins cannot opt themselves into // DWS-owned Agent metadata by declaring the reserved names directly. tc = r.transport.WithAuth(authToken, pluginRequestHeaders(pluginAuth)) tc.TrustedDomains = pluginAuth.TrustedDomains } else { // Default path: use DingTalk OAuth token with identity headers. Agent // metadata is resolved exactly once per invocation and is excluded from // helper-only service-discovery requests. if r.agentMetadata != nil { tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation, *r.agentMetadata)) } else { tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation)) } } callCtx := ctx if r.globalFlags != nil && r.globalFlags.Timeout > 0 { var cancel context.CancelFunc callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second) defer cancel() } if err := runnerPreflightDocDownload(r, callCtx, tc, endpoint, invocation); err != nil { if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil { if IsPatRetrying(ctx) { return executor.Result{}, patCheck } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled { if retryErr != nil { runnerCaptureRuntimeFailure(invocation, err, retryErr) } return result, retryErr } runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } callStart := time.Now() callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params) RecordTiming(ctx, "mcp_call", time.Since(callStart)) if err != nil { if isRefreshableTransportAuthError(err) { if fn := edition.Get().OnAuthError; fn != nil { if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil { if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled { if retryErr != nil { runnerCaptureRuntimeFailure(invocation, err, retryErr) } return result, retryErr } runnerCaptureRuntimeFailure(invocation, err, overrideErr) return executor.Result{}, overrideErr } } } // PAT scope error: offer human-readable output and retry after authorization if isPatScopeError(err) { scopeErr := extractPatScopeError(err) runnerCaptureRuntimeFailure(invocation, err, err) return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) } runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } // ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ---- if fn := edition.Get().ClassifyToolResult; fn != nil { if editionErr := fn(callResult.Content); editionErr != nil { if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil { if IsPatRetrying(ctx) { return executor.Result{}, patCheck // already retried once, don't loop } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled { if retryErr != nil { runnerCaptureRuntimeFailure(invocation, editionErr, retryErr) } return result, retryErr } return executor.Result{}, editionErr } } // ---- Structured PAT auth check (open-source fallback) ---- if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil { if IsPatRetrying(ctx) { return executor.Result{}, patCheck // already retried once, don't loop } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } if callResult.IsError { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) // ClassifyToolResult hook: let the overlay intercept known error // patterns (PAT permission, gateway-auth) before generic handling. if classify := edition.Get().ClassifyToolResult; classify != nil { if hookErr := classify(callResult.Content); hookErr != nil { if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled { if retryErr != nil { runnerCaptureRuntimeFailure(invocation, hookErr, retryErr) } return result, retryErr } runnerCaptureRuntimeFailure(invocation, hookErr, hookErr) return executor.Result{}, hookErr } } mcpErr := newServerFailureAPIError( extractMCPErrorMessage(callResult), "mcp_tool_error", "MCP tool returned a business error; check tool parameters and refer to skill documentation.", invocation.CanonicalProduct, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag) // PAT scope error in business response: offer human-readable output and retry if isPatScopeError(mcpErr) { scopeErr := extractPatScopeError(mcpErr) runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr) return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) } runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr) return executor.Result{}, mcpErr } scanReport, err := r.scanContent(callResult.Content) if err != nil { return executor.Result{}, err } if bizErr := detectBusinessError(callResult.Content); bizErr != "" { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) classifiedErr := newServerFailureAPIError( bizErr, "business_error", "The API returned a business-level error. Check required parameters and values.", invocation.CanonicalProduct, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag) return executor.Result{}, classifiedErr } invocation.Implemented = true // Align with wukong's response envelope: stamp a top-level success=true on // map payloads that don't already carry a success flag. Business errors // (success=false) are intercepted above, so reaching here means the call // succeeded. Additive only — existing keys are never overwritten. if callResult.Content != nil { if _, has := callResult.Content["success"]; !has { callResult.Content["success"] = true } } response := map[string]any{ "endpoint": transport.RedactURL(endpoint), "content": callResult.Content, } if r.includeScanReport && scanReport.Scanned { response["safety"] = scanReport } return executor.Result{Invocation: invocation, Response: response}, nil } func (r *runtimeRunner) executeStdioInvocationAtEndpoint( ctx context.Context, endpoint string, invocation executor.Invocation, ) (executor.Result, error) { if invocation.DryRun { return executor.Result{ Invocation: invocation, Response: map[string]any{ "dry_run": true, "transport": "stdio", "request": executor.ToolCallRequest(invocation.Tool, invocation.Params), "note": "execution skipped by --dry-run", }, }, nil } lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/") if lookupKey == "" { lookupKey = invocation.CanonicalProduct } client, ok := LookupStdioClient(lookupKey) if !ok { return executor.Result{}, apperrors.NewInternal( fmt.Sprintf("stdio client not found for %q", lookupKey)) } callCtx := ctx if r.globalFlags != nil && r.globalFlags.Timeout > 0 { var cancel context.CancelFunc callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second) defer cancel() } if err := runnerStdioEnsureInitialized(client, callCtx); err != nil { return executor.Result{}, apperrors.NewAPI( fmt.Sprintf("stdio initialize failed: %v", err), apperrors.WithOperation("initialize"), apperrors.WithReason("stdio_initialize_error"), ) } tools, err := runnerStdioListTools(client, callCtx) if err != nil { return executor.Result{}, apperrors.NewAPI( fmt.Sprintf("stdio tools/list failed: %v", err), apperrors.WithOperation("tools/list"), apperrors.WithReason("stdio_tools_list_error"), ) } schema, ok := pluginToolInputSchema(tools, invocation.Tool) if !ok { return executor.Result{}, apperrors.NewValidation( fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool), apperrors.WithReason("plugin_tool_not_found"), ) } normalizedParams, err := normalizePluginInputParams(invocation.Params, schema) if err != nil { return executor.Result{}, err } invocation.Params = normalizedParams callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params) if err != nil { return executor.Result{}, apperrors.NewAPI( fmt.Sprintf("stdio call failed: %v", err), apperrors.WithOperation("tools/call"), apperrors.WithReason("stdio_error"), ) } if callResult.IsError { return executor.Result{}, apperrors.NewAPI( extractMCPErrorMessage(callResult), apperrors.WithOperation("tools/call"), apperrors.WithReason("mcp_tool_error"), apperrors.WithServerKey(invocation.CanonicalProduct), ) } invocation.Implemented = true return executor.Result{ Invocation: invocation, Response: map[string]any{ "transport": "stdio", "content": callResult.Content, }, }, nil } func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { snapshot, err := r.resolveAuthSnapshot(ctx) if err != nil { return "", err } return snapshot.AccessToken, nil } func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) { explicitToken := "" if r != nil && r.globalFlags != nil { explicitToken = r.globalFlags.Token } return resolveRuntimeAuthSnapshot(ctx, explicitToken) } func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken) if err != nil { return "", err } return snapshot.AccessToken, nil } func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) { return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) } // getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The // cache itself lives exclusively in TokenManager. func getCachedRuntimeToken(ctx context.Context) (string, error) { loadStart := time.Now() defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }() return resolveRuntimeAuthToken(ctx, "") } func tokenResolutionError(err error) error { if err == nil { return nil } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return err } if errors.Is(err, authpkg.ErrTokenDataNotFound) { return apperrors.NewAuth( "未登录,请先执行 dws auth login", apperrors.WithReason("not_authenticated"), apperrors.WithHint("运行 'dws auth login' 完成登录后重试"), apperrors.WithActions("dws auth login"), apperrors.WithCause(err), ) } // Keychain, parse, permission, lock, and refresh failures are real local or // network errors. Preserve their cause instead of disguising them as logout. return fmt.Errorf("resolve access token: %w", err) } // generateExecutionID returns a random 16-char hex string used to correlate // all log entries (command_start, jsonrpc_request, command_end, etc.) belonging // to a single command invocation. func generateExecutionID() string { b := make([]byte, 8) _, _ = rand.Read(b) return hex.EncodeToString(b) } // ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. // This should be called after login/logout operations. func ResetRuntimeTokenCache() { runtimeTokenManager.Invalidate() } func newRuntimeContentScanner() safety.Scanner { if !runtimeFlagEnabled(os.Getenv(runtimeContentScanEnv), true) { return nil } return safety.NewContentScanner() } func (r *runtimeRunner) scanContent(content map[string]any) (safety.Report, error) { if r == nil || r.scanner == nil { return safety.Report{Scanned: false}, nil } report := r.scanner.ScanPayload(content) if r.enforceContentScan && len(report.Findings) > 0 { return report, apperrors.NewValidation("runtime response blocked by content safety scan") } return report, nil } func runtimeFlagEnabled(raw string, defaultValue bool) bool { trimmed := strings.ToLower(strings.TrimSpace(raw)) if trimmed == "" { return defaultValue } switch trimmed { case "0", "false", "no", "n", "off": return false default: return true } } func isAuthError(err error) bool { var appErr *apperrors.Error if errors.As(err, &appErr) { return appErr.Category == apperrors.CategoryAuth } return false } func productEndpointOverride(productID string) (string, bool) { key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL" value := strings.TrimSpace(os.Getenv(key)) if value == "" { return "", false } return value, true } // resolveIdentityHeaders loads or creates agent identity and returns HTTP // headers to inject into MCP requests. Best-effort: returns nil on failure. func resolveIdentityHeaders() map[string]string { id := authpkg.EnsureExists(defaultConfigDir()) headers := id.Headers() // 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 the edition-fixed // claw-type or the host-owned PAT decision (driven solely by // DINGTALK_DWS_AGENTCODE). sessionID := os.Getenv(envDingtalkSessionID) if sessionID == "" { sessionID = os.Getenv(envDWSSessionID) } if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } // Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the // per-(machine × agent_code) instance id when a code is known. Synthetic // fallbacks must not be sent because PAT authorization checks use the same // header as their grant key. // // Backward-compat by design (additive, not breaking): // - x-dws-agent-id keeps its v1 meaning = machine-level install UUID // (set by id.Headers() above), so old/new clients stay comparable. // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id, // sent only when x-dingtalk-dws-agent-code is non-empty. // Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched. agentCode, agentCodeSig := authpkg.DetectAgentCode() if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" { headers["x-dws-agent-instance-id"] = agentInstanceID } // Emit the CLI version on the wire so the gateway can segment old vs new // clients (and scope agent_code coverage / adoption). The header constant // existed but was never set; wire it here. if version != "" { headers[transport.HeaderVersion] = version } envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), "x-dingtalk-dws-agent-code": agentCode, "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), "x-dingtalk-session-id": sessionID, "x-dingtalk-message-id": os.Getenv(envDingtalkMessageID), } for k, v := range envHeaders { if v != "" { headers[k] = v } } // Inject third-party channel headers. DWS_CHANNEL is forwarded as the // upstream channelCode. if v := os.Getenv(envDWSChannel); v != "" { headers["x-dws-channel"] = v } // DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command // execution validates it strictly in PersistentPreRunE. Library callers // that bypass the root command keep this best-effort API contract: invalid // values are omitted rather than changing the public function signature. if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" { headers[headerDWSAgentHost] = agentHost } if fn := edition.Get().MergeHeaders; fn != nil { headers = fn(headers) } if headers == nil { headers = make(map[string]string) } // claw-type is the edition-fixed routing/PAT identity. Agent Product is a // separate caller-declared observability and IM-display dimension. clawType := resolveEditionClawType(headers) headers["claw-type"] = clawType headers = applyAgentProductHeader(headers) agentProduct, hasAgentProduct := headers[agentproduct.HeaderName] if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil { headers = fn(headers) } if headers == nil { headers = make(map[string]string) } // Credential hooks cannot alter either identity dimension. Restore the // fixed claw-type and the validated Product Header (or its absence). headers["claw-type"] = clawType if hasAgentProduct { headers[agentproduct.HeaderName] = agentProduct } else { delete(headers, agentproduct.HeaderName) } // Agent version and extension are intentionally MCP-request-only. Remove // every case variant potentially supplied by an edition or credential hook // so shared consumers such as A2A cannot inherit them. removeAgentMetadataHeaders(headers) return headers } // detectBusinessError checks the MCP response content for DingTalk business // errors (success=false + errorCode/errorMsg) that are not flagged at the MCP // protocol level. Returns the error message, or "" if the response is OK. func detectBusinessError(content map[string]any) string { return detectBusinessErrorAtDepth(content, 0) } func detectBusinessErrorAtDepth(content map[string]any, depth int) string { if content == nil || depth > 8 { return "" } success, ok := content["success"] if !ok { return detectNestedBusinessError(content, depth) } b, ok := success.(bool) if !ok || b { return detectNestedBusinessError(content, depth) } if nested := detectNestedBusinessError(content, depth); nested != "" { return nested } if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" { return strings.TrimSpace(msg) } if code, ok := content["errorCode"].(string); ok && strings.TrimSpace(code) != "" { return "business error: code " + strings.TrimSpace(code) } return "business error: success=false" } func detectNestedBusinessError(content map[string]any, depth int) string { for _, key := range []string{"content", "result", "data"} { switch child := content[key].(type) { case map[string]any: if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" { return msg } case []any: for _, item := range child { childMap, ok := item.(map[string]any) if !ok { continue } if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" { return msg } } } } return "" } // extractMCPErrorMessage builds an error message from a ToolCallResult with // isError=true. It extracts text from content blocks when available. func extractMCPErrorMessage(result transport.ToolCallResult) string { // Try text from content blocks first. for _, block := range result.Blocks { text := strings.TrimSpace(block.Text) if text != "" { return text } } // Try stringified content map. if msg, ok := result.Content["message"].(string); ok && strings.TrimSpace(msg) != "" { return strings.TrimSpace(msg) } if msg, ok := result.Content["error"].(string); ok && strings.TrimSpace(msg) != "" { return strings.TrimSpace(msg) } return "MCP tool returned an error response" } // logBusinessError logs MCP tool errors and business errors to the file logger // so they can be diagnosed offline. These errors arrive as HTTP 200 responses // and would otherwise not be captured by transport-level logging. func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) { if logger == nil { return } attrs := []any{ "product", inv.CanonicalProduct, "tool", inv.Tool, "reason", reason, } if diag.TraceID != "" { attrs = append(attrs, "trace_id", diag.TraceID) } if diag.ServerErrorCode != "" { attrs = append(attrs, "server_error_code", diag.ServerErrorCode) } if diag.TechnicalDetail != "" { attrs = append(attrs, "technical_detail", diag.TechnicalDetail) } if msg, ok := content["error"].(string); ok { attrs = append(attrs, "error", msg) } if msg, ok := content["errorMsg"].(string); ok { attrs = append(attrs, "errorMsg", msg) } if msg, ok := content["message"].(string); ok { attrs = append(attrs, "message", msg) } logger.Warn("business_error", attrs...) }