Files
AskaEth d6493d79f4 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

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00

113 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}