fix(browser)!: 截图落盘返回路径,不再回 base64

用户报告:截图直接返回二进制(base64)对 agent 十分不友好。确认属实,且
生产日志里有 agent 的**绕行实证**(2026-10-10):

    browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image

那一轮跑了 920 秒。

## 为什么 base64 是错的

工具结果是 JSON 文本,base64 PNG 会**整段进模型上下文**,而模型看不见
这些字符里的图 —— 纯粹浪费:

    视口截图 100-500KB ⇒ base64 后 137-685KB(约 3-17 万 token)

更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,而模型做不到。
内核早有对应约定:`describe_image` 的 path 参数描述写着
「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
本插件返回路径才是合上这个约定。

## 改动

- 截图落盘到 `<data_dir>/browser_screenshots/`,返回**绝对路径**
- 结果里带 `hint` 直接告诉模型「调 describe_image(path=...)」——
  工具描述模型可能漏看,结果里的提示更近
- `inline=true` 才额外带 base64/data_uri(只有确实拿不到文件系统的场景才用)
- 落盘失败**明确报错**,不静默退回 base64:那会让「上下文被灌爆」
  在磁盘满/权限错时重新出现且没有迹象
- 文件名带毫秒时间戳 + 会话 id:同一轮内多次截图是常态
  (生产日志里一轮截了 3 次),固定名会静默覆盖
- 会话 id 过滤路径分隔符(它来自模型且被拼进路径,否则 `../` 能写出目录外)
- 未注入 data_dir 时降级到临时目录,而不是让截图整条失败

## 判据(5 条,可杀变异,逐个实测)

`screenshot_test.go`:
- 落盘 + 返回绝对路径 + 内容逐字节一致
- 连续截图不互相覆盖(文件名去时间戳 → FAIL)
- 会话 id 路径穿越被挡(`../../etc/evil` 仍在目录内)
- 未注入目录时降级而非报错
- 工具描述必须提 path 与 describe_image(描述退回 base64 → FAIL)
- handleScreenshot 返回结构必须是 path 而非 base64
  (整段替换为旧实现 → FAIL;保留 path 但无条件加 base64 → FAIL)

**判据本身修过两次(都是变异测试发现的)**:
1. 第一版只看 `res["base64"] = b64`,把 base64 写成 map 字面量的键就绕过了
2. 第二版按整段文本查 "inline",而**注释里也有 "inline"** ⇒ 把保护开关
   改成 `if false` 仍能通过。改为先 `stripLineComments` 再查真实代码行

全 20 个 example 编译通过;browser 插件 `go test` + plugin 模式构建通过。
This commit is contained in:
JianFeeeee
2026-10-10 23:26:19 +08:00
parent e0ee0c43bb
commit 25bd243662
3 changed files with 327 additions and 11 deletions

View File

@ -31,7 +31,7 @@
| `browser_type` | 输入文本 |
| `browser_scroll` | 滚动页面 |
| `browser_html` | 取当前页 HTML |
| `browser_screenshot` | 截图 |
| `browser_screenshot` | 截图,**保存为 PNG 并返回路径**(拿 path 调 `describe_image` / `ocr_image`) |
| `browser_install` | 安装 systemd 托管的共享浏览器后端 |
| `browser_close` | 关闭会话 |

View File

@ -42,6 +42,24 @@ type Plugin struct {
stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
// screenshotsDir 是截图落盘目录(<data>/browser_screenshots)。
//
// ★ 为何要落盘而不是直接回 base64(2026-10-10 修):
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文。
// 实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),而模型**看不见**这些字符里的图。
//
// 更糟的是它为每个消费方制造了同一道重复劳动:生产日志里 2026-10-10
// 那轮 agent 调 browser_screenshot 后紧跟着 cmd_run(自己 base64 解码
// 写文件),最后才 describe_image —— 它在**绕开**这个设计,
// 而那一轮跑了 920 秒。
//
// 内核早已有约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传
// file 路径)」。本插件返回路径才是合上这个约定。
screenshotsDir string
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
@ -231,9 +249,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
// 截图落盘目录:<data>/browser_screenshots
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
p.screenshotsDir = filepath.Join(s2, "browser_screenshots")
}
}
@ -319,14 +339,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, p.handleNavigate)
s.RegisterTool(tp+"screenshot", sdk.ToolDef{
Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。",
Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图,**保存为 PNG 文件并返回绝对路径**。" +
"要看图请拿返回的 path 调 describe_image(path=...);要提取文字调 ocr_image(path=...)。" +
"(不再直接返回 base64——那样会把十几万 token 灌进上下文而你看不见图。)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
"inline": map[string]interface{}{"type": "boolean", "description": "可选:额外返回 base64/data_uri(默认 false)。仅当调用方拿不到文件系统时才需要;带上会把大段 base64 灌进上下文"},
},
"required": []string{"id"},
},
@ -1142,14 +1165,72 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
if err != nil {
return errResult("screenshot failed: " + err.Error()), nil
}
b64 := base64.StdEncoding.EncodeToString(buf)
return map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil
// ★ 默认落盘并返回**路径**(2026-10-10 改)。
//
// 为何不改回直接回 base64:那是把「解码 + 写文件」这件重复劳动
// 推给每一个消费方,而模型自己做不到(它看不见 base64 里的图)。
// 生产日志实证过 agent 的绕行链路:browser_screenshot → cmd_run 自己
// 解码写盘 → describe_image。
path, saveErr := p.saveScreenshot(buf, format, id)
if saveErr != nil {
// 落盘失败时不静默退回 base64:那会让「上下文被灌爆」这件事
// 在磁盘满/权限错时重新出现,而且没有迹象。
return errResult("screenshot saved failed: " + saveErr.Error()), nil
}
res := map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
// path 是**主要返回值**:模型应拿它去调 describe_image / ocr_image,
// 或交给 files_* / cmd_run 做后续处理。
"path": path,
// hint 直接写在结果里:工具描述模型可能漏看,结果里的提示更近。
"hint": "截图已保存。要看图请调 describe_image(path=...);" +
"要 OCR 请调 ocr_image(path=...)。不要再自己解码 base64。",
}
// inline=true 时才额外带 base64:只有确实拿不到文件系统
// (如前端要把图直接嵌进消息)的少数场景才该用。
if v, ok := args["inline"].(bool); ok && v {
b64 := base64.StdEncoding.EncodeToString(buf)
res["base64"] = b64
res["data_uri"] = fmt.Sprintf("data:image/png;base64,%s", b64)
}
return res, nil
}
// saveScreenshot 把截图写入 <data>/browser_screenshots/,返回**绝对路径**。
//
// 为何文件名带时间戳与会话 id:同一会话连续截图是常态(生产日志里一轮
// 内截了 3 次),固定文件名会互相覆盖,而 agent 很可能还想回看前一张。
// 时间戳用 `20060102-150405.000`(毫秒):同秒内多次截图也不撞名。
func (p *Plugin) saveScreenshot(buf []byte, format, sessionID string) (string, error) {
dir := p.screenshotsDir
if dir == "" {
// 未注入 data_dir(如测试环境)时退到临时目录,而不是报错:
// 截图本身是成功的,只该降级存储位置。
dir = filepath.Join(os.TempDir(), "homeagent-browser-screenshots")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("创建截图目录 %s: %w", dir, err)
}
// 会话 id 由调用方传入,可能含路径分隔符 —— 过滤掉,
// 否则 `../` 能把截图写出目录外。
safeID := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == 0 {
return '_'
}
return r
}, sessionID)
name := fmt.Sprintf("shot-%s-%s.%s",
time.Now().Format("20060102-150405.000"), safeID, format)
full := filepath.Join(dir, name)
if err := os.WriteFile(full, buf, 0644); err != nil {
return "", fmt.Errorf("写入 %s: %w", full, err)
}
return full, nil
}
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {

View File

@ -0,0 +1,235 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 截图必须**落盘并返回路径**,而不是把 base64 塞进工具结果。
//
// # 为何这条判据重要(2026-10-10)
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文,而模型**看不见**
// 这些字符里的图。实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),纯粹是上下文浪费。
//
// 更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,
// 而模型做不到。生产日志实证过 agent 的绕行链路:
//
// browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image
//
// 那一轮跑了 920 秒。内核早有对应约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
func TestSaveScreenshotWritesFileAndReturnsPath(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
// 造一个最小 PNG 头(内容不重要,测的是落盘行为)
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}
path, err := p.saveScreenshot(data, "png", "sess-1")
if err != nil {
t.Fatalf("saveScreenshot: %v", err)
}
// ① 返回的必须是**绝对路径**且指向真实存在的文件
if !filepath.IsAbs(path) {
t.Errorf("返回的应是绝对路径(模型要拿它调 describe_image),实为 %q", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("截图的路径不可读(%s): %v", path, err)
}
if string(got) != string(data) {
t.Errorf("落盘内容与截图字节不一致:%d vs %d", len(got), len(data))
}
// ② 路径必须在 screenshotsDir 内
if !strings.HasPrefix(path, dir+string(filepath.Separator)) {
t.Errorf("截图应落在 %s 内,实为 %q", dir, path)
}
// ③ 文件名要能看出是截图且带 png 后缀
if !strings.HasSuffix(path, ".png") {
t.Errorf("文件名应带 .png 后缀,实为 %q", path)
}
if !strings.Contains(filepath.Base(path), "shot-") {
t.Errorf("文件名应可识别为截图,实为 %q", filepath.Base(path))
}
}
// 同一会话连续截图不能互相覆盖。
//
// 为何专门测:生产日志里**一轮之内**就调了 3 次 browser_screenshot,
// 而 agent 很可能还想回看前一张。固定文件名会静默覆盖。
func TestSaveScreenshotDoesNotOverwrite(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
first, err := p.saveScreenshot([]byte("AAA"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
// 确保时间戳至少差 1ms(文件名精度是毫秒)
time.Sleep(2 * time.Millisecond)
second, err := p.saveScreenshot([]byte("BBB"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("两次截图写到了同一个文件(%s)—— 前一张被覆盖", first)
}
b1, _ := os.ReadFile(first)
if string(b1) != "AAA" {
t.Errorf("第一次截图被覆盖了:%q", b1)
}
}
// 会话 id 不能把截图写出目录外(它来自模型,且被拼进路径)。
func TestSaveScreenshotRejectsPathTraversalInSessionID(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
for _, evil := range []string{"../../etc/evil", "a/../../b", `win\..\..\x`} {
path, err := p.saveScreenshot([]byte("X"), "png", evil)
if err != nil {
t.Fatalf("saveScreenshot(%q): %v", evil, err)
}
// 关键:解析后仍必须在 dir 内
abs, _ := filepath.Abs(path)
base, _ := filepath.Abs(dir)
if !strings.HasPrefix(abs, base+string(filepath.Separator)) {
t.Errorf("会话 id %q 让截图写到了目录外:%s(应在 %s 内)", evil, abs, base)
}
}
}
// 未注入 data_dir 时应降级到临时目录,而不是让截图整条失败。
//
// 理由:截图本身是成功的,只有存储位置该降级。
// 直接报错会把「环境没配」变成「功能不可用」。
func TestSaveScreenshotFallsBackWhenDirUnset(t *testing.T) {
p := &Plugin{name: "browser"} // screenshotsDir 为空
path, err := p.saveScreenshot([]byte("X"), "png", "s1")
if err != nil {
t.Fatalf("未注入目录时应降级到临时目录,实为报错: %v", err)
}
defer os.Remove(path)
if !filepath.IsAbs(path) {
t.Errorf("降级路径也应是绝对路径,实为 %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("降级路径的文件不存在: %v", err)
}
}
// 工具描述必须告诉模型「返回路径 + 拿 path 去 describe_image」。
//
// 为何钉描述而不只钉实现:模型是靠描述决定怎么用工具的。
// 描述若退回「返回 base64」,模型就会又去自己解码。
func TestScreenshotToolDescriptionMentionsPath(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, `tp+"screenshot"`)
if i < 0 {
t.Fatal("找不到 screenshot 工具注册")
}
seg := s[i:]
if end := strings.Index(seg, "}, p.handleScreenshot)"); end > 0 {
seg = seg[:end]
}
if !strings.Contains(seg, "path") {
t.Error("工具描述必须提到返回 path(模型靠描述决定怎么用工具)")
}
if !strings.Contains(seg, "describe_image") {
t.Error("工具描述应指明拿 path 去调 describe_image —— " +
"不写的话模型不知道下一步该做什么")
}
if strings.Contains(seg, "返回 base64 编码的 PNG 图片") {
t.Error("工具描述退回了「返回 base64」,与实现不符(实现已改为落盘 + 返回路径)")
}
}
// handleScreenshot 的**返回结构**必须是 path 而不是 base64。
//
// # 为何用源码断言而不是调用它
//
// handleScreenshot 需要真实的 chromedp 会话(真 Chromium + CDP),
// 单测跑不动。而「结果里返回什么字段」是**静态可判定**的结构事实,
// 源码断言能可靠守住,回归时立刻红。
//
// 为何需要这条额外判据:saveScreenshot 的单测只覆盖落盘函数本身,
// 把 handleScreenshot 改回「返回 base64」**不会**让它变红
// (变异测试实测过 —— 这是一个真实的判据漏洞)。
func TestHandleScreenshotReturnsPathNotBase64(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
if i < 0 {
t.Fatal("找不到 handleScreenshot")
}
seg := s[i:]
if end := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); end > 0 {
seg = seg[:end]
}
// ① 必须调用 saveScreenshot 并把它返回的路径带进结果
if !strings.Contains(seg, "p.saveScreenshot(") {
t.Error("handleScreenshot 必须调用 saveScreenshot 落盘(而不是直接编 base64)")
}
if !strings.Contains(seg, `"path":`) {
t.Error("handleScreenshot 的结果必须含 \"path\" 字段 —— " +
"模型拿它去调 describe_image / ocr_image")
}
// ② base64 只能是**可选**(受 inline 开关控制),不能无条件出现。
//
// 注意:这条要挡住的是「无条件把 base64 塞进返回值」,因为那会让
// 上下文被灌爆——正是本次要修的问题。变异测试发现单看
// `res["base64"] = b64` 是否存在不够:把 base64 写成 map 字面量的
// 一个键(`"base64": ...`)同样绕过它,所以两种写法都要查。
// ⚠️ 这条必须查**真实代码行**而不是整段文本:注释里也出现过 "inline"
// 和 "base64",按整段 Contains 判会把注释算成保护开关
// (变异测试实测:把 `if ...inline...` 改成 `if false` 仍能通过)。
code := stripLineComments(seg)
if strings.Contains(code, `"base64"`) || strings.Contains(code, `data_uri`) {
if !strings.Contains(code, "args[\"inline\"]") {
t.Error("返回结构里出现了 base64/data_uri,但没有真实的 inline 开关保护:" +
"无条件带上就等于把十几万 token 又灌回上下文(本次修复要消掉的问题)")
}
}
// ③ 落盘失败时不得静默退回 base64
if strings.Contains(code, "saveErr") && !strings.Contains(code, `return errResult("screenshot saved failed`) {
t.Error("screenshot saved 失败时必须明确报错(return errResult)," +
"不得静默退回 base64 —— 那会让上下文被灌爆这件事没有迹象")
}
}
// stripLineComments 去掉整行注释与行尾注释,只留可执行代码。
//
// 为何需要:源码断言若把注释也算进去,就会出现「注释里的词冒充保护逻辑」
// 这种假通过。变异测试实测过这个坑。
func stripLineComments(src string) string {
var out []string
for _, line := range strings.Split(src, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "//") {
continue
}
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
out = append(out, line)
}
return strings.Join(out, "\n")
}