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)。
203 lines
8.0 KiB
Go
203 lines
8.0 KiB
Go
package main
|
||
|
||
// 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口):
|
||
//
|
||
// - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列),
|
||
// **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住,
|
||
// 因此这里照 wire_channels_test.go 的既有做法断言那一行。
|
||
// - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP
|
||
// 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。
|
||
// 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1`
|
||
// 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由
|
||
// `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。
|
||
//
|
||
// 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后
|
||
// **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具
|
||
// (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入
|
||
// 是同一类判据:能力静默消失只能靠测试拦。
|
||
func TestWireRuntime_CallsWireBrowser(t *testing.T) {
|
||
t.Parallel()
|
||
src := readSource(t, "wire_runtime.go")
|
||
if !strings.Contains(src, "\ta.wireBrowser()") {
|
||
t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," +
|
||
"模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)")
|
||
}
|
||
}
|
||
|
||
// TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。
|
||
//
|
||
// 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**:
|
||
// 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器
|
||
// (拉起会得到 ErrLaunch,两者一眼可分)。
|
||
func TestAppClose_ClosesBrowserSession(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t)
|
||
// 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。
|
||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||
t.Fatalf("造假可执行文件: %v", err)
|
||
}
|
||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||
writeBrowserConfig(t, cfg, string(raw))
|
||
var out bytes.Buffer
|
||
a := browserTestApp(t, cfg, &out)
|
||
|
||
a.wireBrowser()
|
||
cap, ok := browserEntry(a)
|
||
if !ok {
|
||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||
}
|
||
tool, ok := cap.Value.(sdk.Tool)
|
||
if !ok {
|
||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||
}
|
||
|
||
a.close(context.Background())
|
||
|
||
if !strings.Contains(out.String(), "browser tool closed") {
|
||
t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String())
|
||
}
|
||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||
t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err)
|
||
}
|
||
}
|
||
|
||
// TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实
|
||
// 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。
|
||
//
|
||
// 用法(Windows / Git Bash):
|
||
//
|
||
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m
|
||
//
|
||
// 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」,
|
||
// 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。
|
||
func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||
if !browserE2EEnabled() {
|
||
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
|
||
}
|
||
exe, ok := browsercfg.NewProber().FindLocalBrowser()
|
||
if !ok {
|
||
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
|
||
}
|
||
cfg := wsLoadConfig(t)
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"executable": exe,
|
||
"headless": true,
|
||
"timeoutSeconds": 60,
|
||
"idleTimeoutSeconds": 600,
|
||
})
|
||
writeBrowserConfig(t, cfg, string(raw))
|
||
var out bytes.Buffer
|
||
a := browserTestApp(t, cfg, &out)
|
||
|
||
a.wireBrowser()
|
||
cap, ok := browserEntry(a)
|
||
if !ok {
|
||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||
}
|
||
tool, ok := cap.Value.(sdk.Tool)
|
||
if !ok {
|
||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||
defer cancel()
|
||
|
||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
|
||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||
t.Fatalf("action=info(起真浏览器): %v", err)
|
||
}
|
||
endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir))
|
||
if !endpointAlive(endpoint) {
|
||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||
}
|
||
t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint)
|
||
|
||
// ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。
|
||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer shutCancel()
|
||
a.close(shutCtx)
|
||
|
||
// ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。
|
||
if !waitEndpointGone(endpoint, 15*time.Second) {
|
||
t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint)
|
||
}
|
||
// ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。
|
||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||
t.Errorf("关闭后 err = %v, want ErrNoSession", err)
|
||
}
|
||
t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)",
|
||
endpoint, tailOfLog(out.String()))
|
||
}
|
||
|
||
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
|
||
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
|
||
func devToolsEndpoint(t *testing.T, profileDir string) string {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
|
||
if err != nil {
|
||
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
|
||
}
|
||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||
if err != nil || port <= 0 {
|
||
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
|
||
}
|
||
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
||
}
|
||
|
||
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
||
func endpointAlive(endpoint string) bool {
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
resp, err := client.Get(endpoint + "/json/version")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
return resp.StatusCode == http.StatusOK
|
||
}
|
||
|
||
// waitEndpointGone 等端点停止应答,最多等 budget。
|
||
func waitEndpointGone(endpoint string, budget time.Duration) bool {
|
||
deadline := time.Now().Add(budget)
|
||
for {
|
||
if !endpointAlive(endpoint) {
|
||
return true
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return false
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。
|
||
func tailOfLog(s string) string {
|
||
const keep = 400
|
||
if len(s) <= keep {
|
||
return s
|
||
}
|
||
return s[len(s)-keep:]
|
||
}
|