diff --git a/internal/capability/browser/cdp.go b/internal/capability/browser/cdp.go
index 42e60a0..9e2976d 100644
--- a/internal/capability/browser/cdp.go
+++ b/internal/capability/browser/cdp.go
@@ -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 关联的待答表。
//
diff --git a/internal/capability/browser/cdp_test.go b/internal/capability/browser/cdp_test.go
index e54b97d..8e40117 100644
--- a/internal/capability/browser/cdp_test.go
+++ b/internal/capability/browser/cdp_test.go
@@ -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 "
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) {
diff --git a/internal/capability/mcp/client.go b/internal/capability/mcp/client.go
index 6849162..01f93f4 100644
--- a/internal/capability/mcp/client.go
+++ b/internal/capability/mcp/client.go
@@ -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 对象)。
diff --git a/internal/capability/mcp/errors.go b/internal/capability/mcp/errors.go
new file mode 100644
index 0000000..a02b952
--- /dev/null
+++ b/internal/capability/mcp/errors.go
@@ -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
+}
diff --git a/internal/capability/mcp/errors_test.go b/internal/capability/mcp/errors_test.go
new file mode 100644
index 0000000..0725a5a
--- /dev/null
+++ b/internal/capability/mcp/errors_test.go
@@ -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)
+ }
+}
diff --git a/internal/channel/adapter/flowgate_parse.go b/internal/channel/adapter/flowgate_parse.go
index dc0fe59..fdf48c2 100644
--- a/internal/channel/adapter/flowgate_parse.go
+++ b/internal/channel/adapter/flowgate_parse.go
@@ -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)
}
diff --git a/internal/channel/adapter/flowgate_parse_test.go b/internal/channel/adapter/flowgate_parse_test.go
index f0d48dc..f9b12d1 100644
--- a/internal/channel/adapter/flowgate_parse_test.go
+++ b/internal/channel/adapter/flowgate_parse_test.go
@@ -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)
+ }
+}
diff --git a/internal/services/channelbridge/connectlog.go b/internal/services/channelbridge/connectlog.go
new file mode 100644
index 0000000..10ed42d
--- /dev/null
+++ b/internal/services/channelbridge/connectlog.go
@@ -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 `)逐个换成 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
+ }
+}
diff --git a/internal/services/channelbridge/connectlog_test.go b/internal/services/channelbridge/connectlog_test.go
new file mode 100644
index 0000000..ca5ebf7
--- /dev/null
+++ b/internal/services/channelbridge/connectlog_test.go
@@ -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 : `):这里没有 *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)
+ }
+}
diff --git a/internal/services/channelbridge/reload.go b/internal/services/channelbridge/reload.go
index 486f66f..121e2d4 100644
--- a/internal/services/channelbridge/reload.go
+++ b/internal/services/channelbridge/reload.go
@@ -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
}