mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-15 12:15:11 +00:00
用户报告:截图直接返回二进制(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 模式构建通过。
236 lines
8.8 KiB
Go
236 lines
8.8 KiB
Go
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")
|
||
}
|