Files
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

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

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00

160 lines
7.0 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
// 本文件钉住「参与度判定器的 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)
}
}