Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
# Conflicts: # internal/runtime/trace/errortoken.go
This commit is contained in:
commit
656c35f1e8
20 files changed
+1252
-58
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),
|
||||
@@ -165,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(),
|
||||
@@ -188,12 +189,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
|
||||
@@ -13,9 +15,13 @@ import (
|
||||
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
|
||||
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
|
||||
// 消息内容带进日志。
|
||||
//
|
||||
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
|
||||
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
|
||||
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
|
||||
const reviewExcerptLen = 40
|
||||
|
||||
// ParseReviewIndices decodes the keep-index array a review model returned, rising
|
||||
@@ -83,12 +89,12 @@ func unwrapCodeFence(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// reviewExcerpt truncates text for an error message so the next parse failure is
|
||||
// diagnosable without logging the whole completion.
|
||||
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
|
||||
//
|
||||
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
|
||||
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
|
||||
// 半个,错误串因此变成非法 UTF-8;
|
||||
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
|
||||
func reviewExcerpt(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= reviewExcerptLen {
|
||||
return s
|
||||
}
|
||||
return s[:reviewExcerptLen] + "…"
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
|
||||
@@ -126,3 +127,45 @@ func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
|
||||
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
|
||||
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
|
||||
//
|
||||
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
|
||||
// (utf8.ValidString 为 false)。
|
||||
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
|
||||
}
|
||||
body := strings.TrimSuffix(got, "…")
|
||||
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
|
||||
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超限时应当带省略号:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
|
||||
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
|
||||
//
|
||||
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
|
||||
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "sk-abcdefghijklmnop"
|
||||
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("凭据形状没被打码:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("打码标记缺失:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[0, 1]") {
|
||||
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,41 @@ package trace
|
||||
|
||||
import "strings"
|
||||
|
||||
// 本文件是「上游错误字段能不能出镜」的唯一守卫。
|
||||
// 本文件是「上游错误里的某个字段能不能出镜」的守卫之一:只放行「像协议枚举」的短串。
|
||||
//
|
||||
// 背景:UpstreamErrorInfo 只解析 error.code / error.type 两个字段,而它们**可能**是
|
||||
// 自由文本面 —— 主流上游里它们确实是枚举(invalid_request_error、overloaded_error、
|
||||
// 背景:UpstreamErrorInfo 只解析 error.code / error.type 两个字段,而它们是**自由
|
||||
// 文本面** —— 主流上游里它们确实是枚举(invalid_request_error、overloaded_error、
|
||||
// Throttling.RateQuota …),但网关、代理与兼容层可以往里面塞任何东西,包括回显的
|
||||
// 用户原文。因此这两个字段出镜前必须像令牌:短、只含标识符字符。
|
||||
//
|
||||
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。这道守卫
|
||||
// 原先在 runtime/model(safeErrorToken)与 runtime/multimodal/voice
|
||||
// (dashscopeErrorToken)各有一份逐字同形的实现,M46 把同一份收敛到这里。
|
||||
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。
|
||||
//
|
||||
// 🔴 M49 收口:守卫的**应用点**同样收进本包 —— UpstreamError 构造时对 error.code /
|
||||
// error.type 各调一次(构造即安全,调用点不必也不该再守),构造后的注入走
|
||||
// UpstreamErrorInfo.SetCode / SetType(同一道守卫)。
|
||||
// 🔴 M49 收口的是**应用点**,不是调用权:UpstreamError 构造时对 error.code /
|
||||
// error.type 各守一次(构造即安全),构造后的注入走 SetCode / SetType(同一道守
|
||||
// 卫)—— 这样一来「新调用点默认安全」,不必也不该在 model/voice 里各抄一份。
|
||||
//
|
||||
// 🔴 M50 修正口径(**取代**原先「本包之外的 Go 代码里不应出现 ErrorToken 调用」的说法;
|
||||
// 那条已被证伪:M50 之后本包外确有正当调用点)。判据是**字段的类别**,不是调用点的位置:
|
||||
// 🔴 M51 纠正口径(2026-10-26,复审 p2 裁定):本文件旧注释写的是「包外不应出现
|
||||
// ErrorToken 调用」。那句话把 M48 的真实教训(**调用方逐点补守卫**会漏)错译成了
|
||||
// 「只有本包能守」,于是把「按**字段类别**判定」这条正确口径一并否掉,而现实里包外
|
||||
// 一直有正当调用。正确口径按字段类别分:
|
||||
//
|
||||
// - **协议枚举字段**(信封的 status / code / type、DashScope 的 request_id 这类
|
||||
// 「一格一个值」的字段)**可以**在渲染点调用本守卫后再出镜。真实取值是短令牌 ⇒
|
||||
// 原样通过;上游若在枚举位上塞散文 ⇒ 整体变空串。现行调用点:
|
||||
// `plugins/channels/qq_obv11`(信封 status,两处)、`plugins/channels/weixin_ilink`
|
||||
// (扫码 status 与 redirect_host)、`runtime/multimodal/voice/dashscope_error.go`
|
||||
// (request_id —— M50 之前就存在的正当例外)。
|
||||
// - **自由文本字段**(message / description / msg / wording / 服务端错误说明头 …)
|
||||
// **不得**靠本守卫出镜。它们的问题不是形状(短的人话照样像令牌),而是取值的
|
||||
// 控制权:上游想写什么就写什么,而它写的常常是被拒的请求正文 —— 也就是她的话。
|
||||
// 这类字段的口径是**默认不带**;要带必须显式 opt-in,并在保留时过 RedactText
|
||||
// (见 trace.UpstreamError 的 WithUpstreamMessage 与
|
||||
// internal/channel/adapter/httpclient 的 WithEnvelopeMessage)。
|
||||
// - **协议枚举字段** —— error.code / error.type、CDP `error.message` 里的协议错误
|
||||
// 名、各类状态码:值域是协议定义的短标识符 ⇒ 用 ErrorToken 守。像枚举就保留,
|
||||
// 散文(带空格、标点、超长)一律丢弃;
|
||||
// - **自由文本字段** —— error.message、模型/对端「说的话」、页面异常文本:**默认
|
||||
// 不带**;确实要带时按「带上限地带」处理(rune 上限 + RedactText,见
|
||||
// channel/adapter 的 reviewExcerpt、services/channelbridge 的 judgeReason、trace.UpstreamError 的 WithUpstreamMessage 与 httpclient 的 WithEnvelopeMessage),
|
||||
// 绝不拿 ErrorToken 当清洗(它只会把整段话删掉,不是清洗)。
|
||||
//
|
||||
// 简言之:本守卫回答「这个令牌形状的枚举值能不能露」,不回答「这段人话能不能露」。
|
||||
// 包外调用的现存例子(都是枚举口径,属正当用法):
|
||||
// - `internal/capability/browser/cdp.go` 的 cdpError.Error():CDP error.message
|
||||
// 只有像协议枚举才出镜,其余只留字节数(M51);
|
||||
// - `internal/runtime/multimodal/voice/dashscope_error.go`:DashScope 的 code/type
|
||||
// (M46 起就在包外调用)。
|
||||
//
|
||||
// 因此现在的约束只有一条:**调用前先确认那个字段是枚举口径**;自由文本字段不许用
|
||||
// 它冒充清洗,也不许为了「新调用点安全」而在本文件之外复制一份守卫实现 —— 守卫本身
|
||||
// 仍然只有这一份。
|
||||
|
||||
// maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。
|
||||
// 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件是「适配器拨号失败怎么写进日志」的**唯一**收敛点。
|
||||
//
|
||||
// 为什么需要它:Connect 失败的错误串里几乎总带着**拨号地址** ——
|
||||
// qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`(也可以写成
|
||||
// `user:pass@` 或别的键名),而 *url.Error 的 Error() 会把整条地址渲染进错误串:
|
||||
// `dial tcp wss://h/?access_token=…: …`。这条错误串原样进 WARN,就等于把平台凭据
|
||||
// 写进日志 —— 而日志会被采集、tail、粘进工单,是最容易扩散的面。
|
||||
//
|
||||
// 收敛口径与 trace 包完全一致(那里是**唯一一份**实现,本包只调用、不复制):
|
||||
// - trace.URLCause 剥掉 *url.Error 的外壳 —— 它的 Error() 就是整条地址;
|
||||
// 「包一层 %w 不是修复,只是换个位置泄漏」,剥壳才是;
|
||||
// - 残留文本里**内嵌**的地址(适配器自己用 %s 把地址拼进文案,例如 qq-obv11 的
|
||||
// `dial onebot ws <remote_url>`)逐个换成 trace.MaskQuery 的形态:凭据类
|
||||
// query/fragment 值与 userinfo 打码,host 与路径保留;
|
||||
// - trace.HostOf 单独给出目标来源 —— 打码之后「打到哪」这条诊断信息仍在;
|
||||
// - trace.RedactText 收尾:不在 URL 里的凭据形状(`Authorization: Bearer …` 这类
|
||||
// 握手失败常见回显)也一并抹掉,并把整行压回一屏。
|
||||
//
|
||||
// 🔴 本文件只影响**日志文案**:connectAndRun 的判定与分支一行未动 —— 谁连、连不上
|
||||
// 之后留不留在连接表、要不要继续重试,全都不归本文件管(channelbridge 是历史敏感面)。
|
||||
|
||||
// connectFailureAttrs 把一次拨号失败整理成可以安全落日志的键值对:目标来源
|
||||
// (host)+ 打过码的原因。它替代直接写 `"error", err` —— 后者会把整条拨号地址
|
||||
// (含 access_token)原样带进 WARN。
|
||||
func connectFailureAttrs(channel string, err error) []any {
|
||||
host := urlErrorHost(err)
|
||||
text, embeddedHost := maskEmbeddedURLs(trace.URLCause(err).Error())
|
||||
if host == "" {
|
||||
host = embeddedHost
|
||||
}
|
||||
return []any{"channel", channel, "host", host, "error", trace.RedactText(text)}
|
||||
}
|
||||
|
||||
// urlErrorHost 取 *url.Error 里那条地址的来源(scheme://host[:port],用户信息
|
||||
// 已整段打码)。err 不是 *url.Error 时返回空串 —— 绝不回落到原串。
|
||||
func urlErrorHost(err error) string {
|
||||
var uerr *url.Error
|
||||
if !errors.As(err, &uerr) {
|
||||
return ""
|
||||
}
|
||||
return trace.HostOf(uerr.URL)
|
||||
}
|
||||
|
||||
// maskEmbeddedURLs 把自由文本里内嵌的地址逐个换成 trace.MaskQuery 的形态,并返回
|
||||
// 第一个地址的来源(供日志的 host 字段)。解析不出 host 的地址整段替换成占位符:
|
||||
// 宁可丢掉那条地址,也不回落到原串(回落就是泄漏)。
|
||||
func maskEmbeddedURLs(text string) (masked, host string) {
|
||||
var b strings.Builder
|
||||
rest := text
|
||||
for {
|
||||
i := strings.Index(rest, "://")
|
||||
if i < 0 {
|
||||
b.WriteString(rest)
|
||||
return b.String(), host
|
||||
}
|
||||
start := i
|
||||
for start > 0 && isSchemeByte(rest[start-1]) {
|
||||
start--
|
||||
}
|
||||
end := i + len("://")
|
||||
for end < len(rest) && !isURLTailByte(rest[end]) {
|
||||
end++
|
||||
}
|
||||
raw := rest[start:end]
|
||||
safe := trace.MaskQuery(raw)
|
||||
if safe == "" {
|
||||
safe = "[redacted-url]"
|
||||
}
|
||||
if host == "" {
|
||||
host = trace.HostOf(raw)
|
||||
}
|
||||
b.WriteString(rest[:start])
|
||||
b.WriteString(safe)
|
||||
rest = rest[end:]
|
||||
}
|
||||
}
|
||||
|
||||
// isSchemeByte 报告该字节能否出现在 scheme 里(RFC 3986:ALPHA / DIGIT / "+" /
|
||||
// "-" / ".")。回退扫描到 scheme 起点,才不会把 `ws://` 里的 `ws` 丢掉。
|
||||
func isSchemeByte(c byte) bool {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
return true
|
||||
case c == '+' || c == '-' || c == '.':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isURLTailByte 报告该字节是否**终止**一段地址(日志里内嵌地址的常见收尾:空白、
|
||||
// 引号、括号、逗号/分号/花括号)。`]` 刻意不算终止符:IPv6 字面量
|
||||
// (`http://[::1]:8080/x`)会被切成半截而解析不出 host。>= 0x7f 的字节也终止:那是
|
||||
// 多字节字符的首字节,地址里不会出现裸中文,跟着它一起扫会把后面的整段话当成地址。
|
||||
func isURLTailByte(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '\t', '\n', '\r', '"', '\'', ')', '>', ',', ';', '}':
|
||||
return true
|
||||
default:
|
||||
return c >= 0x7f || c == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件钉住「拨号失败的 WARN 不带凭据」这条红线(connectlog.go)。
|
||||
//
|
||||
// 生产症状:qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`,拨号失败时
|
||||
// 错误串里就是整条地址;原样 `"error", err` 落 WARN 等于把平台凭据写进日志。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 connectFailureAttrs 换回 `"error", err` ⇒ SECRET 出现在日志里,用例红;
|
||||
// - 只做 trace.RedactText(不做 MaskQuery)⇒ 短 token 用例红(形状级匹配要求
|
||||
// 赋值 ≥6 字符,3 字符的 token 逃得过去);
|
||||
// - 把 host 字段摘掉 ⇒ 「目标 host 仍在」断言红(打码不能把诊断一起打没)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// dialFailureAdapter 是「Connect 以给定错误失败」的适配器:其余能力沿用 fakeAdapter。
|
||||
type dialFailureAdapter struct {
|
||||
*fakeAdapter
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *dialFailureAdapter) Connect(context.Context) error { return a.err }
|
||||
|
||||
// newLogBridge 构建一个把日志写进 out 的桥接器(其余依赖用测试桩)。
|
||||
func newLogBridge(t *testing.T, out *bytes.Buffer) *Bridge {
|
||||
t.Helper()
|
||||
b, err := New(Options{
|
||||
Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Out: out}),
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
return b
|
||||
}
|
||||
|
||||
// qqDialAdapter 返回一个「配置完整、拨号必失败」的 qq-obv11 适配器。
|
||||
func qqDialAdapter(err error) *dialFailureAdapter {
|
||||
return &dialFailureAdapter{
|
||||
fakeAdapter: &fakeAdapter{
|
||||
name: "qq-obv11:bot1",
|
||||
cfg: map[string]any{"mode": "client", "remoteURL": "wss://onebot.example.test/open"},
|
||||
},
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// startAndCapture 启动桥接器(连接同步完成)并返回它写下的日志。
|
||||
func startAndCapture(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
b := newLogBridge(t, &out)
|
||||
b.Add(qqDialAdapter(err))
|
||||
b.Start()
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// assertConnectWarn 断言 WARN 的三件事:仍然说「连不上」、目标 host 仍在、凭据不在。
|
||||
func assertConnectWarn(t *testing.T, logged, secret string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(logged, "channel connect failed, leaving disconnected") {
|
||||
t.Fatalf("日志里没有「连不上」这条 WARN:%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "onebot.example.test") {
|
||||
t.Fatalf("日志丢了目标 host(打码不该连诊断一起打没):%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("日志里出现了拨号凭据 %q:%s", secret, logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectFailureWarn_MasksURLErrorToken 覆盖 *url.Error 形状(net/http 与
|
||||
// gorilla dial 的真实形态:Error() 会渲染整条地址)。
|
||||
func TestConnectFailureWarn_MasksURLErrorToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "SECRET-TOKEN-VALUE"
|
||||
dialErr := &url.Error{
|
||||
Op: "dial",
|
||||
URL: "wss://onebot.example.test/open?access_token=" + secret,
|
||||
Err: errors.New("websocket: bad handshake"),
|
||||
}
|
||||
assertConnectWarn(t, startAndCapture(t, dialErr), secret)
|
||||
}
|
||||
|
||||
// TestConnectFailureWarn_MasksEmbeddedURLToken 覆盖「适配器自己把地址拼进文案」的
|
||||
// 形状(qq-obv11 的 `dial onebot ws <remote_url>: <cause>`):这里没有 *url.Error
|
||||
// 外壳可剥,只能靠地址级打码 —— 因此 token 刻意取得**短于**形状级匹配的长度,
|
||||
// 只做 RedactText 的实现会在这里漏。
|
||||
func TestConnectFailureWarn_MasksEmbeddedURLToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "abc"
|
||||
dialErr := errors.New("qq-obv11: dial onebot ws wss://onebot.example.test/open?access_token=" +
|
||||
secret + ": websocket: bad handshake")
|
||||
logged := startAndCapture(t, dialErr)
|
||||
assertConnectWarn(t, logged, secret)
|
||||
if !strings.Contains(logged, "access_token=***") {
|
||||
t.Fatalf("凭据参数的值应被打码成 ***(保留参数名便于排障):%s", logged)
|
||||
}
|
||||
}
|
||||
@@ -219,16 +219,16 @@ func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, ac
|
||||
}
|
||||
if d.AvoidTopic {
|
||||
b.toIdle(st)
|
||||
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason)
|
||||
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", judgeReason(d.Reason))
|
||||
return false, "engage: avoid topic"
|
||||
}
|
||||
if d.WorthReply || d.Interesting {
|
||||
b.enterActive(st, cfg, now, msg)
|
||||
b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason)
|
||||
return true, "engage: " + d.Reason
|
||||
b.log.Debug("engage probing → active (respond)", "group", key, "reason", judgeReason(d.Reason))
|
||||
return true, "engage: " + judgeReason(d.Reason)
|
||||
}
|
||||
b.toIdle(st)
|
||||
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
|
||||
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", judgeReason(d.Reason))
|
||||
return false, "engage: no interest"
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
}
|
||||
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
|
||||
b.countNoInterest(st)
|
||||
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason)
|
||||
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", judgeReason(d.Reason))
|
||||
return b.engageAfterFollowUp(st, cfg, now, key)
|
||||
}
|
||||
st.lastInteract = now
|
||||
st.lastTopic = contentSnippet(msg.Content)
|
||||
st.consecutiveNoInterest = 0
|
||||
b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason)
|
||||
return true, "engage follow-up: " + d.Reason
|
||||
b.log.Debug("engage follow-up respond", "group", key, "reason", judgeReason(d.Reason))
|
||||
return true, "engage follow-up: " + judgeReason(d.Reason)
|
||||
}
|
||||
|
||||
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的 7 处
|
||||
// 出口,统一走 judge_reason.go 的 judgeReason):Debug 日志与返回值必须同时满足
|
||||
// 「凭据打码」与「压平成一行的短句」。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 judgeReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里
|
||||
// 都能搜到 sk-live…,红;
|
||||
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ 换行仍在,红(见下面两条换行断言);
|
||||
// - 只改日志、不改返回值(`"engage: " + d.Reason` 保持原样)⇒ 返回值那条断言红。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// engageReasonSecret 是判定器「回显」进 reason 的凭据形状(sk-… 形状级匹配能认)。
|
||||
const engageReasonSecret = "sk-live-0123456789abcdef"
|
||||
|
||||
// engageReasonRaw 是判定器回的原始 reason:一句正常短语 + 回显的凭据 + 一个换行
|
||||
// (换行是想在按行聚合的日志里伪造一条新记录)。
|
||||
const engageReasonRaw = "第一行:群里有 " + engageReasonSecret + "\n第二行:伪造一条日志"
|
||||
|
||||
// engageReasonWant 是 engageReason 的期望产物:凭据被打码成 [redacted]、两行被压成
|
||||
// 一行(空格分隔)、其余原文保留(诊断价值仍在)。
|
||||
const engageReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
|
||||
|
||||
// engageReasonBridge 构建一条装配了参与度引擎与 LightJudge 的桥接器,
|
||||
// 并把 Debug 级日志写进 out(Debug 是 reason 的唯一落点,级别必须降到 debug)。
|
||||
func engageReasonBridge(t *testing.T, judge LightJudge, out *bytes.Buffer) *Bridge {
|
||||
t.Helper()
|
||||
b, err := New(Options{
|
||||
Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Level: "debug", Out: out}),
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "参与"}},
|
||||
Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{},
|
||||
Light: judge,
|
||||
TriggerWords: []string{"昔涟"},
|
||||
Engage: DefaultEngageConfig(),
|
||||
EngageRand: func() float64 { return 0.5 }, // 永不命中概率触发,只走角色名提及
|
||||
Now: dayNow(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
return b
|
||||
}
|
||||
|
||||
// assertNoCredentialNoNewline 断言一段出镜文本既不含凭据原文,也不含换行
|
||||
// (换行会按行聚合的日志里伪造出「另一条记录」)。
|
||||
func assertNoCredentialNoNewline(t *testing.T, what, got string) {
|
||||
t.Helper()
|
||||
if strings.Contains(got, "sk-live") {
|
||||
t.Fatalf("%s 里出现了判定器回显的凭据:%q", what, got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("%s 里没有打码标记(Reason 应当仍带上限出镜):%q", what, got)
|
||||
}
|
||||
if strings.Contains(got, "\n") || strings.Contains(got, "\\n") {
|
||||
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngageReason_ProbeLogAndReturnAreSanitized 覆盖 PROBING 判定(→ ACTIVE):
|
||||
// 日志与返回值都只带打码压平后的短句。
|
||||
func TestEngageReason_ProbeLogAndReturnAreSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out bytes.Buffer
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
|
||||
}}
|
||||
b := engageReasonBridge(t, judge, &out)
|
||||
|
||||
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
|
||||
respond, reason := b.engageDecision(context.Background(), msg, msg.Sender, nonOwnerSession(), false)
|
||||
if !respond {
|
||||
t.Fatalf("判定器说值得回,engageDecision 却回了 false(reason=%q)", reason)
|
||||
}
|
||||
assertNoCredentialNoNewline(t, "PROBING 返回值", reason)
|
||||
if !strings.Contains(reason, engageReasonWant) {
|
||||
t.Fatalf("返回值 = %q,期望含 %q(打码 + 压平后的短句)", reason, engageReasonWant)
|
||||
}
|
||||
logged := out.String()
|
||||
if !strings.Contains(logged, engageReasonWant) {
|
||||
t.Fatalf("Debug 日志里没有打码压平后的 reason(原文换行未压平?):%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "sk-live") {
|
||||
t.Fatalf("Debug 日志里出现了判定器回显的凭据:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngageReason_FollowUpLogAndReturnAreSanitized 覆盖 ACTIVE 跟进复审(→ 继续参与):
|
||||
// 第二处返回值拼接(`"engage follow-up: " + …`)同样必须打码压平。
|
||||
func TestEngageReason_FollowUpLogAndReturnAreSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out bytes.Buffer
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: true, WorthReply: true, Reason: "先进入活跃窗口"},
|
||||
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
|
||||
}}
|
||||
b := engageReasonBridge(t, judge, &out)
|
||||
|
||||
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
|
||||
ctx := context.Background()
|
||||
if respond, _ := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); !respond {
|
||||
t.Fatal("第一次探测应当进入 ACTIVE")
|
||||
}
|
||||
respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false)
|
||||
if !respond {
|
||||
t.Fatalf("跟进判定说继续参与,engageDecision 却回了 false(reason=%q)", reason)
|
||||
}
|
||||
assertNoCredentialNoNewline(t, "跟进返回值", reason)
|
||||
if !strings.Contains(reason, "engage follow-up: "+engageReasonWant) {
|
||||
t.Fatalf("跟进返回值 = %q,期望含 %q", reason, "engage follow-up: "+engageReasonWant)
|
||||
}
|
||||
logged := out.String()
|
||||
if !strings.Contains(logged, engageReasonWant) {
|
||||
t.Fatalf("跟进 Debug 日志里没有打码压平后的 reason:%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "sk-live") {
|
||||
t.Fatalf("跟进 Debug 日志里出现了判定器回显的凭据:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngageReason_AvoidTopicAndNoInterestAreSanitized 覆盖另外两条 Debug 出口
|
||||
// (回避话题 / 无兴趣),它们同样带 reason。
|
||||
func TestEngageReason_AvoidTopicAndNoInterestAreSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out bytes.Buffer
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: false, WorthReply: false, AvoidTopic: true, Reason: engageReasonRaw},
|
||||
{Interesting: false, WorthReply: false, Reason: engageReasonRaw},
|
||||
}}
|
||||
b := engageReasonBridge(t, judge, &out)
|
||||
|
||||
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
|
||||
ctx := context.Background()
|
||||
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
|
||||
t.Fatalf("回避话题不应回复(reason=%q)", reason)
|
||||
}
|
||||
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
|
||||
t.Fatalf("无兴趣不应回复(reason=%q)", reason)
|
||||
}
|
||||
logged := out.String()
|
||||
if strings.Contains(logged, "sk-live") {
|
||||
t.Fatalf("回避/无兴趣的 Debug 日志里出现了凭据:%s", logged)
|
||||
}
|
||||
if n := strings.Count(logged, engageReasonWant); n < 2 {
|
||||
t.Fatalf("回避与无兴趣两条日志各应带一次打码压平后的 reason,实际 %d 次:%s", n, logged)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件是「判定模型/上游回来的自由文本怎么写进返回值与日志」的唯一收敛点。
|
||||
//
|
||||
// 它的三个使用者都是同一类出口:模型(或上游)给一句 reason,我们把它拼进返回值、
|
||||
// 再被上层落进日志 ——
|
||||
// - engage.go 的参与度判定(5 处 Debug + 2 处返回值);
|
||||
// - light.go 的旧轻量判定(2 处返回值,落到 coalesce_prepare.go 的 Debug);
|
||||
// - notice.go 的系统通知剔噪失败原因(拼进 noticeOutcome.Reason,落到事件 Debug)。
|
||||
//
|
||||
// 这类文本的两种病害都真实存在:模型可以把 Authorization / sk-… 回显进 reason(凭据),
|
||||
// 也可以塞一个换行(在按行聚合的日志里伪造出「另一条记录」)。因此出镜前必须
|
||||
// 「压平 + 打码 + 截断」,而且**只有这一份实现** —— 三处各写一份必然漂移,漂移的方向
|
||||
// 总是「其中一处为了多带点诊断把原文留下来」。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// judgeReasonRunes 是这类 reason 允许出镜的 rune 上限。
|
||||
//
|
||||
// 取 60 的理由:这些 reason 由「只输出 JSON 对象」的轻量判定器给出,实际值是一句短语
|
||||
// (「群里在聊周末安排,与角色无关」≈ 16 字),60 rune 容得下三四句这样的短语;一旦
|
||||
// 超过 60 rune,说明它在**复述群聊原文** —— 那部分对排障没有价值(要的是「判定方向 +
|
||||
// 方向词」),却正是最不该进日志的那段话。
|
||||
const judgeReasonRunes = 60
|
||||
|
||||
// judgeReason 把一段判定/上游 reason 整理成可以出镜的短句(返回值与日志共用这同一份,
|
||||
// 口径不会分叉):
|
||||
//
|
||||
// - 压平空白:一个换行就能在按行聚合的日志里伪造出「另一条记录」;返回值也会流进
|
||||
// 日志(coalesce_prepare 的 Debug),所以同样要压;
|
||||
// - 凭据清洗走 trace.RedactTextLimited(与 trace.RedactText 同一实现,不是第二套
|
||||
// 脱敏路径):模型可能把 Authorization / sk-… 之类原样回显进 reason;
|
||||
// - 截到 judgeReasonRunes(见上)。
|
||||
//
|
||||
// 🔴 本函数只决定「进去的那份文本」,不参与任何判定:调用点的分支条件、状态迁移与
|
||||
// 返回值拼接结构一个字都不动(2026-10-26 塔口径)。
|
||||
func judgeReason(raw string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), judgeReasonRunes)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件钉住 judgeReason 的三个使用者(engage.go / light.go / notice.go)都真的走它。
|
||||
// engage.go 那 7 处由 engage_reason_test.go 覆盖;这里覆盖后两处与 helper 本身。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 judgeReason 换成恒等返回(摘掉 trace.RedactTextLimited)⇒ 三条用例都红;
|
||||
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ helper 那条与 light 那条红(换行仍在);
|
||||
// - 把 light.go / notice.go 的调用点改回原文(`d.Reason` / `err.Error()`)⇒ 对应用例红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// judgeReasonSecret 是模型/上游「回显」进 reason 的凭据形状。
|
||||
const judgeReasonSecret = "sk-live-0123456789abcdef"
|
||||
|
||||
// judgeReasonRaw 是原始 reason:短语 + 回显的凭据 + 换行(想在按行聚合的日志里伪造
|
||||
// 一条新记录)。
|
||||
const judgeReasonRaw = "第一行:群里有 " + judgeReasonSecret + "\n第二行:伪造一条日志"
|
||||
|
||||
// judgeReasonWant 是 judgeReason 的期望产物。
|
||||
const judgeReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
|
||||
|
||||
// assertSanitizedReason 断言一段出镜文本既不含凭据原文,也不含换行。
|
||||
func assertSanitizedReason(t *testing.T, what, got string) {
|
||||
t.Helper()
|
||||
if strings.Contains(got, "sk-live") {
|
||||
t.Fatalf("%s 里出现了回显的凭据:%q", what, got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("%s 里没有打码标记:%q", what, got)
|
||||
}
|
||||
if strings.Contains(got, "\n") {
|
||||
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeReason_RedactsFlattensAndBounds 钉住 helper 本身的三件事。
|
||||
func TestJudgeReason_RedactsFlattensAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := judgeReason(judgeReasonRaw); got != judgeReasonWant {
|
||||
t.Fatalf("judgeReason = %q,期望 %q", got, judgeReasonWant)
|
||||
}
|
||||
// 上限按 rune 计:60 个汉字必须原样保留(不能被截),61 个才截断。
|
||||
if got := judgeReason(strings.Repeat("中", judgeReasonRunes)); got != strings.Repeat("中", judgeReasonRunes) {
|
||||
t.Fatalf("恰好 %d rune 不该被截断:%q", judgeReasonRunes, got)
|
||||
}
|
||||
if got := judgeReason(strings.Repeat("中", judgeReasonRunes+1)); len([]rune(got)) != judgeReasonRunes+1 {
|
||||
t.Fatalf("超限时应保留 %d rune + 省略号,实际 %q", judgeReasonRunes, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyLightJudge_ReasonSanitized 覆盖 light.go 的两处返回值(旧轻量判定车道):
|
||||
// reason 出镜前必须打码压平(它随后被 coalesce_prepare 的 Debug 落日志)。
|
||||
func TestLegacyLightJudge_ReasonSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, respond := range []bool{true, false} {
|
||||
judge := &stubLightJudge{resp: LightDecision{Respond: respond, Reason: judgeReasonRaw}}
|
||||
b, err := New(Options{
|
||||
Log: logger.New(logger.Options{Level: "debug", Out: &strings.Builder{}}),
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "x"}},
|
||||
Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{},
|
||||
Light: judge,
|
||||
TriggerWords: []string{"昔涟"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
msg := nonOwnerGroupMsg("提一句昔涟")
|
||||
got, reason := b.legacyLightJudge(context.Background(), msg, msg.Sender, false)
|
||||
b.Stop(context.Background())
|
||||
if got != respond {
|
||||
t.Fatalf("legacyLightJudge respond = %v, 期望 %v", got, respond)
|
||||
}
|
||||
assertSanitizedReason(t, "light judge 返回值", reason)
|
||||
if !strings.Contains(reason, "light judge: "+judgeReasonWant) {
|
||||
t.Fatalf("返回值 = %q,期望含 %q", reason, "light judge: "+judgeReasonWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoticeReviewFailureReasonSanitized 覆盖 notice.go 的失败原因:
|
||||
// 审查模型(或它背后的上游)的 error 文本进 noticeOutcome.Reason 前同样要打码压平。
|
||||
func TestNoticeReviewFailureReasonSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
|
||||
return nil, errors.New(judgeReasonRaw)
|
||||
}
|
||||
ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, ok: true}
|
||||
b, _, _, _ := noticeFixture(t, ad, review)
|
||||
|
||||
msg := groupNoticeMessage()
|
||||
out := b.preEntryReview(context.Background(), &msg, ad)
|
||||
if out.Proceed {
|
||||
t.Fatalf("审查失败必须走 fail-safe 静默,实际放行了:reason=%q", out.Reason)
|
||||
}
|
||||
if !strings.HasPrefix(out.Reason, "notice review failed: ") {
|
||||
t.Fatalf("原因前缀被改动了:%q", out.Reason)
|
||||
}
|
||||
assertSanitizedReason(t, "notice 审查失败原因", out.Reason)
|
||||
}
|
||||
@@ -279,9 +279,9 @@ func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessag
|
||||
})
|
||||
if err == nil {
|
||||
if d.Respond {
|
||||
return true, "light judge: " + d.Reason
|
||||
return true, "light judge: " + judgeReason(d.Reason)
|
||||
}
|
||||
return false, "light judge: " + d.Reason
|
||||
return false, "light judge: " + judgeReason(d.Reason)
|
||||
}
|
||||
b.log.Warn("light judge failed; silencing", "error", err)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (b *Bridge) preEntryReview(ctx context.Context, msg *adapter.UnifiedMessage
|
||||
default:
|
||||
keep, err := b.reviewNoticeNoise(ctx, *msg, notice)
|
||||
if err != nil {
|
||||
out.Reason = "notice review failed: " + err.Error()
|
||||
out.Reason = "notice review failed: " + judgeReason(err.Error())
|
||||
out.Proceed = noticeFailSafeProceeds
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -66,8 +66,10 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
|
||||
err := a.Connect(connCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// 原始 dial 错误里带着整条拨号地址(`?access_token=…`),不能原样落 WARN;
|
||||
// connectFailureAttrs 只改文案,不动这里的判定与分支(见 connectlog.go)。
|
||||
b.log.Warn("channel connect failed, leaving disconnected",
|
||||
"channel", a.Name(), "error", err)
|
||||
connectFailureAttrs(a.Name(), err)...)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user