File size: 15,216 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 | package upstream
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)
const (
ccIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
ccBranch = "Main branch (you will usually use this for PRs)"
ccHeader = "x-anthropic-billing-header: cc_version=1.0; cc_entrypoint=cli;"
// Codex instructions 首段(上游逐字精确指纹,三句缺一不可)。
codexInstructions = "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful."
)
func TestIdentityRewritten(t *testing.T) {
out := sanitizeText(ccIdentity)
if !strings.Contains(out, "official CLI tool for Claude.") {
t.Errorf("identity not rewritten: %q", out)
}
if strings.Contains(out, ccIdentity) {
t.Errorf("original identity still present: %q", out)
}
}
// 桌面版(claude-desktop-3p / Agent SDK)的身份句以逗号接后继内容,结尾不是句号。
// 回归用例:匹配串曾带结尾句号,导致该形态漏网、指纹原样发上游 → 400 code=11128。
func TestIdentityDesktopVariantRewritten(t *testing.T) {
in := "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
out := sanitizeText(in)
if strings.Contains(out, "official CLI for Claude") {
t.Errorf("desktop identity not rewritten: %q", out)
}
if !strings.Contains(out, "official CLI tool for Claude, running within the Claude Agent SDK.") {
t.Errorf("desktop identity suffix not preserved: %q", out)
}
}
func TestBranchRewritten(t *testing.T) {
out := sanitizeText(ccBranch)
if !strings.Contains(out, "Default branch (you will usually use this for PRs)") {
t.Errorf("branch not rewritten: %q", out)
}
if strings.Contains(out, "Main branch") {
t.Errorf("original branch still present: %q", out)
}
}
// 反馈句带 Anthropic 仓库链接,上游按整句拦截(实测只留链接或只留半边均不拦)。
// 回归用例:give→provide 一词之差即可绕过。
func TestFeedbackSentenceRewritten(t *testing.T) {
in := "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues"
out := sanitizeText(in)
if strings.Contains(out, "To give feedback") {
t.Errorf("feedback sentence not rewritten: %q", out)
}
if !strings.Contains(out, "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues") {
t.Errorf("feedback sentence not rewritten as expected: %q", out)
}
}
// 上游反探测:请求体里出现裸数字 11128 即整单拦截(与上下文无关)。
// 回归用例:该串会被改写为 11-128 以打断精确匹配。
func TestUpstreamErrorCodeRewritten(t *testing.T) {
in := "upstream returned code=11128 for this request"
out := sanitizeText(in)
if strings.Contains(out, "11128") {
t.Errorf("error code not rewritten: %q", out)
}
if !strings.Contains(out, "11-128") {
t.Errorf("error code not rewritten as expected: %q", out)
}
}
// 回归:工具调用消息的 content 常为 null,而旧版 sanitizeMessages 在 content 缺失时
// 直接 continue,整条消息连 tool_calls 一起被跳过 → arguments 里的被拦字符串原样漏出。
func TestToolCallArgumentsSanitized(t *testing.T) {
msgs := []any{
map[string]any{"role": "user", "content": "run"},
map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{
map[string]any{"id": "c1", "type": "function", "function": map[string]any{
"name": "Bash",
"arguments": `{"command":"echo 11128"}`,
}},
}},
}
if !sanitizeMessages(msgs) {
t.Fatal("sanitizeMessages 未报告任何改动,tool_calls 被跳过")
}
fn := msgs[1].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any)
got := fn["arguments"].(string)
if strings.Contains(got, "11128") {
t.Errorf("tool_call arguments 未被净化: %q", got)
}
}
func TestBillingHeaderStrippedValueIrrelevant(t *testing.T) {
out := sanitizeText(ccHeader)
if strings.Contains(out, "x-anthropic-billing-header") {
t.Errorf("header not stripped: %q", out)
}
}
// 附加验证:正常对话里出现 github.com/anthropics/ 链接(但不是反馈句整句)
// 时,预检特征命中(进入净化),但改写层只动精确匹配的整句——普通链接文本
// 不该被改写。同理,既不含 11128 也不含反馈整句的文本原样返回。
func TestNormalAnthropicLinkNotRewritten(t *testing.T) {
in := "see https://github.com/anthropics/anthropic-cookbook for examples"
if out := sanitizeText(in); out != in {
t.Errorf("normal anthropic link should be untouched: %q -> %q", in, out)
}
}
// Codex instructions 首段:命中预告且整句改写,逐字指纹被破坏、语义保留。
func TestCodexInstructionsRewritten(t *testing.T) {
out := sanitizeText(codexInstructions)
if strings.Contains(out, codexInstructions) {
t.Errorf("codex fingerprint still present: %q", out)
}
if !strings.Contains(out, "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.") {
t.Errorf("codex first sentence not rewritten: %q", out)
}
// 其余两句原样保留,语义不变。
if !strings.Contains(out, "Codex CLI is an open source project led by OpenAI.") ||
!strings.Contains(out, "You are expected to be precise, safe, and helpful.") {
t.Errorf("codex remaining sentences altered: %q", out)
}
}
// 预检必须能认出 Codex 特征(此前只含 Claude Code,导致提前放行)。
func TestCodexFingerprintDetected(t *testing.T) {
if !hasFingerprint(codexInstructions) {
t.Error("codex fingerprint not detected by precheck")
}
}
// 非精确变体不应被改写:仅去掉其中一个词即视为已破坏,无需改动。
func TestCodexVariantNotTouched(t *testing.T) {
in := "You are a coding agent running in a CLI, a terminal-based coding assistant."
if out := sanitizeText(in); out != in {
t.Errorf("already-broken variant should be untouched: %q -> %q", in, out)
}
}
func TestBillingHeaderCaseInsensitive(t *testing.T) {
alt := "X-Anthropic-Billing-Header: cc_version=1.0;"
out := strings.ToLower(sanitizeText(alt))
if strings.Contains(out, "billing") {
t.Errorf("case-insensitive header not stripped: %q", out)
}
}
func TestTrailingKVStripped(t *testing.T) {
out := sanitizeText("...; cc_version=2.0; cc_entrypoint=cli;")
if strings.Contains(out, "cc_version") || strings.Contains(out, "cc_entrypoint") {
t.Errorf("trailing kv not stripped: %q", out)
}
}
func TestExactMatchOnlyVariantNotTouched(t *testing.T) {
in := "...official CLI for Claude!"
if out := sanitizeText(in); out != in {
t.Errorf("variant should be untouched: %q -> %q", in, out)
}
}
func TestUserFreeTextNotTouched(t *testing.T) {
in := "please use main branch for this repo"
if out := sanitizeText(in); out != in {
t.Errorf("free text should be untouched: %q -> %q", in, out)
}
}
func TestNoFeatureReturnsSameString(t *testing.T) {
in := "ordinary user message"
if out := sanitizeText(in); out != in {
t.Errorf("no-feature text should pass through unchanged: %q -> %q", in, out)
}
}
func TestMultimodalTextPartOnly(t *testing.T) {
imgPart := map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": "..."}}
content := []any{
map[string]any{"type": "text", "text": ccIdentity},
imgPart,
}
out, changed := sanitizeContent(content)
if !changed {
t.Fatal("expected change")
}
parts := out.([]any)
txt, _ := parts[0].(map[string]any)["text"].(string)
if !strings.Contains(txt, "CLI tool") {
t.Errorf("text part not sanitized: %q", txt)
}
img, _ := parts[1].(map[string]any)
if img["type"] != "image" || img["source"].(map[string]any)["data"] != "..." {
t.Error("image part modified")
}
}
// 集成:完整请求体经 PrepareBodyOpt 净化后无残留指纹,且 stream/tool_choice 行为不受影响。
func TestPrepareBodyOptSanitizesSystem(t *testing.T) {
body := []byte(`{"model":"glm-5.2","messages":[` +
`{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` +
`{"role":"user","content":"hi"}]}`)
out := PrepareBodyOpt(body, true)
var obj map[string]any
if err := json.Unmarshal(out, &obj); err != nil {
t.Fatal(err)
}
if obj["stream"] != true {
t.Error("stream not forced")
}
msgs := obj["messages"].([]any)
sys, _ := msgs[0].(map[string]any)["content"].(string)
if strings.Contains(sys, "x-anthropic-billing-header") || strings.Contains(sys, ccIdentity) || strings.Contains(sys, ccBranch) {
t.Errorf("fingerprints remain: %q", sys)
}
if !strings.Contains(sys, "CLI tool") {
t.Errorf("rewrite missing: %q", sys)
}
}
func TestPrepareBodyOptDisabledPreservesFingerprints(t *testing.T) {
body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`)
out := PrepareBodyOpt(body, false)
if !strings.Contains(string(out), ccIdentity) {
t.Error("sanitize=false should preserve fingerprints")
}
// 但 stream 仍强制
var obj map[string]any
_ = json.Unmarshal(out, &obj)
if obj["stream"] != true {
t.Error("stream should still be forced")
}
}
// PrepareBody 默认行为 = 开启脱敏(保持向后兼容)。
func TestPrepareBodyDefaultSanitizes(t *testing.T) {
body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`)
out := PrepareBodyOpt(body, true)
if strings.Contains(string(out), ccIdentity) {
t.Error("PrepareBody default should sanitize")
}
}
// 出站边界集成:ChatStream 发往上游的 wire body 必须无残留指纹。
func TestChatStreamWireBodySanitized(t *testing.T) {
var gotBody []byte
ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
})
defer ts.Close()
c := New()
c.SanitizeFingerprints.Store(true)
c.ChatBaseCN = ts.URL
acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"}
body := []byte(`{"model":"glm-5.2","messages":[` +
`{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` +
`{"role":"user","content":"hi"}]}`)
rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{})
if err != nil {
t.Fatal(err)
}
defer rc.Close()
if status >= 400 {
t.Fatalf("upstream status %d: %s", status, respBody)
}
// 上游收到的 body:stream 强制 + 指纹已净化
var obj map[string]any
if err := json.Unmarshal(gotBody, &obj); err != nil {
t.Fatalf("wire body not json: %v", err)
}
if obj["stream"] != true {
t.Error("wire body stream not forced")
}
sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string)
for _, fp := range []string{"x-anthropic-billing-header", ccIdentity, ccBranch} {
if strings.Contains(sys, fp) {
t.Errorf("wire body contains fingerprint %q: %q", fp, sys)
}
}
}
// 出站边界(Codex 场景):CPA 把 instructions 折成 role=system 的首条消息,
// 净化后 wire body 不得残留 Codex 逐字指纹;其余消息不受影响。
func TestChatStreamWireBodyCodexInstructionsSanitized(t *testing.T) {
var gotBody []byte
ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
})
defer ts.Close()
c := New()
c.SanitizeFingerprints.Store(true)
c.ChatBaseCN = ts.URL
acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"}
body := []byte(`{"model":"kimi-k3","messages":[` +
`{"role":"system","content":"` + codexInstructions + `"},` +
`{"role":"user","content":"say ok"}]}`)
rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{})
if err != nil {
t.Fatal(err)
}
defer rc.Close()
if status >= 400 {
t.Fatalf("upstream status %d: %s", status, respBody)
}
var obj map[string]any
if err := json.Unmarshal(gotBody, &obj); err != nil {
t.Fatalf("wire body not json: %v", err)
}
sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string)
if strings.Contains(sys, codexInstructions) ||
strings.Contains(sys, "in the Codex CLI, a terminal-based coding assistant.") {
t.Errorf("wire body still contains codex fingerprint: %q", sys)
}
if !strings.Contains(sys, "running in the Codex CLI tool, a terminal-based") {
t.Errorf("codex rewrite missing on wire: %q", sys)
}
user, _ := obj["messages"].([]any)[1].(map[string]any)["content"].(string)
if user != "say ok" {
t.Errorf("user message altered: %q", user)
}
}
// 出站边界:关闭脱敏后 wire body 原样保留指纹(验证开关真实有效)。
func TestChatStreamWireBodySanitizeDisabled(t *testing.T) {
var gotBody []byte
ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: [DONE]\n\n"))
})
defer ts.Close()
c := New()
c.SanitizeFingerprints.Store(false)
c.ChatBaseCN = ts.URL
acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"}
body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`)
rc, status, _, err := c.ChatStream(acct, body, "", ChatMeta{})
if err != nil {
t.Fatal(err)
}
defer rc.Close()
if status >= 400 {
t.Fatalf("upstream status %d", status)
}
if !strings.Contains(string(gotBody), ccIdentity) {
t.Error("sanitize disabled should preserve fingerprint on wire")
}
}
// TestSanitizeMessagesScrubsReasoningField thinking.go 的回填会把客户端送来的
// reasoning_content 镜像进 reasoning 字段;此前只洗 content / reasoning_content /
// tool_calls → 镜像进 reasoning 的指纹(裸 "11128" 这类反探测串)原样出站,
// 而请求体里出现裸 11128 本身就是上游整单拦截条件。
func TestSanitizeMessagesScrubsReasoningField(t *testing.T) {
ms := []any{map[string]any{
"role": "assistant",
"content": "hi",
"reasoning": "upstream said 11128",
}}
if !sanitizeMessages(ms) {
t.Fatal("reasoning 字段里的指纹未被净化")
}
got, _ := ms[0].(map[string]any)["reasoning"].(string)
if strings.Contains(got, "11128") {
t.Fatalf("reasoning 仍含裸指纹: %q", got)
}
}
// newTestUpstream 起一个假上游并捕获请求。
func newTestUpstream(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(h)
}
|