fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=), URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动 - browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数 - mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度, 分类链(errors.Is/As、context.Canceled)经 Unwrap 保留 - channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText 四处均补了会随回退变红的用例(变异已实测)。
This commit is contained in:
1 parent
2702777950
commit
d6493d79f4
10 files changed
+538
-23
No files matched your search
@@ -5,10 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
||||
@@ -30,8 +33,28 @@ type cdpError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
|
||||
// Error 渲染协议错误(实现 error 接口)。
|
||||
//
|
||||
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
|
||||
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
|
||||
// 可见视图,因此默认**不出镜**:
|
||||
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
|
||||
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
|
||||
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
|
||||
//
|
||||
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
|
||||
func (e *cdpError) Error() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CDP %d", e.Code)
|
||||
switch token := trace.ErrorToken(e.Message); {
|
||||
case token != "":
|
||||
b.WriteString(": ")
|
||||
b.WriteString(token)
|
||||
case e.Message != "":
|
||||
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
|
||||
//
|
||||
|
||||
@@ -3,6 +3,7 @@ package browser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
|
||||
// 判定「连接级问题 ⇒ 丢会话重起」)。
|
||||
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||||
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||||
//
|
||||
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
|
||||
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
|
||||
// (trace.ErrorToken)才保留,其余只留字节数。
|
||||
//
|
||||
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
|
||||
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
|
||||
// 守卫 —— 用例不该拿它当「自由文本」。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
|
||||
// ⇒ 哨兵文本出现在错误串里,本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
|
||||
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32000, Message: "boom"}
|
||||
return nil, &cdpError{Code: -32000, Message: message}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
|
||||
if err == nil {
|
||||
t.Fatal("协议错误应当返回 error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
|
||||
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP -32000") {
|
||||
t.Errorf("错误没带上协议码: %v", err)
|
||||
}
|
||||
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
|
||||
}
|
||||
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
|
||||
if !strings.Contains(err.Error(), "Runtime.evaluate") {
|
||||
t.Errorf("错误丢了方法名: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
|
||||
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("newCDPClient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
|
||||
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
|
||||
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
|
||||
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
|
||||
}
|
||||
|
||||
info := serverInfoFrom(session, c.hostFeatures())
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
|
||||
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil {
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
|
||||
out := make([]ToolInfo, 0, 8)
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
out = append(out, toolInfoFrom(tool))
|
||||
}
|
||||
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
|
||||
}
|
||||
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
return ToolResult{
|
||||
Content: toContentBlocks(res.Content),
|
||||
@@ -188,12 +187,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
|
||||
}
|
||||
|
||||
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
|
||||
//
|
||||
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
|
||||
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
|
||||
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
|
||||
func wrapConnectErr(err error) error {
|
||||
safe := upstreamError(err)
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
|
||||
}
|
||||
return err
|
||||
return safe
|
||||
}
|
||||
|
||||
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
|
||||
//
|
||||
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
|
||||
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
|
||||
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
|
||||
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
|
||||
// **默认不带自由文本**,只留可分类的骨架。
|
||||
//
|
||||
// 留下的骨架(够定位,不够回显):
|
||||
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
|
||||
// 不是错误文本);
|
||||
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
|
||||
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
|
||||
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
|
||||
|
||||
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
|
||||
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
|
||||
//
|
||||
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
|
||||
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
|
||||
type upstreamFailure struct {
|
||||
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
|
||||
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
|
||||
code int64
|
||||
hasCode bool
|
||||
// length 是被扣下的自由文本的字节数。
|
||||
length int
|
||||
// cause 只用于分类,绝不参与 Error()。
|
||||
cause error
|
||||
}
|
||||
|
||||
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
|
||||
func (f *upstreamFailure) Error() string {
|
||||
if f.hasCode {
|
||||
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
|
||||
}
|
||||
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
|
||||
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
|
||||
// 少了它,取消会被上层当成普通工具失败继续跑。
|
||||
func (f *upstreamFailure) Unwrap() error { return f.cause }
|
||||
|
||||
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
|
||||
//
|
||||
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
|
||||
// JSON-RPC 信封),所以不能只断言最外层。
|
||||
func upstreamError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
failure := &upstreamFailure{cause: err, length: len(err.Error())}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) {
|
||||
failure.code = wire.Code
|
||||
failure.hasCode = true
|
||||
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
|
||||
failure.length = len(wire.Message)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mcp
|
||||
|
||||
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
|
||||
//
|
||||
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
|
||||
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
|
||||
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
|
||||
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
|
||||
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
|
||||
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
|
||||
|
||||
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
|
||||
func sentinelServer() *mcpsdk.Server {
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
|
||||
srv.AddTool(&mcpsdk.Tool{
|
||||
Name: "boom",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
|
||||
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
|
||||
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, sentinelServer())
|
||||
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||||
}
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
for _, want := range []string{"boom", "fake", "code=-32603"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
|
||||
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
|
||||
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
|
||||
t.Parallel()
|
||||
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
|
||||
err := upstreamError(cause)
|
||||
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code=-32601") {
|
||||
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
|
||||
}
|
||||
// 分类面:WireError 的 Is 比的是码,链不能断。
|
||||
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
|
||||
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
|
||||
}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if !errors.As(err, &wire) || wire.Code != -32601 {
|
||||
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
|
||||
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
|
||||
// 收敛文本时绝不能把这条链切断。
|
||||
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "context canceled") {
|
||||
t.Errorf("取消的原文也被带进了错误串:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user