From fe3872ee2848697d5c3ee44d97e5b571bc16b126 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 19:13:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(trace):=20=E4=BB=A4=E7=89=8C=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E4=B8=8A=E7=A7=BB=E8=BF=9B=20trace.UpstreamError?= =?UTF-8?q?=EF=BC=8C=E6=9E=84=E9=80=A0=E5=8D=B3=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜, 因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 —— 任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。 - trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。 - 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫), 供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再 各抄一份 ErrorToken。 - 删除因此冗余的调用方守卫:model.providerUpstreamError 与 voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type, 构造函数看不见)。 - 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时 四条用例 + model/voice 旧用例同时变红。 --- .../adapter/httpclient/upstream_token_test.go | 56 +++++++++++ .../runtime/embedding/upstream_token_test.go | 57 +++++++++++ internal/runtime/model/upstream.go | 20 ++-- .../multimodal/vision/upstream_token_test.go | 57 +++++++++++ .../multimodal/voice/dashscope_error.go | 13 ++- .../multimodal/voice/upstream_token_test.go | 41 ++++++++ internal/runtime/trace/errortoken.go | 6 ++ internal/runtime/trace/upstream.go | 40 +++++++- .../trace/upstream_token_guard_test.go | 96 +++++++++++++++++++ .../weixin_ilink/upstream_token_test.go | 50 ++++++++++ 10 files changed, 419 insertions(+), 17 deletions(-) create mode 100644 internal/channel/adapter/httpclient/upstream_token_test.go create mode 100644 internal/runtime/embedding/upstream_token_test.go create mode 100644 internal/runtime/multimodal/vision/upstream_token_test.go create mode 100644 internal/runtime/multimodal/voice/upstream_token_test.go create mode 100644 internal/runtime/trace/upstream_token_guard_test.go create mode 100644 plugins/channels/weixin_ilink/upstream_token_test.go diff --git a/internal/channel/adapter/httpclient/upstream_token_test.go b/internal/channel/adapter/httpclient/upstream_token_test.go new file mode 100644 index 0000000..8e1b858 --- /dev/null +++ b/internal/channel/adapter/httpclient/upstream_token_test.go @@ -0,0 +1,56 @@ +package httpclient + +// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后, +// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时, +// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。 +// +// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**, +// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认 +// 不安全」,所以这一条故意不去调用点补。 + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type +// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。 +func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger())) + _, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1}) + if err == nil { + t.Fatal("502 却报成功") + } + if !errors.Is(err, ErrAPI) { + t.Fatalf("err = %v, want ErrAPI", err) + } + + var logBuf bytes.Buffer + slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err) + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, "她刚才说的那句话") { + t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface) + } + } + // 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。 + for _, want := range []string{"status=502", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("错误里缺少 %q:%v", want, err) + } + } +} diff --git a/internal/runtime/embedding/upstream_token_test.go b/internal/runtime/embedding/upstream_token_test.go new file mode 100644 index 0000000..4acebb1 --- /dev/null +++ b/internal/runtime/embedding/upstream_token_test.go @@ -0,0 +1,57 @@ +package embedding + +// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后, +// embedding 这一处一行没改也应当自动安全 —— 兼容层把散文塞进 error.code / +// error.type 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。 +// +// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。 +// 这一处最敏感:/embeddings 的 input 就是她的记忆正文,回显它的散文不能进日志。 + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestAPIEmbedder_Non200DropsFreeTextErrorCode 400 报文的 error.code / error.type +// 里带散文 ⇒ 两个字段整体消失,而状态码与体积摘要仍在。 +func TestAPIEmbedder_Non200DropsFreeTextErrorCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}` + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + emb := NewAPI( + config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"}, + WithRoundTripper(rt), + ) + + _, err := emb.Embed(context.Background(), []string{"hi"}) + if err == nil { + t.Fatal("400 却报成功") + } + + var logBuf bytes.Buffer + slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("embed call failed", "err", err) + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, "她刚才说的那句话") { + t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface) + } + } + for _, want := range []string{"status=400", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("错误里缺少 %q:%v", want, err) + } + } +} diff --git a/internal/runtime/model/upstream.go b/internal/runtime/model/upstream.go index b45bc01..db46caf 100644 --- a/internal/runtime/model/upstream.go +++ b/internal/runtime/model/upstream.go @@ -40,10 +40,10 @@ func upstreamLabel(name, baseURL string) string { // 而这类 error 会被 `chat run` / `loop step` 打日志。截断到 512 字节只是把 // 「整段泄漏」变成「前 512 字节泄漏」,不是修复。 // -// error.code 与 error.type 也要过令牌守卫(trace.ErrorToken):它们在主流上游是枚举, -// 但网关、代理与兼容层可以往里面塞任何东西 —— 包括回显的输入。不像令牌的字段一律变空 -// 串,代价只是少一条分类提示;status / body 摘要等诊断面不受影响。两个字段用同一道 -// 守卫(不按字段分别处理),是因为「同一份上游报文,两个出口口径不一致」正是下发漏点。 +// error.code 与 error.type 的令牌守卫(trace.ErrorToken)**不在这里**:它已上移进 +// trace.UpstreamError 的构造函数(M49),上游报文里不像令牌的字段在那里就变空串。 +// 调用点再守一遍是 M46 留下的重复 —— 「同一件事守两遍」的下一步永远是两处口径分叉, +// 所以这里删掉,只保留「构造即安全」这一处真源。 // // 双 %w:既保持 errors.Is(err, ErrUpstream)(降级链按它分类,主 → 备 → 本地), // 也让 errors.Is(err, trace.ErrUpstream) 成立(调用方按上游失败分类)。 @@ -55,8 +55,6 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error { // helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。 return fmt.Errorf("model: %w: status %d", ErrUpstream, resp.StatusCode) } - info.Code = trace.ErrorToken(info.Code) - info.Type = trace.ErrorToken(info.Type) return fmt.Errorf("model: %w: %w", ErrUpstream, info) } @@ -65,14 +63,18 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error { // // evt.Message 是上游自由文本,Anthropic 会用它回显请求内容(例如「prompt is too // long: <片段>」),所以**整段丢弃**(原本是无上限拼进 error)。只保留 evt.Type -// (协议枚举,如 overloaded_error)且必须像令牌才出镜:网关把散文塞进 type 时它 -// 是空串 —— 宁可少一条分类提示,不让自由文本借道 type 出去。 +// (协议枚举,如 overloaded_error)。 +// +// 🔴 这里的类型是**流内事件**,报文不在 trace.UpstreamError 的视野里(HTTP 已 200, +// body 为空),所以它属于「构造后注入」这一族,必须走 SetType(与构造函数同一道 +// 令牌守卫):网关把散文塞进 type 时它是空串 —— 宁可少一条分类提示,不让自由文本 +// 借道 type 出去。 func streamEventError(upstream string, evt *anthropicStreamError) error { info, ok := trace.UpstreamError(upstream, 0, "text/event-stream", nil).(*trace.UpstreamErrorInfo) if !ok { // helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。 return fmt.Errorf("model: %w: anthropic stream error event", ErrUpstream) } - info.Type = trace.ErrorToken(evt.Type) + info.SetType(evt.Type) return fmt.Errorf("model: %w: %w", ErrUpstream, info) } diff --git a/internal/runtime/multimodal/vision/upstream_token_test.go b/internal/runtime/multimodal/vision/upstream_token_test.go new file mode 100644 index 0000000..c9ca35e --- /dev/null +++ b/internal/runtime/multimodal/vision/upstream_token_test.go @@ -0,0 +1,57 @@ +package vision + +// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后, +// 本适配器一行没改也应当自动安全 —— 视觉上游把散文塞进 error.code / error.type +// 时,错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。 +// +// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。 +// 视觉这一处尤其不能漏 —— 送上游的图像可能是她的照片,回显它的散文同样不能进日志。 + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "testing" +) + +// TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode 4xx 报文的 error.code / +// error.type 里带散文 ⇒ 两个字段整体消失,status 与体积摘要仍在。 +func TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}` + + ad := newAdapter("https://vision.example.com") + ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + })} + + _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "") + if err == nil { + t.Fatal("400 却报成功") + } + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + + var logBuf bytes.Buffer + slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("vision call failed", "err", err) + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, "她刚才说的那句话") { + t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface) + } + } + for _, want := range []string{"status=400", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("错误里缺少 %q:%v", want, err) + } + } +} diff --git a/internal/runtime/multimodal/voice/dashscope_error.go b/internal/runtime/multimodal/voice/dashscope_error.go index 1b50270..c901705 100644 --- a/internal/runtime/multimodal/voice/dashscope_error.go +++ b/internal/runtime/multimodal/voice/dashscope_error.go @@ -41,7 +41,13 @@ func classifyDashscopeStatus(endpoint string, status int, contentType string, bo // helper 的构造是固定的,走不到这里;真走到也只少一条诊断。 return fmt.Errorf("voice: %w", sentinel) } - info.Code = trace.ErrorToken(fields.code) + // 🔴 DashScope 把错误码放在**顶层** code 上(不是 error.code),构造函数看不见它, + // 因此这里属于「构造后注入」:走 SetCode(同一道令牌守卫)。这不是重复守卫 —— + // 去掉了它,散文形态的顶层 code 就会直接出镜(TestDashscopeTTS_Non200DropsFreeTextCode + // 会变红)。 + info.SetCode(fields.code) + // request_id 不进 UpstreamErrorInfo,也不由构造函数解析:它是被**拼进错误文本**的 + // 第三个自由文本面,所以它的守卫只能留在这里(这不是「同一件事守两遍」)。 if rid := trace.ErrorToken(fields.requestID); rid != "" { return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info) } @@ -98,15 +104,14 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields { // message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考 // 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。 // 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致); -// 确实带回来的 code/type 也只以令牌形态出镜(过 trace.ErrorToken)。 +// 确实带回来的 code/type 由 trace.UpstreamError **构造时**过令牌守卫(M49 上移), +// 这里不再守第二遍。 func dashscopeOKBodyError(endpoint string, body []byte) error { info, ok := trace.UpstreamError(trace.HostOf(endpoint), http.StatusOK, "", body).(*trace.UpstreamErrorInfo) if !ok { // helper 的构造是固定的,走不到这里;真走到也只少一条诊断。 return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response", ErrProviderFailed) } - info.Code = trace.ErrorToken(info.Code) - info.Type = trace.ErrorToken(info.Type) return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w", ErrProviderFailed, info) } diff --git a/internal/runtime/multimodal/voice/upstream_token_test.go b/internal/runtime/multimodal/voice/upstream_token_test.go new file mode 100644 index 0000000..780da0e --- /dev/null +++ b/internal/runtime/multimodal/voice/upstream_token_test.go @@ -0,0 +1,41 @@ +package voice + +// 本文件是 M49 的**出口回归探针**:DashScope 的「HTTP 200 但业务失败」收口里, +// error.code / error.type 的守卫已经上移进 trace.UpstreamError —— 本包那处调用点的 +// 重复守卫已删除,因此这里的断言直接钉在**构造函数**上(摘掉构造函数的守卫就变红)。 +// +// 与它相邻的一条(TestDashscopeTTS_Non200DropsFreeTextCode)钉的是另一件事: +// DashScope 的 4xx 把错误码放在**顶层** code(构造函数看不见),那一处靠 +// UpstreamErrorInfo.SetCode 注入,守卫仍在。两条合起来覆盖这一族的两个面。 + +import ( + "errors" + "strings" + "testing" +) + +// TestDashscopeOKBodyError_DropsFreeTextErrorCode 200 报文的 error.code / error.type +// 里带散文(TTS 场景下可能是她要念的台词被回显)⇒ 两个字段整体消失,而 status=200 +// 与 body_bytes(体积摘要)仍在。 +func TestDashscopeOKBodyError_DropsFreeTextErrorCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`) + + err := dashscopeOKBodyError("https://dashscope.example.com/api/v1/services/audio/tts", body) + if !errors.Is(err, ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err) + } + msg := err.Error() + if strings.Contains(msg, "她刚才说的那句话") { + t.Fatalf("上游 error.code/type 里的散文进了错误:%v", msg) + } + if strings.Contains(msg, " code=") || strings.Contains(msg, " type=") { + t.Fatalf("非令牌形状的 code/type 没有被丢弃:%v", msg) + } + for _, want := range []string{"status=200", "body_bytes="} { + if !strings.Contains(msg, want) { + t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg) + } + } +} diff --git a/internal/runtime/trace/errortoken.go b/internal/runtime/trace/errortoken.go index f6e37a9..59da7f3 100644 --- a/internal/runtime/trace/errortoken.go +++ b/internal/runtime/trace/errortoken.go @@ -12,6 +12,12 @@ import "strings" // 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。这道守卫 // 原先在 runtime/model(safeErrorToken)与 runtime/multimodal/voice // (dashscopeErrorToken)各有一份逐字同形的实现,M46 把同一份收敛到这里。 +// +// 🔴 M49 收口:守卫的**应用点**同样收进本包 —— UpstreamError 构造时对 error.code / +// error.type 各调一次(构造即安全,调用点不必也不该再守),构造后的注入走 +// UpstreamErrorInfo.SetCode / SetType(同一道守卫)。因此本包之外的 Go 代码里 +// **不应出现 ErrorToken 调用**:出现了就意味着又回到「调用方逐点补」的漂移形态 +// (M48 复审的 finding 正是这么漏的:httpclient / vision / embedding / weixin 四处)。 // maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。 // 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。 diff --git a/internal/runtime/trace/upstream.go b/internal/runtime/trace/upstream.go index f8150ab..e36b9f7 100644 --- a/internal/runtime/trace/upstream.go +++ b/internal/runtime/trace/upstream.go @@ -18,6 +18,12 @@ import ( // JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式 // opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到 // maxUpstreamMessageRunes; +// - 🔴 **code / type 构造时就过令牌守卫**(ErrorToken):只有像协议令牌的短串才 +// 出镜,散文一律变空串。守卫**在本构造函数里**,不在调用点 —— 因此任何新调用点 +// **默认就是安全的**。M46 曾把它加在调用方(model / voice 各一份),M48 复审的 +// 中危 finding 证明那正是漂移形态:调用方逐点补,四条出口(httpclient / vision / +// embedding / weixin)就一直漏。M49 把守卫上移到这里,并把「构造后再注入」这一 +// 条窄路收进 SetCode / SetType(同样守卫)。 // - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的 // param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志; // - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段, @@ -63,9 +69,12 @@ type UpstreamErrorInfo struct { // BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能 // 认出来,正文拿不到)。 BodySHA256 string - // Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。 + // Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过 + // 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期 + // 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」 + // 推断「上游没给码」(两者在构造后不可区分)。 Code string - // Type 是上游 JSON 里 error.type。 + // Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。 Type string // Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage) // 才非空**:那一刻它已压平空白、清洗凭据形状并截断到 @@ -182,6 +191,12 @@ func WithUpstreamMessage() UpstreamOption { // 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256 // 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点 // 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。 +// +// 🔴 同样默认的还有 **code / type 的令牌守卫**:它们不是「可选加固」,而是本构造函数 +// 的固定行为 —— 报文里的 error.code / error.type 只有像协议令牌(≤64 字节、仅 +// [A-Za-z0-9_.-])才出镜,其余变空串。调用方**不需要**、也**不要**再守一遍:守卫 +// 加在调用点意味着新调用点默认不安全(M46 → M49 的教训)。构造后要注入这两个字段 +// 时走 SetCode / SetType,别直接赋值。 func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error { var cfg upstreamOptions for _, opt := range opts { @@ -199,8 +214,10 @@ func UpstreamError(upstream string, status int, contentType string, body []byte, info.BodySHA256 = DigestOf(string(body)).SHA256 } if parsed, ok := parseUpstreamErrorBody(body); ok { - info.Code = parsed.Code - info.Type = parsed.Type + // 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该 + // 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。 + info.Code = ErrorToken(parsed.Code) + info.Type = ErrorToken(parsed.Type) if cfg.keepMessage { info.Message = sanitizeUpstreamMessage(parsed.Message) } @@ -208,6 +225,21 @@ func UpstreamError(upstream string, status int, contentType string, body []byte, return info } +// SetCode 是**构造之后**改写 Code 的唯一合法通道:它过的是与构造函数同一道令牌守卫 +// (ErrorToken),所以「写入即安全」。 +// +// 为什么还需要这条窄路:有些上游把错误码放在**顶层** code(DashScope 的 4xx 报文)、 +// 或者把失败放进一个已经 200 的**流内事件**里(Anthropic SSE 的 error.type)—— +// 这两种报文都不在 UpstreamError 参数的视野里,只能构造后注入。注入点也是守卫点, +// 但守卫代码留在本包:调用点只写 `info.SetCode(raw)`,不再各抄一份 ErrorToken +// (M46 的两份实现就是这么漂移出来的)。 +// +// 语义是**覆盖**:注入的值不像令牌时 Code 变空串,而不是保留构造函数解出来的旧值。 +func (e *UpstreamErrorInfo) SetCode(code string) { e.Code = ErrorToken(code) } + +// SetType 是 Type 版的 SetCode,口径逐字相同。 +func (e *UpstreamErrorInfo) SetType(typ string) { e.Type = ErrorToken(typ) } + // upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段, // 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。 type upstreamErrorBody struct { diff --git a/internal/runtime/trace/upstream_token_guard_test.go b/internal/runtime/trace/upstream_token_guard_test.go new file mode 100644 index 0000000..325394a --- /dev/null +++ b/internal/runtime/trace/upstream_token_guard_test.go @@ -0,0 +1,96 @@ +package trace + +// 本文件是 M49 的核心断言:**守卫在构造函数里**。 +// +// M46 把令牌守卫加在调用方(model / voice 各一份),M48 复审的中危 finding 证明那是 +// 漂移形态 —— 构造函数自己不守,任何新调用点默认就不安全,四条出口(httpclient / +// vision / embedding / weixin)恰恰一直漏着。这里把断言钉在 UpstreamError 与 +// SetCode / SetType 上:摘掉构造函数里的守卫,本文件与四条出口的用例同时变红。 +// +// 每条都是「把它放宽就变红」的形状:断言的是真正进日志的两个出口(Error() / Attrs())。 + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" +) + +// TestUpstreamError_ConstructorDropsFreeTextCodeAndType 报文里 error.code 与 +// error.type 都是回显的散文 ⇒ 两个字段在构造时就变空串,而 status / body_bytes / +// body_sha256 仍在(诊断面不能一起丢)。 +func TestUpstreamError_ConstructorDropsFreeTextCodeAndType(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`) + + err := UpstreamError("api.example.com", 400, "application/json", body) + info, ok := err.(*UpstreamErrorInfo) + if !ok { + t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err) + } + if info.Code != "" || info.Type != "" { + t.Fatalf("info.Code/Type = %q/%q,want 空串(构造即安全)", info.Code, info.Type) + } + + var logBuf bytes.Buffer + slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err) + for _, surface := range []string{err.Error(), logBuf.String(), strings.Join(anyToStrings(info.Attrs()), " ")} { + if strings.Contains(surface, "她刚才说的那句话") { + t.Fatalf("散文经 code/type 进了出口:%s", surface) + } + if strings.Contains(surface, " code=") || strings.Contains(surface, " type=") { + t.Fatalf("非令牌形状的 code/type 没有被丢弃:%s", surface) + } + } + for _, want := range []string{"status=400", "body_bytes=", "body_sha256=", "api.example.com"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q,want 含 %q(诊断线索不能一起丢)", err.Error(), want) + } + } + if !errors.Is(err, ErrUpstream) { + t.Fatal("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断") + } +} + +// TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType 真实的协议枚举必须原样 +// 通过:丢了它们只是少一条分类提示,但那是**有价值的**提示,别顺手一起丢。 +func TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType(t *testing.T) { + t.Parallel() + body := []byte(`{"error":{"code":"invalid_request_error","type":"Throttling.RateQuota"}}`) + err := UpstreamError("api.example.com", 429, "application/json", body) + info := err.(*UpstreamErrorInfo) + if info.Code != "invalid_request_error" || info.Type != "Throttling.RateQuota" { + t.Fatalf("info.Code/Type = %q/%q,want 原值(令牌形状必须保留)", info.Code, info.Type) + } + for _, want := range []string{"code=invalid_request_error", "type=Throttling.RateQuota"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q,want 含 %q", err.Error(), want) + } + } +} + +// TestUpstreamError_SetCodeGuardsPostConstructionInjection 构造后注入这条窄路也必须 +// 是守卫点:SetCode / SetType 是唯一合法通道,散文进去 = 空串出来(覆盖语义)。 +func TestUpstreamError_SetCodeGuardsPostConstructionInjection(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + info := UpstreamError("dashscope.example.com", 400, "application/json", nil).(*UpstreamErrorInfo) + + info.SetCode("Throttling.RateQuota") + info.SetType("rate-limit") + if info.Code != "Throttling.RateQuota" || info.Type != "rate-limit" { + t.Fatalf("info.Code/Type = %q/%q,want 令牌原值", info.Code, info.Type) + } + + // 注入不受控的散文:两个字段都变空串(而不是保留上一次的值)。 + info.SetCode(prose) + info.SetType(prose) + if info.Code != "" || info.Type != "" { + t.Fatalf("info.Code/Type = %q/%q,want 空串(SetXxx 必须过同一道守卫)", info.Code, info.Type) + } + if text := info.Error(); strings.Contains(text, "她刚才说的那句话") { + t.Fatalf("SetCode/SetType 让散文进了错误文本:%s", text) + } +} diff --git a/plugins/channels/weixin_ilink/upstream_token_test.go b/plugins/channels/weixin_ilink/upstream_token_test.go new file mode 100644 index 0000000..89d2ba4 --- /dev/null +++ b/plugins/channels/weixin_ilink/upstream_token_test.go @@ -0,0 +1,50 @@ +package weixin_ilink + +// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后, +// iLink 适配器一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type +// 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。 +// +// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。 +// iLink 会在 errmsg 里回显请求内容(发出去的就是她说的话),所以这一条不能漏。 + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / +// error.type 里带散文 ⇒ 两个字段整体消失,状态码与体积摘要仍在。 +func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)) + })) + defer srv.Close() + + ad := newAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) + err := ad.doJSON(context.Background(), http.MethodPost, ad.apiURL("/ilink/bot/sendmessage"), nil, nil) + if err == nil { + t.Fatal("400 却报成功") + } + + var logBuf bytes.Buffer + slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("ilink call failed", "err", err) + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, "她刚才说的那句话") { + t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface) + } + } + for _, want := range []string{"status=400", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("错误里缺少 %q:%v", want, err) + } + } +}