Files
Cyrene_v2/cmd/cyrene/persona_identity_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

378 lines
14 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 main
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定 P0-10 的**收窄**(design/11 §0 红线):
//
// - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按
// **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字
// 都没有(因此也不会随请求发给模型供应商);
// - 它不再进静态 system 指令(那会让它跟着角色进入每个会话);
// - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。
// identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」,
// 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。
const identityFixture = "# 身份说明书 · 我的「人」\n\n" +
"- 称呼:你叫他「亲爱的」。\n" +
"- 陌生账号 kook:stranger-999 与本文件无关。\n" +
"- 本说明书只描述人设/关系,不授权任何权限。"
// identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。
type identityProbeAdapter struct{ reqs []model.Request }
func (a *identityProbeAdapter) Name() string { return "identity-probe" }
func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
// identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的
// owner 档案。identityFile 为空串表示「没配」。
func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) {
t.Helper()
return identityAppLogged(t, identityFile, testLog())
}
// identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。
func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) {
t.Helper()
p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
if err != nil {
t.Fatalf("loader.Load(cyrene.yaml): %v", err)
}
reg := personareg.New()
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
owner := &user.OwnerProfile{
ID: "owner",
DisplayName: "叶酱",
DefaultPersona: p.ID,
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}},
IdentityFile: identityFile,
}
a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}}
return a, p
}
// writeIdentity 把 identityFixture 写进临时文件并返回路径。
func writeIdentity(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "owner_identity.md")
if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil {
t.Fatalf("write identity file: %v", err)
}
return path
}
// absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。
func absentIdentityEnvFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "absent.env")
}
// unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env
// 里的取值),并在测试结束还原。
func unsetInjectionEnv(t *testing.T) {
t.Helper()
key := config.OwnerIdentityInjectionEnv
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("os.Unsetenv(%s): %v", key, err)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, prev)
return
}
_ = os.Unsetenv(key)
})
}
// envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致
// (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。
func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() }
func envGroupTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func envStrangerTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环,
// 返回模型实际收到的消息列表。
func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message {
t.Helper()
adapter := &identityProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{Agent: ag, PersonaKnowledgeFor: resolve, MaxRounds: 8})
if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess",
[]model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("适配器没有收到任何请求")
}
return adapter.reqs[0].Messages
}
// identityMessagesContain 报告消息里是否有任一携带给定文本。
func identityMessagesContain(messages []model.Message, want string) bool {
for _, m := range messages {
for _, part := range m.Content {
if strings.Contains(part.Text, want) {
return true
}
}
}
return false
}
// TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的
// 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池
// 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面
// (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。
func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
t.Parallel()
a, p := identityApp(t, writeIdentity(t))
base := instructionsFromPersona(p)
if got := a.defaultInstructions(); got != base {
t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base)
}
if pooled := a.personaInstructions(p); pooled != base {
t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base)
}
if strings.Contains(a.defaultInstructions(), identityFixture) {
t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话")
}
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
if ag.Instructions() != base {
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
}
}
}
// TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件
// 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也
// 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。
func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
missing := filepath.Join(t.TempDir(), "nope.md")
for _, tc := range []struct {
name string
path string
}{
{name: "文件不存在", path: missing},
{name: "路径是目录", path: t.TempDir()},
{name: "没配 identityFile", path: ""},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a, p := identityApp(t, tc.path)
if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want {
t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want)
}
if a.ownerIdentityKnowledge() != "" {
t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge())
}
if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" {
t.Fatalf("没有正文时不该渲染出任何块:%q", block)
}
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envOwnerTurn()); got != "" {
t.Fatalf("没有正文时主人会话也被注入了:%q", got)
}
})
}
}
// TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心:
// 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到
// 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。
func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
owner := resolve(envOwnerTurn())
if owner == "" || !strings.Contains(owner, identityFixture) {
t.Fatalf("主人会话没有拿到身份说明书:%q", owner)
}
if !strings.HasPrefix(owner, identityKnowledgeHeader) {
t.Fatalf("主人会话的知识块没有段头:%q", owner)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
if got := resolve(tc.env); got != "" {
t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got)
}
}
}
// TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效:
// always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。
func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) {
cases := []struct {
name string
mode string
env loop.SessionEnv
wantContent bool
}{
{name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true},
{name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true},
{name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false},
{name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode)
a, _ := identityApp(t, writeIdentity(t))
got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env)
if tc.wantContent != strings.Contains(got, identityFixture) {
t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent)
}
})
}
}
// TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」:
// 回退到最安全的 owner_only,且日志里带上原始取值与回退档。
func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, "everyone")
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
a, _ := identityAppLogged(t, writeIdentity(t), log)
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envGroupTurn()); got != "" {
t.Fatalf("非法取值下群聊被注入了:%q", got)
}
if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) {
t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got)
}
logged := buf.String()
for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} {
if !strings.Contains(logged, want) {
t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged)
}
}
if strings.Contains(logged, identityFixture) {
t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)")
}
}
// TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文
// 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与
// 陌生人私聊的每条消息都不含它。
func TestOwnerIdentityInjection_EndToEnd(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn())
if !identityMessagesContain(ownerMsgs, identityFixture) {
t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs)
}
if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) {
t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
msgs := runIdentityTurn(t, resolve, tc.env)
if identityMessagesContain(msgs, identityFixture) {
t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs)
}
if identityMessagesContain(msgs, identityKnowledgeHeader) {
t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs)
}
}
}
// TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真
// 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据
// + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。
func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) {
t.Parallel()
a, _ := identityApp(t, writeIdentity(t))
// 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(block, "stranger-999") {
t.Fatalf("测试前提不成立:说明书正文没被读出来")
}
if !a.owner.IsOwner("kook", "owner-1") {
t.Fatal("白名单里的账号未被认成主人")
}
if a.owner.IsOwner("kook", "stranger-999") {
t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右")
}
if a.owner.IsOwner("kook", "") {
t.Fatal("空账号被判成了主人")
}
}