fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径

① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
This commit is contained in:
AskaEth committed 2026-10-26 20:00:57 +08:00
1 parent 7fbb8d6232
commit 76263d42ed
11 files changed
+560 -47

No files matched your search

+3 -1
View File
@@ -164,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
Logger: c.log,
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(),
+124
View File
@@ -0,0 +1,124 @@
package mcp
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
//
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
//
// logger.Error("keepalive ping failed; closing session", "error", err, …)
//
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
// 了对端回显的整句话。
//
// 收敛口径(与 client.go 同一刻画,不做第二套):
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
// `upstreamError` 渲染的 code + message_bytes;
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
//
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
import (
"context"
"errors"
"log/slog"
"strings"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
//
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
const maxSDKLogRunes = 300
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
func redactingLogger(l *slog.Logger) *slog.Logger {
return slog.New(&redactingHandler{inner: l.Handler()})
}
// redactingHandler 是 redactingLogger 的 handler 形态。
type redactingHandler struct {
inner slog.Handler
}
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool {
out.AddAttrs(scrubSDKAttr(a))
return true
})
return h.inner.Handle(ctx, out)
}
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
// 记录会绕过本文件。
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
scrubbed := make([]slog.Attr, len(attrs))
for i, a := range attrs {
scrubbed[i] = scrubSDKAttr(a)
}
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
}
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
func (h *redactingHandler) WithGroup(name string) slog.Handler {
return &redactingHandler{inner: h.inner.WithGroup(name)}
}
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
func scrubSDKText(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
}
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
func scrubSDKAttr(a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindString:
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok && err != nil {
a.Value = slog.StringValue(scrubSDKError(err))
}
case slog.KindGroup:
group := a.Value.Group()
scrubbed := make([]slog.Attr, len(group))
for i, item := range group {
scrubbed[i] = scrubSDKAttr(item)
}
a.Value = slog.GroupValue(scrubbed...)
}
return a
}
// scrubSDKError 区分两类错误来源(口径见文件头):
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
// message_bytes),一个字节的自由文本都不留;
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
func scrubSDKError(err error) string {
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
return upstreamError(err).Error()
}
return scrubSDKText(err.Error())
}
@@ -0,0 +1,175 @@
package mcp
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
//
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
//
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
//
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
// 说的那句话」被对端回显)。
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
// keepalive 日志」这条路径。
//
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
type pingErrorTransport struct {
inner mcpsdk.Transport
}
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
conn, err := t.inner.Connect(ctx)
if err != nil {
return nil, err
}
return newPingErrorConn(conn), nil
}
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
type pingErrorConn struct {
inner mcpsdk.Connection
msgs chan sdkjsonrpc.Message
done chan struct{}
once sync.Once
}
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
c := &pingErrorConn{
inner: inner,
msgs: make(chan sdkjsonrpc.Message, 16),
done: make(chan struct{}),
}
go c.pump()
return c
}
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
func (c *pingErrorConn) pump() {
defer close(c.msgs)
for {
msg, err := c.inner.Read(context.Background())
if err != nil {
return
}
select {
case c.msgs <- msg:
case <-c.done:
return
}
}
}
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
req, ok := msg.(*sdkjsonrpc.Request)
if !ok || req.Method != "ping" {
return c.inner.Write(ctx, msg)
}
select {
case c.msgs <- &sdkjsonrpc.Response{
ID: req.ID,
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
}:
return nil
case <-c.done:
return context.Canceled
}
}
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
select {
case msg, ok := <-c.msgs:
if !ok {
return nil, context.Canceled
}
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *pingErrorConn) Close() error {
c.once.Do(func() { close(c.done) })
return c.inner.Close()
}
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
c := New(MCPConfig{Name: "fake"},
WithTransport(startFakeServer(t, sentinelServer())),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if got := buf.String(); strings.Contains(got, errSentinel) {
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
}
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
}
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
c := New(MCPConfig{Name: "fake"},
WithTransport(tr),
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
WithProtocolVersion("2025-11-25"),
WithKeepAlive(20*time.Millisecond, 1),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
return strings.Contains(buf.String(), "keepalive ping failed")
})
logged := buf.String()
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
if !strings.Contains(logged, "keepalive ping failed") {
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
}
if strings.Contains(logged, sdkLogSentinel) {
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
}
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
}
+59
View File
@@ -0,0 +1,59 @@
package mcp
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
// 用真 server 证明这条日志面确实走过收敛。
//
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
t.Parallel()
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
wire := fmt.Errorf("calling %q: %w", "ping",
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
got := scrubSDKError(wire)
if strings.Contains(got, "SENTINEL-MCPLOG") {
t.Fatalf("线上错误的自由文本进了日志:%q", got)
}
if !strings.Contains(got, "code=-32603") {
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
}
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
t.Fatalf("本地错误的原因被吞掉了:%q", got)
}
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
got = scrubSDKError(leaky)
if strings.Contains(got, "sk-live") {
t.Fatalf("本地错误里的凭据没被打码:%q", got)
}
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
}
}
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
// 状态,不该被字符串化或截断。
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
t.Parallel()
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
}
}