feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)

- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
  大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
  + 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
   wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
This commit is contained in:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+113 -58
View File
@@ -27,6 +27,9 @@ package main
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
//
// 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的
// 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。
import (
"context"
@@ -62,6 +65,88 @@ const (
browserNotRegisteredEffect = "the model will not see a browser tool at all"
)
// browserBuild 是「按当前配置把 browser 工具建出来」的结论(成功与失败共用一个形状,
// 使每一条失败路径都只能经**同一个出口**报原因,不会在两条分支里各写各的文案)。
type browserBuild struct {
// tool / mgr 是成功时的产物(失败时两者皆 nil)。
tool *browsertools.Tool
mgr *browsercap.Manager
// reason 失败时非空:分类原因码(`browsercfg.Reason*` 或本文件的 browserReason*)。
reason string
// detail 是给人看的一句话(失败原因细节;不含凭据、不含图片内容)。
detail string
// configPath / configSource 是本次生效配置的来路(进日志,便于对照面板)。
configPath string
configSource string
// source / headless 是探测结论(只进日志)。
source string
headless bool
// available 报告这次是否真的建出了可用的工具。
available bool
}
// buildBrowserTool 走「读文件 → 探测可用性 → profile 守卫 → 建管理器与工具」这四步。
//
// carry 是「要沿用的截图附件箱」:面板保存触发热重载时,重建前的待送截图不能丢
// (模型刚拍完还没送出去的那一张)。为 nil 时工具自带一个新箱子。
func (a *app) buildBrowserTool(carry *browsertools.ShotOutbox) browserBuild {
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
return browserBuild{
reason: browserReasonInvalidConfig,
detail: err.Error(),
configPath: configPath,
}
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
return browserBuild{
reason: string(avail.Reason),
detail: avail.Detail,
configPath: configPath,
configSource: configSource,
}
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
return browserBuild{
reason: browserReasonProfileRejected,
detail: err.Error(),
configPath: configPath,
configSource: configSource,
}
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
opts := []browsertools.Option{browsertools.WithLogger(a.log)}
if carry != nil {
opts = append(opts, browsertools.WithShotOutbox(carry))
}
return browserBuild{
tool: browsertools.New(mgr, opts...),
mgr: mgr,
configPath: configPath,
configSource: configSource,
source: string(avail.Source),
headless: avail.Headless,
available: true,
}
}
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
// (记分类原因 + 不注册),绝不阻塞启动。
func (a *app) wireBrowser() {
@@ -73,66 +158,36 @@ func (a *app) wireBrowser() {
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
return // 同一 app 上重复装配:已经注册过,不重复探测
}
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonInvalidConfig, "configPath", configPath, "error", err,
"effect", browserNotRegisteredEffect)
build, ok := a.registerBrowserBuild(log, nil)
if !ok {
return
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) {
return
}
}
// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。
// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserTool(
log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config,
configPath, configSource, dataDir string,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
log.Warn("browser tool not registered",
"reason", string(avail.Reason), "detail", avail.Detail,
"configPath", configPath, "configSource", configSource,
"effect", browserNotRegisteredEffect)
return false
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir,
"error", err, "effect", browserNotRegisteredEffect)
return false
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
tool := browsertools.New(mgr, browsertools.WithLogger(a.log))
if err := a.caps.RegisterTool(tool); err != nil {
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return false
}
log.Info("browser tool registered",
"source", string(avail.Source), "headless", avail.Headless,
"configSource", configSource, "configPath", configPath,
"shotsDir", browsercfg.DefaultShotsDir(dataDir),
"source", build.source, "headless", build.headless,
"configSource", build.configSource, "configPath", build.configPath,
"shotsDir", browsercfg.DefaultShotsDir(resolveDataDir(a.cfg)),
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
return true
}
// registerBrowserBuild 建出工具并注册进能力面;任何一步失败都**只**在日志里报分类原因。
// 抽成独立函数是为了让每一处「不注册」都只有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserBuild(log *slog.Logger, carry *browsertools.ShotOutbox) (browserBuild, bool) {
build := a.buildBrowserTool(carry)
if !build.available {
log.Warn("browser tool not registered",
"reason", build.reason, "detail", build.detail,
"configPath", build.configPath, "configSource", build.configSource,
"effect", browserNotRegisteredEffect)
return browserBuild{}, false
}
if err := a.caps.RegisterTool(build.tool); err != nil {
// 注册失败就把刚建的管理器关掉:它可能已经持有 profile 锁与清扫协程,
// 留着就是一次凭据目录的泄漏(registerBrowserBuild 的失败路径只有这一条)。
if cerr := build.mgr.Close(); cerr != nil {
log.Warn("browser manager close failed after register error", "error", cerr)
}
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return browserBuild{}, false
}
return build, true
}
+214
View File
@@ -0,0 +1,214 @@
package main
// 本文件是 design/28 §7 判据 2(M4 闭环)的**组合根落点**:把「截图 → 进本回合上下文」
// 接成一条自动的路。三件事各归各家,本文件只做中间那一段接线:
//
// plugins/tools/browser(交图) → 本文件(装配期并入本轮视图) → multimodal/vision(成图/成文)
//
// 具体地:
//
// 1. `browser` 工具的 `screenshot` 动作落盘后把这张图放进它**自己的附件箱**
// (`plugins/tools/browser/attach.go`,按 `loop.SessionFromContext` 的会话分桶);
// 2. 本文件给出的 `TransformContext` 钩子在**每个模型步**之前被循环调用
// (`internal/runtime/loop/memory.go` 的 assembleContext 末尾),把该会话的待送截图
// **取走即消费**,经 `vision.ShotAttacher` 变成一条带图(或带文字描述)的 user 消息,
// 追加进本轮视图;
// 3. `ShotAttacher` 用**既有**的视觉路由决定形态:主模型能收图 ⇒ 内联成真的 image 部件;
// 收不了图 ⇒ vision/OCR 工具产出的文字(design/28 §5 的那一行)。
//
// 为什么用 `TransformContext` 而不是自己往 msgs 里写:循环对这个钩子的契约就是
// 「投影、过滤或重排模型所见的消息」(`loop.Options.TransformContext`),而且它
// **只影响本轮视图**、不写回运行窗口 —— 截图因此不会进 checkpoint 快照,也不会进
// 后台记忆提取的输入面(design/28 §4 第 3 条:「默认不进长期记忆」,截图不是记忆)。
//
// ⚠️ **接线状态(如实)**:本文件提供 `a.browserShotTransform(...)`,生产接线需要
// `wire_runtime.go` 的 `loop.New(loop.Options{…})` 里加一行:
//
// TransformContext: a.browserShotTransform(visionResolver),
//
// 本片(Y4)的文件面**不含** `wire_runtime.go`,因此那一行由主会话落地;落地前本文件
// 只在测试里被使用(`wire_browser_context_test.go` 用真实 loop + 探针适配器验证
// 「装上它之后,模型请求里确实出现 image 部件」)。
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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/runtime/multimodal/vision"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserShotLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
const browserShotLogSource = "cmd/cyrene/wire_browser_context"
// cropToolName 是裁切能力的工具名(`plugins/tools/io/crop.go` 的 Name)。
// 「大图先过 crop」这条要求落在这里:视觉层按区域裁一块再内联,不自己实现裁剪。
const cropToolName = "image-crop"
// browserShotTransform 造出装给 `loop.Options.TransformContext` 的钩子。
//
// resolver 就是运行时那一个 `*vision.MessageResolver`(组合根已经建好的那个实例),
// 因此截图与用户附图共用同一个 Router、同一份 data: URI 缓存 —— 两处各建一个会让
// 「同一种图两种处理」变成可能。
func (a *app) browserShotTransform(resolver *vision.MessageResolver) func(context.Context, []model.Message, int) []model.Message {
injector := &browserShotInjector{
app: a,
log: browserShotLogger(a.log),
}
if resolver != nil {
injector.attacher = vision.NewShotAttacher(resolver,
vision.WithShotCropper(browserShotCropper{invoke: a.browserInvoker()}))
}
return injector.apply
}
// browserShotLogger 给日志补来源标签并兜住 nil:`a.log` 为 nil 的夹具下
// `logger.WithSource` 会返回 nil,而注入器会直接调用它(nil 的 *slog.Logger 会 panic)。
func browserShotLogger(base *slog.Logger) *slog.Logger {
if base == nil {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserShotLogSource)
}
return logger.WithSource(base, browserShotLogSource)
}
// browserShotInjector 是钩子的实现体(把状态与函数分开,便于单测直接构造)。
type browserShotInjector struct {
app *app
attacher *vision.ShotAttacher
log *slog.Logger
}
// apply 在每个模型步之前把待送截图并入本轮视图。
//
// 它**绝不**因为一张图的问题让整个回合失败:任何一步失败都如实降级(图读不出来就只发
// 一句「没送成」的说明,模型仍能按路径自己读),因为「截图送不进去」远比「回合直接报错」
// 轻(同 vision 的既有口径:图像解析失败保留原消息、回合继续)。
func (in *browserShotInjector) apply(ctx context.Context, msgs []model.Message, _ int) []model.Message {
outbox := in.outbox()
if outbox == nil || in.attacher == nil {
return msgs
}
shots := outbox.Drain(loop.SessionFromContext(ctx))
if len(shots) == 0 {
return msgs
}
out := msgs
for _, shot := range shots {
att, err := in.attacher.Attach(ctx, vision.ShotRef{
Path: shot.Path,
Bytes: shot.Bytes,
Width: shot.Width,
Height: shot.Height,
Format: shot.Format,
})
if err != nil {
// 如实说明「这张图没送成」,并给出路径:模型仍可用 vision / image-crop 自己读。
in.log.Warn("browser screenshot not attached to the turn",
"path", shot.Path, "bytes", shot.Bytes, "error", err)
out = append(out, attachmentFailureMessage(shot.Path, err))
continue
}
in.log.Info("browser screenshot attached to the turn",
"path", shot.Path, "bytes", shot.Bytes, "route", att.Route, "cropped", att.Cropped)
out = append(out, attachmentMessage(att))
}
// 触发一次 len 变化后返回新切片(不修改入参切片:钩子的契约是「可以投影」,
// 而原地追加会改到循环持有的 msgs 底层数组)。
return append([]model.Message(nil), out...)
}
// outbox 取回 browser 工具的附件箱。工具没注册(本机没有浏览器)时返回 nil。
//
// 用**结构断言**而不是具体类型:将来若给工具套一层装饰(例如审计包装),
// 只要它还暴露 ShotOutbox 就仍然接得上,不必改这里。
func (in *browserShotInjector) outbox() *browsertools.ShotOutbox {
if in.app == nil || in.app.caps == nil {
return nil
}
entry, err := in.app.caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
holder, ok := entry.Value.(interface {
ShotOutbox() *browsertools.ShotOutbox
})
if !ok {
return nil
}
return holder.ShotOutbox()
}
// browserInvoker 返回工具调用器(`image-crop` 经它调)。dispatcher 未接时返回 nil,
// 视觉层据此判断「没有裁切能力」并如实降级(见 vision.ShotAttacher.cropped)。
func (a *app) browserInvoker() vision.ToolInvoker {
if a == nil || a.dispatcher == nil {
return nil
}
return a.dispatcher.Invoke
}
// attachmentMessage 把一条附件结论渲染成**一条 user 消息**。
//
// 为什么是 user 角色:DeepSeek / Anthropic 协议的图像部件**只允许出现在 user 消息**里
// (design/24 §2.3 第 3 条),而工具结果回合在本项目是 `RoleTool`(文本-only 形状,
// `loop.toolResultMessage`)—— 往那里塞 image 部件会被编码器静默丢掉。
func attachmentMessage(att vision.ShotAttachment) model.Message {
note := strings.TrimSpace(att.Note)
if note == "" {
note = "[截图附件]这一步在浏览器里拍下的一页。"
}
parts := make([]model.Content, 0, 2)
parts = append(parts, model.Content{Type: model.ContentTypeText, Text: note})
if part := att.Content; part.Type == model.ContentTypeImage || strings.TrimSpace(part.Text) != "" {
parts = append(parts, part)
}
return model.Message{Role: model.RoleUser, Content: parts}
}
// attachmentFailureMessage 是「这张图没送成」的如实说明(**不带**图片内容,
// 只带路径与病因分类),使模型不会以为页面里什么都没有。
func attachmentFailureMessage(path string, err error) model.Message {
text := fmt.Sprintf("[截图附件]刚才那张截图**没有**并入本轮上下文(%v)。完整图在 %s,"+
"可用 vision、OCR 或 image-crop 工具按路径自行读取。", err, path)
return model.Message{Role: model.RoleUser, Content: []model.Content{
{Type: model.ContentTypeText, Text: text},
}}
}
// browserShotCropper 是「大图先过 crop」的执行者:把区域裁切交给既有的 `image-crop`
// 工具(同一份实现、同一份上限与同一套坐标语义),本层不重写裁剪。
type browserShotCropper struct {
invoke vision.ToolInvoker
}
// CropRegion 实现 vision.ShotCropper。
func (c browserShotCropper) CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error) {
if c.invoke == nil {
return "", fmt.Errorf("browser: 没有可用的裁切能力(工具分发器未接线)")
}
res, err := c.invoke(ctx, cropToolName, map[string]any{
"image": path,
"x": x,
"y": y,
"width": w,
"height": h,
})
if err != nil {
return "", fmt.Errorf("browser: %s: %w", cropToolName, err)
}
m, ok := res.(map[string]any)
if !ok {
return "", fmt.Errorf("browser: %s 返回了非对象结果(%T)", cropToolName, res)
}
uri, _ := m["image"].(string)
if strings.TrimSpace(uri) == "" {
return "", fmt.Errorf("browser: %s 没返回裁切结果", cropToolName)
}
return uri, nil
}
+261
View File
@@ -0,0 +1,261 @@
package main
// 本文件是 design/28 §7 **判据 2(M4 闭环)的真跑**(切片 Y4 / B3):
//
// 一张**图里才有、HTML 里没有**的内容(这里由一张真实 PNG 代表)⇒ 它进了模型请求。
//
// 走的是**真实循环**(`loop.New` + 探针适配器,与组合根同一个装配形状),因此它验的是
// 完整那一段:截图箱 → `TransformContext` 钩子 → 视觉路由(主模型能收图 ⇒ 真 image 部件;
// 收不了图 ⇒ vision 工具产出的文字)→ 模型请求。
//
// 三段各自归各家、这里只做端到端:
// - 截图**落盘**(真 Chrome、真 CDP)由 `wire_browser_e2e_test.go` 与
// `internal/capability/browser/compare_test.go` 覆盖(需要真机,默认 Skip);
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
//
// ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options`
// 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见
// `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform`
// 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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/runtime/multimodal/vision"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// b3Session 是本文件用的会话 id(截图箱按会话分桶,测试与循环必须用同一个)。
const b3Session = "sess-b3"
// b3RenderOnlyText 是「图里才有、HTML 里没有」的那句话:走工具形态时它必须出现在
// 模型请求里(纯文本路径的正面证据)。
const b3RenderOnlyText = "计费周期:2026-09 至 2026-10(这是渲染后才出现的文字)"
// b3PNG 造一张真实 PNG(视觉层对字节**嗅探**,假字节会被拒)。
func b3PNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 6, 4))
img.Set(0, 0, color.RGBA{R: 0x10, G: 0x20, B: 0x30, A: 0xff})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("编码 PNG: %v", err)
}
return buf.Bytes()
}
// b3Fixture 是一次闭环验证的夹具:真 loop + 探针适配器 + 装着截图的 browser 工具。
type b3Fixture struct {
adapter *compactProbeAdapter
loop *loop.Loop
outbox *browsertools.ShotOutbox
shot string
}
// newB3Fixture 装配夹具。sighted 决定主模型能不能收图(两条闭环路径的分岔点就在这里)。
func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
t.Helper()
data := b3PNG(t)
shotPath := filepath.Join(t.TempDir(), "shot-b3.png")
if err := os.WriteFile(shotPath, data, 0o600); err != nil {
t.Fatalf("写截图: %v", err)
}
outbox := browsertools.NewShotOutbox()
outbox.Put(b3Session, browsercapShot(shotPath, len(data)))
// 只接**附件箱**这一面:本用例验的是「截图落盘之后」那一段,真浏览器由真机 e2e 覆盖。
tool := browsertools.New(nil, browsertools.WithShotOutbox(outbox))
var out bytes.Buffer
a := &app{
cfg: &config.Config{DataDir: t.TempDir()},
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
caps: registry.New(),
}
if err := a.caps.RegisterTool(tool); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
adapter := &compactProbeAdapter{}
resolver := vision.NewMessageResolver(
vision.NewRouter(vision.WithVisionTool("vision"), vision.WithOCRTool("ocr")),
func() vision.ModelInfo {
return vision.ModelInfo{Name: "main", SupportsVision: sighted}
},
func(_ context.Context, _ string, _ map[string]any) (any, error) {
// 工具形态的假实现:返回「只有渲染后才看得见」的那句话。
return map[string]any{"description": b3RenderOnlyText, "text": b3RenderOnlyText}, nil
},
)
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
return &b3Fixture{
adapter: adapter,
outbox: outbox,
shot: shotPath,
loop: loop.New(loop.Options{
Agent: ag,
// ← 生产接线就是这一行(见 wire_runtime.go 的接线状态注释)。
TransformContext: a.browserShotTransform(resolver),
OnStream: func(model.StreamEvent) {},
}),
}
}
// browsercapShot 造与落盘结论同形的截图值。
func browsercapShot(path string, size int) browsercap.Shot {
return browsercap.Shot{Path: path, Bytes: size, Width: 6, Height: 4, Format: "png",
DimensionsKnown: true}
}
// TestBrowserShotClosure_SightedModelReceivesTheImage 是判据 2 的正面:
// 主模型能收图 ⇒ 模型请求里**确实有 image 部件**,且是服务端内联的 data: URI。
func TestBrowserShotClosure_SightedModelReceivesTheImage(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, true)
if _, err := fx.loop.Run(context.Background(), b3Session,
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(fx.adapter.reqs) == 0 {
t.Fatal("没有捕获到模型请求")
}
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
part, ok := findImagePart(req.Messages)
if !ok {
t.Fatalf("模型请求里没有 image 部件 —— 截图没有进上下文(判据 2 不成立);消息数=%d",
len(req.Messages))
}
if !strings.HasPrefix(part.ImageURL, "data:image/png;base64,") {
t.Errorf("image 部件不是内联的 data: URI(云 provider 取不到本地路径):%.60s", part.ImageURL)
}
if !imagePartIsOnUserMessage(req.Messages) {
t.Error("image 部件不在 user 消息上:Anthropic/DeepSeek 只接受 user 消息里的图像部件")
}
if note := lastUserText(req.Messages); !strings.Contains(note, fx.shot) {
t.Errorf("附件说明里没给出截图路径(模型无法据此裁区域精读):%q", note)
}
}
// TestBrowserShotClosure_TextOnlyModelGetsToolFormText 是判据 2 的另一条路:
// 主模型读不了图 ⇒ 图**不进**上下文,进去的是 vision 工具的读数(「渲染后才出现的文字」)。
func TestBrowserShotClosure_TextOnlyModelGetsToolFormText(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, false)
if _, err := fx.loop.Run(context.Background(), b3Session,
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
t.Fatalf("Run: %v", err)
}
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
if _, hasImage := findImagePart(req.Messages); hasImage {
t.Error("主模型读不了图时不该把 image 部件塞进上下文(那是无意义的 token 与 400 风险)")
}
text := allText(req.Messages)
if !strings.Contains(text, b3RenderOnlyText) {
t.Errorf("工具形态的文字没进上下文:%q", text)
}
}
// TestBrowserShotClosure_ShotIsConsumedOnce 钉住「取走即消费」:同一张截图不会被送到两次
// (重复送图既费预算,又会诱导模型以为「又拍了一张」)。
func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, true)
msgs := []model.Message{model.TextMessage(model.RoleUser, "看看这一页")}
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
t.Fatalf("第一次 Run: %v", err)
}
if fx.outbox.Pending(b3Session) != 0 {
t.Fatalf("第一次之后箱子里还剩 %d 张(取走即消费被破坏)", fx.outbox.Pending(b3Session))
}
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
t.Fatalf("第二次 Run: %v", err)
}
if _, ok := findImagePart(fx.adapter.reqs[len(fx.adapter.reqs)-1].Messages); ok {
t.Error("同一张截图在第二次回合里又被送了一次")
}
}
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行。
//
// 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go,
// 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」,
// 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
if !strings.Contains(src, "browserShotTransform(") {
t.Skip("接线未应用:请在 wire_runtime.go 的 loop.New(loop.Options{…}) 里加 " +
"`TransformContext: a.browserShotTransform(visionResolver),`" +
"(B3 闭环的生产挂点;本片无权改该文件)")
}
if !strings.Contains(src, "TransformContext:") {
t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上")
}
}
/* ---------------------------------------------------------------------------
* 断言助手
* ------------------------------------------------------------------------- */
// findImagePart 返回请求里的第一个 image 部件。
func findImagePart(msgs []model.Message) (model.Content, bool) {
for _, m := range msgs {
for _, part := range m.Content {
if part.Type == model.ContentTypeImage {
return part, true
}
}
}
return model.Content{}, false
}
// imagePartIsOnUserMessage 报告 image 部件是否挂在 user 消息上(协议硬要求)。
func imagePartIsOnUserMessage(msgs []model.Message) bool {
for _, m := range msgs {
for _, part := range m.Content {
if part.Type == model.ContentTypeImage {
return m.Role == model.RoleUser
}
}
}
return false
}
// allText 拼出请求里全部文本(断言工具形态的文字是否进了上下文)。
func allText(msgs []model.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(loopText(m))
b.WriteString("\n")
}
return b.String()
}
// lastUserText 取最后一条 user 消息的文本(附件说明挂在它上面)。
func lastUserText(msgs []model.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == model.RoleUser {
return loopText(msgs[i])
}
}
return ""
}
+366
View File
@@ -0,0 +1,366 @@
package main
// 本文件是 design/28 §8 **B4(M3 面板入口)的组合根接线**:把 `browser` 插件声明的
// 面板页与六个动作登记到插件管理器上(design/09 §5.2.1 的「插件管理器 = 唯一中枢」)。
//
// 五条口径:
//
// 1. **不开任何路由**:动作走宿主的通用动作端点
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(PLG-2),插件只给动作名;
// 2. **面板声明与动作处理器同源**:动作名常量都在 `plugins/tools/browser/panel.go`,
// 本文件只做「名字 → 处理器」的映射,不另抄一份名字;
// 3. **读面不起浏览器**:`plugin.getBrowserState` / `plugin.listShots` /
// `plugin.getFingerprint` 只读内存事实(`state.go` 口径 1)。真正会起浏览器的只有两个
// **写**动作(`plugin.shootPage` / `plugin.checkFingerprint`),它们按 §5.4.1 只对
// owner/admin 可见可调;
// 4. **保存即热重载**:`plugin.saveBrowserConfig` 落 `$DATA_DIR/config/plugins/browser.json`
// 后**重建**工具并替换注册表条目(`registry.ReplaceTool`),所以面板文案说的
// 「保存后立即生效」是真话(design/09 §5.5 的同一条口径);
// 5. **失败要能分类**:一律经 `pluginmgr.ActionError` 交出「错误码 + 文案 + HTTP 状态」,
// 绝不回「已保存」而实际没生效(§5.4.6)。
//
// ⚠️ **接线状态(如实)**:本文件提供 `wireBrowserPanel(hub, a, log)`,生产接线需要在
// `wire_frontend.go` 的 `wirePanelHub` 里加一行(与 search 的声明并列):
//
// wireBrowserPanel(hub, a, log)
//
// 本片(Y4)的文件面**不含** `wire_frontend.go`,因此那一行由主会话落地;落地前本文件
// 只在测试里被使用(`wire_browser_panel_test.go` 用真实 hub + 假浏览器验证「声明过校验、
// 六个动作真能分发」)。
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserPanelLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
const browserPanelLogSource = "cmd/cyrene/wire_browser_panel"
// 动作失败码(宿主级,只在 browser 面板这组文件里使用)。领域自己的码来自
// `browsercap` 的分类原因码。
const (
// browserCodeConfigUnreadable 报告面板配置存在但读不出来(损坏 / 权限)。
browserCodeConfigUnreadable = "config_unreadable"
// browserCodeConfigRejected 报告这次保存的配置**非法**(越界 / 端点非法):没落盘。
browserCodeConfigRejected = "config_rejected"
// browserCodeConfigWriteFailed 报告配置写不进去(磁盘 / 权限)。
browserCodeConfigWriteFailed = "config_write_failed"
// browserCodeUnavailable 报告本机现在没有可用浏览器来源(工具没进能力面)。
browserCodeUnavailable = "browser_unavailable"
// browserCodeNoSession 报告「当前没有打开的页面」:拍一张没有目标(面板不替模型开页)。
browserCodeNoSession = "browser_session_not_open"
// browserCodeActionFailed 报告浏览器动作执行失败(分类原因见日志)。
browserCodeActionFailed = "browser_action_failed"
)
// wireBrowserPanel 声明 browser 的面板页并把六个动作登记到面板中枢。
//
// 声明被拒只告警、不阻断启动(与 search 的处置一致:§5.2.1 的口径是「该面板不注册 +
// 显式报错」,不是「宿主陪葬」)。登记失败同理(面板会以 501 如实作答)。
func wireBrowserPanel(hub *pluginmgr.Panels, a *app, log *slog.Logger) {
if hub == nil || a == nil {
return
}
env := browserPanelEnv{
app: a,
hub: hub,
log: browserPanelLogger(log),
path: browsercfg.DefaultConfigPath(resolveDataDir(a.cfg)),
}
env.declare()
if err := hub.RegisterActions(browsertools.PanelPluginID, env.handlers()); err != nil {
env.log.Error("browser panel actions not registered; panel actions will answer 501",
"pluginId", browsertools.PanelPluginID, "error", err)
return
}
env.warnImpliedButMissing()
}
// browserPanelLogger 给日志补上本文件的来源标签,并兜住 nil(`logger.WithSource(nil, …)`
// 返回 nil,而本文件多处直接 `env.log.Info/Warn` —— 对 nil 的 *slog.Logger 调用会 panic)。
// 与 `plugins/tools/browser` 的 discardLog 同一手法:nil 的语义是「不输出」,不是「不标注」。
func browserPanelLogger(base *slog.Logger) *slog.Logger {
if base == nil {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserPanelLogSource)
}
return logger.WithSource(base, browserPanelLogSource)
}
// browserPanelEnv 是六个动作共享的接线事实(一次装配、多动作复用)。
//
// 刻意只装**在多个动作间共享**的东西(日志出口、配置文件路径、面板中枢),每次调用各自
// 读文件/读注册面:动作之间不共享「当前配置」这份可变状态,就不会出现「A 动作读到的是
// B 动作刚改之前的那份」。
type browserPanelEnv struct {
app *app
hub *pluginmgr.Panels
log *slog.Logger
path string
}
// handlers 返回本插件声明的**六个动作**的处理器(键 = 声明里的动作名,同源引用常量)。
func (e browserPanelEnv) handlers() map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
browsertools.ActionState: e.state,
browsertools.ActionShots: e.shots,
browsertools.ActionFingerprint: e.fingerprint,
browsertools.ActionShoot: e.shoot,
browsertools.ActionCheck: e.check,
browsertools.ActionSaveConfig: e.saveConfig,
}
}
// declare 用**当前盘上的配置**重新声明面板(表单默认值 = 现状)。保存后也会再调一次,
// 使面板上的值刷新成刚落盘的那份(前端提交成功后会重取快照)。
func (e browserPanelEnv) declare() {
cfg, err := e.currentConfig()
if err != nil {
e.log.Warn("browser panel declared with default config values",
"path", e.path, "error", err)
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
}
available := e.tool() != nil
accepted, derr := e.hub.Declare(browsertools.PanelPluginID, browsertools.AdminPanels(browsertools.PanelInput{
Config: cfg,
Available: available,
Source: e.source(),
}))
if derr != nil {
e.log.Warn("browser panel declaration rejected",
"pluginId", browsertools.PanelPluginID, "accepted", accepted, "error", derr)
return
}
e.log.Info("browser panel declared",
"pluginId", browsertools.PanelPluginID, "panels", accepted, "available", available)
}
// warnImpliedButMissing 把「声明里有、登记里没有」的动作如实报出来(端点对它的结算
// 就是 501)。它是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的运行期版本。
func (e browserPanelEnv) warnImpliedButMissing() {
registered := make(map[string]bool)
for _, name := range e.hub.ActionHandlerNames(browsertools.PanelPluginID) {
registered[name] = true
}
for _, name := range e.hub.DeclaredActions(browsertools.PanelPluginID) {
if !registered[name] {
e.log.Warn("declared browser panel action has no handler; it will answer 501",
"pluginId", browsertools.PanelPluginID, "action", name)
}
}
}
// currentConfig 读盘上的 browser 配置(缺失 ⇒ 默认),已 Normalize。
func (e browserPanelEnv) currentConfig() (browsercfg.Config, error) {
dataDir := resolveDataDir(e.app.cfg)
fileCfg, err := browsercfg.Load(e.path, dataDir)
if err != nil {
return browsercfg.Config{}, err
}
out := browsercfg.Defaults(dataDir)
if fileCfg != nil {
out = *fileCfg
}
return out, nil
}
// tool 从能力面取回 browser 工具(没注册 ⇒ nil:面板要如实说明「这台机器上没有浏览器」,
// 而不是报一个空表把「没接线」伪装成「没有数据」)。
func (e browserPanelEnv) tool() *browsertools.Tool {
if e.app == nil || e.app.caps == nil {
return nil
}
entry, err := e.app.caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
tool, _ := entry.Value.(*browsertools.Tool)
return tool
}
// source 返回来源描述(只进面板描述文案)。
func (e browserPanelEnv) source() string {
if tool := e.tool(); tool != nil {
return tool.State().Source
}
return ""
}
// state 是 `plugin.getBrowserState`:运行状态只读事实面的一行。**不启动浏览器**。
func (e browserPanelEnv) state(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
cfg, err := e.currentConfig()
if err != nil {
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
}
snap := browsercapState(e.tool(), cfg)
return map[string]any{"rows": browsertools.StateRows(snap)}, nil
}
// fingerprint 是 `plugin.getFingerprint`:**上一次**自检的读数(未自检过 ⇒ 如实说「未自检」)。
func (e browserPanelEnv) fingerprint(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
snap := browsercapState(e.tool(), browsercfg.Config{})
return map[string]any{"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt)}, nil
}
// shots 是 `plugin.listShots`:最近截图统计(**不含路径与内容**)。
func (e browserPanelEnv) shots(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
snap := browsercapState(e.tool(), browsercfg.Config{})
return map[string]any{"rows": browsertools.ShotRows(snap.Shots)}, nil
}
// shoot 是 `plugin.shootPage`(写动作,owner-only):拍一张**当前页面**。
//
// 为什么需要会话已在跑:面板**不替模型开页**(否则它就成了「任意 URL 的截图器」,
// design/28 §1 M3 明确否掉的那个面)。主人先在对话里让它打开某页(或人工在有头窗口里
// 接管),再从面板拍一张 —— 真实排障流程就是这样。
func (e browserPanelEnv) shoot(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
tool := e.tool()
if tool == nil {
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
"本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)",
http.StatusConflict, nil)
}
if !tool.State().SessionLive {
return nil, pluginmgr.NewActionError(browserCodeNoSession,
"浏览器当前没有打开的页面:先让她打开一个页面(或在有头窗口里人工接管),再回面板拍一张",
http.StatusConflict, nil)
}
res, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if err != nil {
e.log.Warn("panel screenshot failed", "error", err)
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
"截图失败:"+err.Error(), http.StatusBadGateway, err)
}
return map[string]any{"shot": res}, nil
}
// check 是 `plugin.checkFingerprint`(写动作,owner-only):真起一次浏览器读指纹。
// 它是本页唯一「更新只读事实面」的写动作 —— 读面本身绝不启动浏览器(口径 1)。
func (e browserPanelEnv) check(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
tool := e.tool()
if tool == nil {
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
"本机没有可用的浏览器来源,无法自检", http.StatusConflict, nil)
}
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
e.log.Warn("panel fingerprint check failed", "error", err)
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
"指纹自检失败:"+err.Error(), http.StatusBadGateway, err)
}
snap := tool.State()
return map[string]any{
"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt),
}, nil
}
// saveConfig 是 `plugin.saveBrowserConfig`(写动作,owner-only):落盘 + 热重载。
//
// 事务形状与 web-search 的热重载同族(`websearch_reload.go`):
//
// ① 快照文件 → ② 校验并落盘 → ③ 按落盘后的配置重建工具并替换注册表条目;
// ④ **保存前本来有可用工具**而 ③ 失败 ⇒ 回滚文件 + 409:绝不让一次保存把「盘上的配置」
// 与「跑着的那个浏览器」静默拆开(那正是 design/09 §5.4.6 点名要防的「报成功但没生效」)。
// 保存前本来**没有**可用工具(例如第一次配一个还没起来的容器)⇒ 文件留下、如实回
// `applied=false` + 原因码:那种情形没有能力可丢,报错反而会让主人**没法先配好**。
func (e browserPanelEnv) saveConfig(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
patch, err := browserConfigPatch(req.Payload)
if err != nil {
return nil, err
}
cur, err := e.currentConfig()
if err != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
}
next, err := applyBrowserConfigPatch(cur, patch)
if err != nil {
return nil, err
}
hadTool := e.tool() != nil
snap, snapExisted, serr := readConfigSnapshot(e.path)
if serr != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它", http.StatusInternalServerError, serr)
}
if err := browsercfg.Save(e.path, resolveDataDir(e.app.cfg), next); err != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
"配置写不进去(磁盘或权限)", http.StatusInternalServerError, err)
}
applied, registered, reason := e.app.replaceBrowserTool(e.log)
if !applied && hadTool {
if rerr := restoreConfigSnapshot(e.path, snap, snapExisted); rerr != nil {
e.log.Error("browser config rollback failed after a save that did not apply",
"path", e.path, "reason", reason, "rollbackError", rerr)
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
"这份配置没能生效,且回滚失败:盘上的文件可能已经变了(详见服务端日志)",
http.StatusInternalServerError, rerr)
}
return nil, pluginmgr.NewActionError(browserCodeConfigRejected,
"这份配置没能生效(已回滚到保存前那一份):"+reason, http.StatusConflict, nil)
}
e.declare()
return map[string]any{
"saved": true,
"applied": applied,
"registered": registered,
"reason": reason,
}, nil
}
// browsercapState 取只读事实快照:没有工具时用**盘上的配置**兜底(面板仍要显示现状),
// 而不是回一个空对象让页面显示一片「—」。
func browsercapState(tool *browsertools.Tool, cfg browsercfg.Config) browsercap.StateSnapshot {
if tool != nil {
return tool.State()
}
return browsercap.StateSnapshot{
Available: false,
Headless: cfg.Headless,
ViewportWidth: cfg.ViewportWidth,
ViewportHeight: cfg.ViewportHeight,
ProfileDir: cfg.ProfileDir,
Concurrency: cfg.Concurrency,
IdleTimeoutSeconds: cfg.IdleTimeoutSeconds,
ScreenshotMaxBytes: cfg.ScreenshotMaxBytes,
}
}
// decodeUserValues 把动作载荷里的 `values` 取出来(前端表单的提交形状是 `{values:{…}}`)。
// 形状不对时返回结构化错误,绝不「当成空补丁」静默保存一份什么都没改的配置。
func decodeUserValues(payload any) (map[string]any, error) {
m, ok := payload.(map[string]any)
if !ok {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"请求体不是对象:表单提交的形状是 {\"values\":{…}}", http.StatusBadRequest, nil)
}
raw, exists := m["values"]
if !exists || raw == nil {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"请求体缺少 values(没有要保存的内容)", http.StatusBadRequest, nil)
}
values, ok := raw.(map[string]any)
if !ok {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"values 不是对象", http.StatusBadRequest, nil)
}
return values, nil
}
// requireString 取一个字符串补丁值(空串是合法值:清空 profile/端点即「回落自动探测」)。
func requireString(key string, raw any) (string, error) {
s, ok := raw.(string)
if !ok {
return "", pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("字段 %s 需要一个字符串", key), http.StatusBadRequest, nil)
}
return strings.TrimSpace(s), nil
}
+246
View File
@@ -0,0 +1,246 @@
package main
// 本文件是 browser 面板的**数据面**(切片 Y4 / B4):把动作载荷翻成配置补丁、把配置
// 写盘/回滚,以及保存之后的**热重载**(重建工具 + 换注册表条目)。
//
// 拆出来的理由同 web-search 那一组:`wire_browser_panel.go` 只留「名字 → 处理器」的接线,
// 载荷解析与文件事务各自成文件,函数才守得住 ≤80 行。
//
// 两条口径:
//
// 1. **补丁是补丁**:前端提交的是 `{values:{…}}`,而 `planSubmit` 只发**改动过的**字段
// (见 `frontend/web/src/admin/schema/values.ts`)⇒ 没出现的键一律**保持原值**,
// 绝不把「没提到」当成「清空」(那是静默清空的经典事故,design/09 §5.4.2);
// 2. **回滚是凭据**:保存前先把盘上那一份读进内存,热重载失败就写回去 —— 绝不把后端
// 留在「盘上这份配置让它下次起不来」的状态。
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserConfigRollbackMode 是回滚写回时的文件权限:与 `browsercfg.Save` 的 0600 同口径
// (那份文件带显式 CDP 端点与 profile 位置)。硬编码 0600 而不是引用对方的常量,是因为
// 变化方向是「收紧」,不存在比 0600 更松的风险。
const browserConfigRollbackMode os.FileMode = 0o600
// browserConfigPatch 解析表单提交的载荷(`{values:{…}}`)并做**形状**校验:
// 只接受本面板声明过的键,未知键一律拒(**绝不静默忽略** —— 静默忽略会让「我改了但它没生效」
// 变成查不出的谜,design/09 §5.2.1 的同一条口径)。
func browserConfigPatch(payload any) (map[string]any, error) {
values, err := decodeUserValues(payload)
if err != nil {
return nil, err
}
known := map[string]bool{
browsertools.FieldHeadless: true,
browsertools.FieldExecutable: true,
browsertools.FieldCDPEndpoint: true,
browsertools.FieldProfileDir: true,
browsertools.FieldViewportWidth: true,
browsertools.FieldViewportHeight: true,
browsertools.FieldIdleTimeout: true,
browsertools.FieldConcurrency: true,
}
out := make(map[string]any, len(values))
for key, raw := range values {
if !known[key] {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("未知字段 %q:本面板只接受 %d 个已声明字段", key, len(known)),
http.StatusBadRequest, nil)
}
out[key] = raw
}
return out, nil
}
// applyBrowserConfigPatch 把补丁合进当前配置。未出现的键保持原值(见文件头口径 1)。
//
// 类型不匹配一律报 400(`values` 已由前端按声明类型还原:number → 数字、switch → 布尔);
// 越界值交给 `browsercfg.Validate` 判(它只判**原始值**,不 Normalize —— 悄悄改小会让
// 面板显示的与生效的不是一回事)。
func applyBrowserConfigPatch(cur browsercfg.Config, patch map[string]any) (browsercfg.Config, error) {
out := cur
for key, raw := range patch {
var err error
switch key {
case browsertools.FieldHeadless:
out.Headless, err = patchBool(key, raw)
case browsertools.FieldExecutable:
out.Executable, err = requireString(key, raw)
case browsertools.FieldCDPEndpoint:
out.CDPEndpoint, err = requireString(key, raw)
case browsertools.FieldProfileDir:
out.ProfileDir, err = requireString(key, raw)
case browsertools.FieldViewportWidth:
out.ViewportWidth, err = patchInt(key, raw)
case browsertools.FieldViewportHeight:
out.ViewportHeight, err = patchInt(key, raw)
case browsertools.FieldIdleTimeout:
out.IdleTimeoutSeconds, err = patchInt(key, raw)
case browsertools.FieldConcurrency:
out.Concurrency, err = patchInt(key, raw)
default:
err = fmt.Errorf("未知字段 %q", key)
}
if err != nil {
return browsercfg.Config{}, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("字段 %s 的取值不合法:%v", key, err), http.StatusBadRequest, err)
}
}
if verr := out.Validate(); verr != nil {
return browsercfg.Config{}, pluginmgr.NewActionError(browserCodeConfigRejected,
"这份配置超出硬上限,**没有**落盘:"+verr.Error(), http.StatusConflict, verr)
}
return out, nil
}
// patchBool 取一个布尔补丁值。
func patchBool(key string, raw any) (bool, error) {
v, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("需要布尔值")
}
return v, nil
}
// patchInt 取一个数值补丁值(JSON 数字解出来是 float64;整数字符串也认,因为
// 「留空/清空」在某些控件上会以空串抵达)。
func patchInt(key string, raw any) (int, error) {
switch v := raw.(type) {
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("需要整数")
}
return int(v), nil
case int:
return v, nil
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
// 清空 = 回落默认(Normalize 的口径:≤0 ⇒ 用默认值)。
return 0, nil
}
var n int
if _, err := fmt.Sscanf(trimmed, "%d", &n); err != nil {
return 0, fmt.Errorf("需要整数")
}
return n, nil
default:
return 0, fmt.Errorf("需要整数")
}
}
// readConfigSnapshot 读一份文件快照(回滚凭据)。文件不存在时返回 (nil, false, nil) ——
// 那是「保存前没有配置文件」这种合法状态,回滚时要把它恢复成「没有文件」。
func readConfigSnapshot(path string) ([]byte, bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, fmt.Errorf("读配置快照 %s: %w", path, err)
}
return raw, true, nil
}
// restoreConfigSnapshot 把文件恢复到快照那一份;快照本来不存在 ⇒ 删掉它。
func restoreConfigSnapshot(path string, raw []byte, existed bool) error {
if !existed {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除配置 %s: %w", path, err)
}
return nil
}
if err := os.WriteFile(path, raw, browserConfigRollbackMode); err != nil {
return fmt.Errorf("回写配置 %s: %w", path, err)
}
return nil
}
// replaceBrowserTool 按**落盘后的配置**重建 browser 工具并替换能力面里的条目
// (design/09 §5.5 的口径:面板保存后下一轮即用新接线,不必重启)。
//
// 返回 (applied, registered, reason):
// - applied=true 报告工具真的被换掉/加上(reason 为空);
// - applied=false 时 reason 是分类原因码(`browsercfg.Reason*` 或本文件的
// "replace-failed"/"register-failed"/"no-registry"),调用方据此决定回滚与文案。
//
// 两种「不生效」都**不改动既有的工具**:没有可用来源时工具不该存在(判据 1 的诚实口径),
// 但也没理由因为一次保存把已经在跑的那一把弄没(那是「保存一次就少一个能力」的事故形状)。
func (a *app) replaceBrowserTool(log *slog.Logger) (bool, bool, string) {
if a == nil || a.caps == nil {
return false, false, "no-registry"
}
existing, gerr := a.caps.Get(browsertools.ToolName)
haveExisting := gerr == nil
carry := shotOutboxOf(existing.Value)
build := a.buildBrowserTool(carry)
if !build.available {
// 新配置下没有可用来源:工具**不注册**(design/28 §7 判据 1 的诚实口径)。
log.Warn("browser tool not rebuilt after config save",
"reason", build.reason, "detail", build.detail,
"configPath", build.configPath, "effect", browserNotRegisteredEffect)
return false, haveExisting, build.reason
}
if haveExisting {
if err := a.caps.ReplaceTool(build.tool); err != nil {
closeBrowserManager(log, build.mgr)
log.Warn("browser tool replace failed", "error", err)
return false, true, "replace-failed"
}
closeOldBrowserTool(log, existing.Value)
return true, true, ""
}
if err := a.caps.RegisterTool(build.tool); err != nil {
closeBrowserManager(log, build.mgr)
log.Warn("browser tool register failed after config save", "error", err)
return false, false, "register-failed"
}
log.Info("browser tool registered after config save",
"source", build.source, "headless", build.headless, "configSource", build.configSource)
return true, true, ""
}
// shotOutboxOf 取旧条目上的附件箱(重建时沿用:模型刚拍完还没送出去的那一张不能丢)。
// 用结构断言而不是具体类型:换工具实现只要还暴露 ShotOutbox 就接得上。
func shotOutboxOf(value any) *browsertools.ShotOutbox {
holder, ok := value.(interface {
ShotOutbox() *browsertools.ShotOutbox
})
if !ok {
return nil
}
return holder.ShotOutbox()
}
// closeOldBrowserTool 关掉被换下来的工具(它持有进程外资源:我们起的真 Chromium、CDP 连接、
// profile 锁)。不关就是每次保存泄漏一个浏览器进程。
func closeOldBrowserTool(log *slog.Logger, value any) {
closer, ok := value.(interface{ Close() error })
if !ok {
return
}
if err := closer.Close(); err != nil {
log.Warn("old browser tool close failed", "error", err)
}
}
// closeBrowserManager 关掉刚建好但没能接上的管理器(注册被拒 / 替换失败时的收尾)。
func closeBrowserManager(log *slog.Logger, mgr *browsercap.Manager) {
if mgr == nil {
return
}
if err := mgr.Close(); err != nil {
log.Warn("browser manager close failed", "error", err)
}
}
+415
View File
@@ -0,0 +1,415 @@
package main
// 本文件是 design/28 §8 **B4(M3 面板入口)的接线验证**(切片 Y4):
// 声明真的进了面板目录、六个动作真的能分发、读写面分类正确、保存真的热重载。
//
// 为什么这些断言必须走**真实面板中枢**(`pluginmgr.NewPanels`)而不是只测函数:
//
// - 「动作可不可调」由声明推出(`pluginmgr.actionIndex`),viewer 的写面在**快照裁剪**
// 里被摘掉(`paneldecl.filterPanel`)—— 两件事都不是本包能自证的;
// - 保存的语义是「盘上改了 + 运行里的工具也换了」,只有从**能力注册表**读回工具才能验。
//
// 真浏览器不在这里:`buildBrowserTool` 只按配置探测**可执行文件是否存在**,因此本文件用
// 一个假的可执行文件就能走完装配(真机 e2e 见 `wire_browser_e2e_test.go`,默认 Skip)。
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
auth "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserPanelFixture 是 B4 的夹具:真 hub + 已注册的 browser 工具(假可执行文件)。
type browserPanelFixture struct {
app *app
hub *pluginmgr.Panels
log *slog.Logger
out *bytes.Buffer
path string
}
// newBrowserPanelFixture 造夹具并完成接线。
func newBrowserPanelFixture(t *testing.T) *browserPanelFixture {
t.Helper()
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)
}
writeBrowserConfig(t, cfg, `{"executable":`+strconv.Quote(exe)+`}`)
var out bytes.Buffer
a := &app{
cfg: cfg,
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
caps: registry.New(),
}
a.wireBrowser()
if _, ok := browserEntry(a); !ok {
t.Fatalf("夹具前提不成立:明明可用却没注册(日志:%s)", out.String())
}
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
log := logger.WithSource(a.log, "test/panel")
wireBrowserPanel(hub, a, log)
fx := &browserPanelFixture{
app: a, hub: hub, log: log, out: &out,
path: browsercfg.DefaultConfigPath(cfg.DataDir),
}
t.Cleanup(func() {
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
if closer, ok := entry.Value.(interface{ Close() error }); ok {
_ = closer.Close()
}
}
})
return fx
}
// dispatch 调一次动作(等价于宿主动作端点的分发路径:声明 → 启用 → 处理器)。
func (fx *browserPanelFixture) dispatch(t *testing.T, action string, payload any) (any, error) {
t.Helper()
return fx.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: browsertools.PanelPluginID, Action: action, Payload: payload,
})
}
// TestWireBrowserPanel_DeclaresPanelAndActionKinds 钉住声明进了目录,且六个动作的读写面
// 分类正确(读面 viewer 可调、写面只 owner/admin —— 这是 §5.4.1 的门)。
func TestWireBrowserPanel_DeclaresPanelAndActionKinds(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
groups, err := fx.hub.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups(owner): %v", err)
}
if len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("面板快照形状 = %+v", groups)
}
item := groups[0].Panels[0]
if item.PluginID != browsertools.PanelPluginID || item.Panel.ID != browsertools.PanelIDSettings {
t.Fatalf("面板标识 = %s/%s", item.PluginID, item.Panel.ID)
}
if len(item.Panel.Sections) != 4 {
t.Fatalf("区块数 = %d, want 4(配置 / 状态 / 指纹 / 截图)", len(item.Panel.Sections))
}
wantKinds := map[string]pluginmgr.ActionKind{
browsertools.ActionState: pluginmgr.ActionRead,
browsertools.ActionShots: pluginmgr.ActionRead,
browsertools.ActionFingerprint: pluginmgr.ActionRead,
browsertools.ActionSaveConfig: pluginmgr.ActionWrite,
browsertools.ActionShoot: pluginmgr.ActionWrite,
browsertools.ActionCheck: pluginmgr.ActionWrite,
}
for name, want := range wantKinds {
got, ok := fx.hub.Action(browsertools.PanelPluginID, name)
if !ok {
t.Errorf("动作 %s 没进声明索引(面板会被宿主拒掉或点了没反应)", name)
continue
}
if got != want {
t.Errorf("动作 %s 的读写面 = %q, want %q", name, got, want)
}
}
}
// TestWireBrowserPanel_ViewerSeesReadOnlyProjection 钉住角色裁剪:viewer 看到的分组里
// **没有**表单(写面)与行内动作(写动作),只留只读表格 —— 这是「只读监控投影」的定义。
func TestWireBrowserPanel_ViewerSeesReadOnlyProjection(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
groups, err := fx.hub.Groups(auth.RoleViewer)
if err != nil {
t.Fatalf("Groups(viewer): %v", err)
}
if len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("viewer 的面板快照形状 = %+v", groups)
}
for _, s := range groups[0].Panels[0].Panel.Sections {
if s.Type == "form" {
t.Errorf("viewer 拿到了写表单(%s)", s.ID)
}
if len(s.RowActions) > 0 {
t.Errorf("viewer 拿到了行内写动作(%s:%+v)", s.ID, s.RowActions)
}
}
// 对照:owner 看得到表单与行内动作(否则上面的断言在「什么都没渲染」时也会过)。
ownerGroups, err := fx.hub.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups(owner): %v", err)
}
forms, rowActions := 0, 0
for _, s := range ownerGroups[0].Panels[0].Panel.Sections {
if s.Type == "form" {
forms++
}
rowActions += len(s.RowActions)
}
if forms != 1 || rowActions != 2 {
t.Fatalf("owner 看到的表单/行内动作 = %d/%d, want 1/2", forms, rowActions)
}
}
// TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser 钉住三件事:
// 读动作真的能分发、回的是 `{rows:[…]}`、且**没有**启动浏览器会话(面板取数无副作用)。
func TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
for _, action := range []string{
browsertools.ActionState, browsertools.ActionShots, browsertools.ActionFingerprint,
} {
res, err := fx.dispatch(t, action, map[string]any{})
if err != nil {
t.Fatalf("%s: %v", action, err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("%s 结果类型 = %T", action, res)
}
rows, ok := m["rows"].([]map[string]any)
if !ok || rows == nil {
t.Fatalf("%s 的 rows = %#v(必须是数组,哪怕是空的)", action, m["rows"])
}
// 状态与指纹**恒有一行**(事实面),最近截图在没有截图时如实为空表。
if action != browsertools.ActionShots && len(rows) == 0 {
t.Fatalf("%s 应当至少一行事实:%#v", action, m["rows"])
}
}
tool := browserToolValue(t, fx)
if snap := tool.State(); snap.SessionLive {
t.Error("读动作启动了浏览器会话:面板取数必须无进程外副作用")
}
}
// TestWireBrowserPanel_SaveConfigHotReloads 钉住「保存即生效」:落盘 + 换掉运行中的工具
// (面板的意义就是不改 `.env`、不重启)。
func TestWireBrowserPanel_SaveConfigHotReloads(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
res, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{
browsertools.FieldViewportWidth: float64(1600),
browsertools.FieldViewportHeight: float64(900),
browsertools.FieldIdleTimeout: float64(120),
browsertools.FieldHeadless: true,
},
})
if err != nil {
t.Fatalf("saveBrowserConfig: %v", err)
}
m, _ := res.(map[string]any)
if m["applied"] != true || m["registered"] != true {
t.Fatalf("保存结论 = %#v(要求盘上与运行中都生效)", m)
}
snap := browserToolValue(t, fx).State()
if snap.ViewportWidth != 1600 || snap.ViewportHeight != 900 || snap.IdleTimeoutSeconds != 120 || !snap.Headless {
t.Fatalf("热重载后的生效配置 = %+v,与保存内容不一致", snap)
}
raw, err := os.ReadFile(fx.path)
if err != nil {
t.Fatalf("读回配置: %v", err)
}
var onDisk browsercfg.Config
if err := json.Unmarshal(raw, &onDisk); err != nil {
t.Fatalf("配置不是合法 JSON: %v", err)
}
if onDisk.ViewportWidth != 1600 || !onDisk.Headless {
t.Fatalf("盘上的配置 = %+v", onDisk)
}
// 面板默认值跟着刷新(前端提交成功后会重取快照,表单因此显示「刚落盘的那份」)。
groups, _ := fx.hub.Groups(auth.RoleOwner)
for _, s := range groups[0].Panels[0].Panel.Sections {
if s.Type != "form" {
continue
}
for _, f := range s.Fields {
if f.Key == browsertools.FieldViewportWidth && f.Default != 1600 {
t.Errorf("面板默认值没刷新:viewportWidth=%v", f.Default)
}
}
}
}
// TestWireBrowserPanel_SaveConfigRejectsUnknownField 钉住「未知字段不许静默忽略」:
// 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
func TestWireBrowserPanel_SaveConfigRejectsUnknownField(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
before, _ := os.ReadFile(fx.path)
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{"nope": "x"},
})
ae := requireActionError(t, err)
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
t.Fatalf("失败形状 = %+v, want invalid_payload/400", ae)
}
after, _ := os.ReadFile(fx.path)
if !bytes.Equal(before, after) {
t.Error("被拒的保存改动了盘上的配置文件")
}
}
// TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable 钉住事务:保存前有可用工具而新配置
// 让它不可用(路径写错)⇒ **回滚**且如实报冲突,绝不让「盘上的」与「跑着的」静默拆开。
func TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
before, _ := os.ReadFile(fx.path)
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{
browsertools.FieldExecutable: filepath.Join(t.TempDir(), "no-such-browser.exe"),
},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeConfigRejected || ae.Status != http.StatusConflict {
t.Fatalf("失败形状 = %+v, want %s/409", ae, browserCodeConfigRejected)
}
after, _ := os.ReadFile(fx.path)
if !bytes.Equal(before, after) {
t.Error("没能生效的保存没有回滚:盘上留下了会让工具消失的配置")
}
if _, ok := browserEntry(fx.app); !ok {
t.Error("回滚之后原来的工具也不见了(应当原样保留)")
}
}
// TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget 钉住两个写动作的诚实下限:
// 没有打开的页面时「拍一张」如实报 browser_session_not_open,绝不偷偷替模型开一页。
func TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
_, err := fx.dispatch(t, browsertools.ActionShoot, map[string]any{
"row": map[string]any{"source": "本机 Chrome"},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeNoSession || ae.Status != http.StatusConflict {
t.Fatalf("拍一张的失败形状 = %+v, want %s/409", ae, browserCodeNoSession)
}
}
// TestWireBrowserPanel_WithoutToolIsHonest 钉住「没有浏览器来源」时的两个写动作:如实报
// browser_unavailable(而不是 500 / 假成功),且声明仍然在(面板可以配,配好保存即生效)。
func TestWireBrowserPanel_WithoutToolIsHonest(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := &app{cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New()}
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
wireBrowserPanel(hub, a, logger.WithSource(a.log, "test/panel"))
groups, gerr := hub.Groups(auth.RoleOwner)
if gerr != nil || len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("没有工具时面板也该声明(可以在面板里配):%+v, %v", groups, gerr)
}
for _, action := range []string{browsertools.ActionShoot, browsertools.ActionCheck} {
_, derr := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: browsertools.PanelPluginID, Action: action,
})
ae := requireActionError(t, derr)
if ae.Code != browserCodeUnavailable {
t.Errorf("%s 的失败形状 = %+v, want %s", action, ae, browserCodeUnavailable)
}
}
}
/* ---------------------------------------------------------------------------
* 助手
* ------------------------------------------------------------------------- */
// browserToolValue 从能力面取回 browser 工具。
func browserToolValue(t *testing.T, fx *browserPanelFixture) *browsertools.Tool {
t.Helper()
entry, err := fx.app.caps.Get(browsertools.ToolName)
if err != nil {
t.Fatalf("能力面里没有 browser 工具: %v", err)
}
tool, ok := entry.Value.(*browsertools.Tool)
if !ok {
t.Fatalf("能力面里的值不是 browser 工具: %T", entry.Value)
}
return tool
}
// requireActionError 断言错误是一条**结构化动作失败**(错误码 + 文案 + 状态)。
func requireActionError(t *testing.T, err error) *pluginmgr.ActionError {
t.Helper()
if err == nil {
t.Fatal("期望一个结构化失败,得到 nil")
}
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v(%T),不是 structured ActionError", err, err)
}
if ae.Message == "" {
t.Error("结构化失败没有文案(§5.4.6 要求错误码与文案分离且都在)")
}
return ae
}
// TestWireBrowserPanel_ConfigFileIsPrivate 钉住落盘权限:browser.json 可能带显式 CDP 端点
// 与 profile 位置,按 0600 落盘(`browsercfg.Save` 的口径,这里从**面板保存**这条路上验)。
func TestWireBrowserPanel_ConfigFileIsPrivate(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
if _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
}); err != nil {
t.Fatalf("saveBrowserConfig: %v", err)
}
if runtime.GOOS == "windows" {
// Windows 不体现 POSIX 权限位(仓库既有的 store_test.go 同口径跳过)。
t.Skip("Windows 不体现 POSIX 权限位;0600 的判定在 store_test.go 覆盖")
}
info, err := os.Stat(fx.path)
if err != nil {
t.Fatalf("stat 配置: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("配置权限 = %o, want 600", perm)
}
}
// TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile 钉住「坏配置不静默顶替」:
// 文件非法时 `currentConfig` 返回错误(面板据此如实告知),不悄悄回落到默认值。
func TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
if err := os.WriteFile(fx.path, []byte("{ not json"), 0o600); err != nil {
t.Fatalf("写坏配置: %v", err)
}
if _, err := fx.dispatch(t, browsertools.ActionState, map[string]any{}); err != nil {
t.Fatalf("读动作不该因为配置坏而失败(它用默认值兜底显示现状): %v", err)
}
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeConfigUnreadable {
t.Fatalf("坏配置下保存的失败形状 = %+v, want %s", ae, browserCodeConfigUnreadable)
}
raw, _ := os.ReadFile(fx.path)
if string(raw) != "{ not json" {
t.Error("坏配置在保存失败后被改写了(不该动它)")
}
if !errors.Is(err, browsercfg.ErrInvalidConfig) {
t.Logf("note: 错误链未含 ErrInvalidConfig(%v)", err)
}
}