feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)

X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
This commit is contained in:
AskaEth committed 2026-10-06 21:16:23 +08:00
1 parent 2bf3b536dd
commit 2d211c2d16
20 files changed
+1568 -176

No files matched your search

@@ -0,0 +1,404 @@
package backthink
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件是 design/12 §N C.3 工具面的判据(C.8 判据 7 / 8 与 C.3 的入参契约)。
// recordingWaker 是 Waker 的替身:记下每一次请求,并按脚本回一张收据或一个错误。
// 「Waker 一次都没被调用」正是 C.8 判据 7 的断言点,因此调用记录是它存在的理由。
type recordingWaker struct {
mu sync.Mutex
reqs []SelfWakeRequest
receipt SelfWakeReceipt
err error
}
// WakeNow 记录请求并返回脚本结果(默认零值收据 = 被上限拒绝)。
func (w *recordingWaker) WakeNow(_ context.Context, req SelfWakeRequest) (SelfWakeReceipt, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.reqs = append(w.reqs, req)
if w.err != nil {
return SelfWakeReceipt{}, w.err
}
return w.receipt, nil
}
// calls 返回已记录的请求快照。
func (w *recordingWaker) calls() []SelfWakeRequest {
w.mu.Lock()
defer w.mu.Unlock()
out := make([]SelfWakeRequest, len(w.reqs))
copy(out, w.reqs)
return out
}
// groupCtx 是群聊环境(第二道身份闸必须拒掉它)。stranger 同理,用一个就够。
func groupCtx() context.Context {
return loop.WithSessionEnv(context.Background(),
loop.SessionEnv{ActorEnv: restrict.ActorGroupMember})
}
// okResult 把工具结果断言成 map 并返回(类型不对就 Fatal)。
func okResult(t *testing.T, val any) map[string]any {
t.Helper()
m, ok := val.(map[string]any)
if !ok {
t.Fatalf("工具结果类型 = %T, want map[string]any", val)
}
return m
}
// TestThinkTool_OwnerQueuesAndReportsReceipt 断言主路径:主人环境 + 合法入参 ⇒ 请求原样
// 到达 Waker,收据被原样映射成模型可读的结果(含 ok/queued/coalesced/used/limit/windowSec)。
func TestThinkTool_OwnerQueuesAndReportsReceipt(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}}
tt := NewThinkTool(w)
val, err := tt.Execute(ownerCtx(), map[string]any{
thinkArgReason: "稍后回看她提过的日程",
thinkArgUrgency: "warning",
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
reqs := w.calls()
if len(reqs) != 1 {
t.Fatalf("Waker 被调用 %d 次, want 1", len(reqs))
}
if reqs[0].Reason != "稍后回看她提过的日程" {
t.Fatalf("理由 = %q, want 原样透传", reqs[0].Reason)
}
if reqs[0].Declared != urgency.LevelWarning {
t.Fatalf("声明档位 = %s, want warning", reqs[0].Declared)
}
m := okResult(t, val)
for k, want := range map[string]any{
"ok": true, "queued": true, "coalesced": false, "reason": "",
"used": 1, "limit": 4, "windowSec": 3600,
} {
if got := m[k]; got != want {
t.Fatalf("结果 %s = %#v, want %#v(整张结果:%#v)", k, got, want, m)
}
}
}
// TestThinkTool_MissingUrgencyMeansRoutine 断言省略 urgency 就是例行档(零值),
// 绝不被猜成更高档 —— 「猜错档位」的代价是让一轮思考穿透安静时段。
func TestThinkTool_MissingUrgencyMeansRoutine(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}}
tt := NewThinkTool(w)
if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "只是想再想想"}); err != nil {
t.Fatalf("Execute: %v", err)
}
if got := w.calls()[0].Declared; got != urgency.LevelRoutine {
t.Fatalf("省略 urgency 的档位 = %s, want routine", got)
}
}
// TestThinkTool_GroupMemberRefusedWithoutCallingWaker 是 C.8 判据 7:群聊(非主人)
// 环境必须返回包装后的 ErrSelfWakeNotOwner,且 **Waker 一次都没被调用**。
//
// 变异:删掉 Execute 里的 owner 判定 ⇒ 本用例变红(群聊能自排)。
func TestThinkTool_GroupMemberRefusedWithoutCallingWaker(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}}
tt := NewThinkTool(w)
_, err := tt.Execute(groupCtx(), map[string]any{thinkArgReason: "偷排一轮"})
if !errors.Is(err, ErrSelfWakeNotOwner) {
t.Fatalf("群聊环境的错误 = %v, want 包装后的 ErrSelfWakeNotOwner", err)
}
if len(w.calls()) != 0 {
t.Fatalf("群聊环境触到了 Waker(它必须「不做事」):%+v", w.calls())
}
}
// TestThinkTool_StrangerRefused 断言陌生人环境同样被拒(三种 env 里两个受限档都要拦)。
func TestThinkTool_StrangerRefused(t *testing.T) {
t.Parallel()
w := &recordingWaker{}
tt := NewThinkTool(w)
ctx := loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: restrict.ActorStranger})
if _, err := tt.Execute(ctx, map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeNotOwner) {
t.Fatalf("陌生人环境的错误 = %v, want ErrSelfWakeNotOwner", err)
}
if len(w.calls()) != 0 {
t.Fatalf("陌生人环境触到了 Waker:%+v", w.calls())
}
}
// TestThinkTool_UrgencyIsAClosedSet 是 C.8 判据 8:只有闭集里的**精确字面量**被认,
// 其余(含近义词、前缀、大小写、带尾随空格)一律零值 routine。
//
// 变异:把判定放宽成前缀/子串匹配(例如 strings.HasPrefix(raw, "crit"))⇒
// "critical " / "crit" 会被读成 critical,本用例变红。
func TestThinkTool_UrgencyIsAClosedSet(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want urgency.Level
}{
{"routine", urgency.LevelRoutine},
{"warning", urgency.LevelWarning},
{"critical", urgency.LevelCritical},
{"", urgency.LevelRoutine},
{"urgent", urgency.LevelRoutine},
{"紧急", urgency.LevelRoutine},
{"CRITICAL", urgency.LevelRoutine},
{"critical ", urgency.LevelRoutine},
{"crit", urgency.LevelRoutine},
{"warn", urgency.LevelRoutine},
}
for _, c := range cases {
w := &recordingWaker{}
tt := NewThinkTool(w)
if _, err := tt.Execute(ownerCtx(), map[string]any{
thinkArgReason: "看看", thinkArgUrgency: c.in,
}); err != nil {
t.Fatalf("urgency=%q 时 Execute: %v", c.in, err)
}
if got := w.calls()[0].Declared; got != c.want {
t.Fatalf("urgency=%q ⇒ %s, want %s", c.in, got, c.want)
}
}
}
// TestThinkTool_ReasonRequired 断言理由缺省 / 空白 / 类型不对都是参数错误,且
// **不替她编一个理由**(Waker 不被调用)。
func TestThinkTool_ReasonRequired(t *testing.T) {
t.Parallel()
bad := []struct {
name string
args any
}{
{"缺字段", map[string]any{}},
{"空串", map[string]any{thinkArgReason: ""}},
{"纯空白", map[string]any{thinkArgReason: " \n\t "}},
{"非字符串", map[string]any{thinkArgReason: 42}},
{"整份参数不是对象", "just a string"},
}
for _, c := range bad {
w := &recordingWaker{}
tt := NewThinkTool(w)
_, err := tt.Execute(ownerCtx(), c.args)
if !errors.Is(err, ErrSelfWakeToolArgs) {
t.Fatalf("%s:错误 = %v, want ErrSelfWakeToolArgs", c.name, err)
}
if len(w.calls()) != 0 {
t.Fatalf("%s:Waker 被调用了 %+v(不该发生)", c.name, w.calls())
}
}
}
// TestThinkTool_ReasonTrimmedAndTruncated 断言理由去首尾空白、按 **rune** 截断(不腰斩
// 多字节字符),并把截断如实回报 —— 不静默丢弃。
func TestThinkTool_ReasonTrimmedAndTruncated(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}}
tt := NewThinkTool(w)
long := strings.Repeat("想", selfWakeReasonMaxRunes+50)
val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " " + long + " "})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := w.calls()[0].Reason
if len([]rune(got)) != selfWakeReasonMaxRunes {
t.Fatalf("理由长度 = %d rune, want %d", len([]rune(got)), selfWakeReasonMaxRunes)
}
if !strings.HasPrefix(got, "想") {
t.Fatalf("截断把多字节字符切坏了:%q", got)
}
if m := okResult(t, val); m["reasonTruncated"] != true {
t.Fatalf("截断没有如实回报:%#v", m)
}
// 未超长时首尾空白被去掉,且不报截断。
val2, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " 回看日程 "})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if got := w.calls()[1].Reason; got != "回看日程" {
t.Fatalf("理由 = %q, want 去空白后的 %q", got, "回看日程")
}
if m := okResult(t, val2); m["reasonTruncated"] != false {
t.Fatalf("未截断却报了截断:%#v", m)
}
}
// TestThinkTool_CappedIsABusinessResultNotAnError 断言「被上限拒绝」是**合法业务结果**
// (ok:false、err 为 nil):她要看得见拒绝才会停止连排;把它伪装成 error 或反过来
// 把拒绝伪装成成功,都会让她误判。
func TestThinkTool_CappedIsABusinessResultNotAnError(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{
Accepted: false, Coalesced: false,
Reason: "已达自我唤醒上限(本窗口 4/4,窗口 1h0m0s),本轮未排队",
Used: 4, Limit: 4, WindowSec: 3600,
}}
tt := NewThinkTool(w)
val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "再想一轮"})
if err != nil {
t.Fatalf("被上限拒绝必须是业务结果而不是 error,got %v", err)
}
m := okResult(t, val)
if m["ok"] != false || m["queued"] != false {
t.Fatalf("拒绝的收据映射错了:%#v", m)
}
if m["used"] != 4 || m["limit"] != 4 {
t.Fatalf("拒绝没带上用量口径:%#v", m)
}
if s, _ := m["reason"].(string); !strings.Contains(s, "上限") {
t.Fatalf("拒绝说明没原样带出:%#v", m)
}
}
// TestThinkTool_CoalescedReportsAcceptedButNotQueued 断言合并语义在工具面上可分辨:
// 合并 = ok:true + coalesced:true + queued:false(不额外产生一轮)。
func TestThinkTool_CoalescedReportsAcceptedButNotQueued(t *testing.T) {
t.Parallel()
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Coalesced: true, Used: 2, Limit: 4, WindowSec: 3600}}
tt := NewThinkTool(w)
val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "合并到在队那轮"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := okResult(t, val)
if m["ok"] != true || m["coalesced"] != true || m["queued"] != false {
t.Fatalf("合并的收据映射错了:%#v", m)
}
}
// TestThinkTool_WakerErrorsSurfaceAsErrors 断言服务侧的真实故障(未 Start、未接线)
// 是 error,**不是** ok:true —— 红线:不伪造成功。
func TestThinkTool_WakerErrorsSurfaceAsErrors(t *testing.T) {
t.Parallel()
w := &recordingWaker{err: ErrSelfWakeUnavailable}
tt := NewThinkTool(w)
if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) {
t.Fatalf("未 Start 的错误 = %v, want 包装后的 ErrSelfWakeUnavailable", err)
}
nilWaker := NewThinkTool(nil)
if _, err := nilWaker.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) {
t.Fatalf("未接线 Waker 的错误 = %v, want ErrSelfWakeUnavailable", err)
}
}
// TestThinkTool_ParametersDeclareTheClosedSet 断言工具面把闭集**广告**出去:
// reason 必填、urgency 的 enum 恰是三个档位字面量(与 urgency 词汇同源)。
func TestThinkTool_ParametersDeclareTheClosedSet(t *testing.T) {
t.Parallel()
params, err := NewThinkTool(&recordingWaker{}).Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatalf("properties 类型 = %T", params["properties"])
}
if _, ok := props[thinkArgReason]; !ok {
t.Fatalf("schema 里没有 %q:%#v", thinkArgReason, props)
}
req, ok := params["required"].([]any)
if !ok || len(req) != 1 || req[0] != thinkArgReason {
t.Fatalf("required = %#v, want 只含 %q", params["required"], thinkArgReason)
}
urgencyProp, ok := props[thinkArgUrgency].(map[string]any)
if !ok {
t.Fatalf("%s 属性类型 = %T", thinkArgUrgency, props[thinkArgUrgency])
}
enum, ok := urgencyProp["enum"].([]any)
if !ok {
t.Fatalf("enum 类型 = %T, want []any", urgencyProp["enum"])
}
want := map[string]bool{"routine": true, "warning": true, "critical": true}
if len(enum) != len(want) {
t.Fatalf("enum = %#v, want 恰好三个闭集取值", enum)
}
for _, v := range enum {
s, _ := v.(string)
if !want[s] {
t.Fatalf("enum 里有闭集之外的取值 %#v(want %v)", v, want)
}
}
}
// TestThinkTool_ThroughDispatcher 走**真实分发器**(注册表 → JSON-Schema 校验 →
// 工具执行),证明三件事:
// 1. 注册进注册表后模型真的调得到,且合法入参一路走到 Waker;
// 2. 群聊环境在**执行前**被第二道闸拒掉(Waker 不被触碰);
// 3. 闭集之外的 urgency 被 schema 拦在 Execute 之前(工具面本来就是闭集),
// 因此 Execute 里的闭集兜底只服务直接调用。
func TestThinkTool_ThroughDispatcher(t *testing.T) {
t.Parallel()
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}}
if err := d.Register(NewThinkTool(w)); err != nil {
t.Fatalf("Register: %v", err)
}
cap, err := reg.Get(ThinkToolName)
if err != nil {
t.Fatalf("%s 没进能力注册表: %v", ThinkToolName, err)
}
if cap.Kind != registry.KindTool {
t.Fatalf("能力类型 = %q, want %q", cap.Kind, registry.KindTool)
}
// 策略标记保持零值 = owner-only(design/11 §3 的 fail-closed 默认)。
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Fatalf("%s 的策略必须保持 owner-only,got %+v", ThinkToolName, cap.Policy)
}
val, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{
thinkArgReason: "稍后回看她的日程", thinkArgUrgency: "warning",
})
if err != nil {
t.Fatalf("Invoke(owner): %v", err)
}
if m := okResult(t, val); m["ok"] != true {
t.Fatalf("Invoke(owner) 结果 = %#v", m)
}
if len(w.calls()) != 1 || w.calls()[0].Declared != urgency.LevelWarning {
t.Fatalf("Waker 收到的请求 = %+v", w.calls())
}
if _, err := d.Invoke(groupCtx(), ThinkToolName, map[string]any{thinkArgReason: "偷排"}); !errors.Is(err, ErrSelfWakeNotOwner) {
t.Fatalf("Invoke(group) 错误 = %v, want ErrSelfWakeNotOwner", err)
}
if len(w.calls()) != 1 {
t.Fatalf("群聊调用触到了 Waker:%+v", w.calls())
}
if _, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{
thinkArgReason: "x", thinkArgUrgency: "urgent",
}); err == nil {
t.Fatal("闭集之外的 urgency 必须被 schema 拒绝,而不是静默当成 routine")
}
if len(w.calls()) != 1 {
t.Fatalf("被 schema 拒掉的调用触到了 Waker:%+v", w.calls())
}
}