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:
AskaEth committed 2026-10-26 19:44:22 +08:00
1 parent 2702777950
commit d6493d79f4
10 files changed
+538 -23

No files matched your search

@@ -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)
}
}
+3 -1
View File
@@ -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
}