Files
Cyrene_v2/internal/config/guardrail_test.go
T
AskaEth 51c1672355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00

69 lines
2.6 KiB
Go
Raw 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 config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// 本文件锁定 GUARDRAIL_MAX_INPUT_CHARS 的配置侧契约(切片 G2):键名一致、三态可解。
// 派生与两条 WARN 的落点在组合根(cmd/cyrene/wire_guardrail.go 的
// deriveGuardrailInputLimit),这里只保证「键真的能配上、值真的能进来」。
// TestGuardrailMaxInputCharsEnvMatchesTag 钉住键名与 env 标签**逐字一致**:
// 组合根的 WARN 用 GuardrailMaxInputCharsEnv 点出「改哪个键」,两处一旦漂移,
// 日志就会把部署方指到不存在的键上。
func TestGuardrailMaxInputCharsEnvMatchesTag(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeOf(GuardrailConfig{}).FieldByName("MaxInputChars")
if !ok {
t.Fatal("GuardrailConfig 里没有 MaxInputChars 字段")
}
if got := field.Tag.Get("env"); got != GuardrailMaxInputCharsEnv {
t.Fatalf("MaxInputChars 的 env 标签 = %q, want %q", got, GuardrailMaxInputCharsEnv)
}
}
// TestLoad_GuardrailMaxInputCharsTriState 钉住三态取值真的能配进来:
// 未设置 ⇒ 0(= 按推导预算派生);进程环境或 .env 给出正数 ⇒ 覆盖值;
// 负数 ⇒ **原样**进来(非法判定在组合根,那里负责记 WARN 并回落,不在这一层报错,
// 免得一个笔误让整个进程起不来)。
func TestLoad_GuardrailMaxInputCharsTriState(t *testing.T) {
dir := t.TempDir()
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
unsetenvForTest(t, GuardrailMaxInputCharsEnv)
cfg, err := Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != 0 {
t.Fatalf("未配置时 = %d, want 0(0 = 按推导预算派生)", cfg.Guardrail.MaxInputChars)
}
// .env 里写了也算配上(与进程环境同一优先级链)。
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(GuardrailMaxInputCharsEnv+"=1000\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != 1000 {
t.Fatalf(".env 配置时 = %d, want 1000", cfg.Guardrail.MaxInputChars)
}
// 负数原样进来:非法值的处理在组合根(WARN + 回落派生值),这一层不拦。
t.Setenv(GuardrailMaxInputCharsEnv, "-1")
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != -1 {
t.Fatalf("负数配置时 = %d, want -1(原样进来,判定在组合根)", cfg.Guardrail.MaxInputChars)
}
}