feat(browser): 交互式会话的 timeout 改为必填,并补参数校验与测试

此前 `timeout` 默认 10m:Agent 不传也能开会话,于是"忘记设时长"会静默拿到一个
10 分钟就自己消失的浏览器会话,排查起来像是浏览器不稳。

改为**必填**:
- 新增 `parseBrowserSessionTimeout`(空值 → "timeout is required;创建浏览器会话时必须
  明确指定关闭时长,如 15m 或 2h";非法或 ≤0 → 明确报错),工具 schema 的
  `required` 加上 `timeout` 并同步描述;
- 缺参时返回可读错误结果(而不是静默套默认值);
- 新增 `plugin_test.go`(62 行)钉住「不传 timeout 必须报错」等边界;
- 示例版本 2.3.0 → 2.4.0,顺带对齐结构体字段(gofmt)。

验证:`go vet ./...` 干净、`go test ./...` → ok(browser 模块自带 go.mod)。
This commit is contained in:
JianFeeeee
2026-09-12 20:17:18 +08:00
parent fcb7490f63
commit 8c10b7ecc7
3 changed files with 132 additions and 43 deletions

View File

@ -2,7 +2,7 @@
"name": "browser", "name": "browser",
"name_zh": "浏览器", "name_zh": "浏览器",
"name_en": "Browser", "name_en": "Browser",
"version": "2.3.0", "version": "2.4.0",
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",

View File

@ -159,6 +159,18 @@ func errResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg} return map[string]interface{}{"isError": true, "content": msg}
} }
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
raw := strings.TrimSpace(readArg(args, "timeout", ""))
if raw == "" {
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
}
timeout, err := time.ParseDuration(raw)
if err != nil || timeout <= 0 {
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
}
return timeout, nil
}
func newHTTPClient(timeout int, proxyURL string) *http.Client { func newHTTPClient(timeout int, proxyURL string) *http.Client {
transport := &http.Transport{ transport := &http.Transport{
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
@ -276,14 +288,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{ s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start", Name: tp + "start",
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。", Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"}, "url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"}, "timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"}, "profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
}, },
"required": []string{"timeout"},
}, },
}, p.handleBrowserStart) }, p.handleBrowserStart)
@ -881,10 +894,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
} }
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m") timeout, err := parseBrowserSessionTimeout(args)
timeout, err := time.ParseDuration(timeoutStr)
if err != nil { if err != nil {
timeout = 10 * time.Minute return errResult(err.Error()), nil
} }
source := readArg(args, "source", "") source := readArg(args, "source", "")
@ -899,13 +911,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
s.mu.Lock() s.mu.Lock()
id := s.id id := s.id
cur := s.currentURL cur := s.currentURL
s.createdAt = time.Now()
s.timeout = timeout
closesAt := s.createdAt.Add(timeout)
s.mu.Unlock() s.mu.Unlock()
p.mu.Unlock() p.mu.Unlock()
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{ return map[string]interface{}{
"id": id, "id": id,
"status": "reused", "status": "reused",
"url": cur, "url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)", "timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
}, nil }, nil
} }
} }
@ -972,13 +990,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
session.currentURL = initURL session.currentURL = initURL
} }
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source) closesAt := session.createdAt.Add(timeout)
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{ return map[string]interface{}{
"id": id, "id": id,
"status": "created", "status": "created",
"mode": "shared-backend", "mode": "shared-backend",
"url": initURL, "url": initURL,
"timeout": timeout.String(), "timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
}, nil }, nil
} }
@ -1204,13 +1224,20 @@ func (p *Plugin) cleanupLoop() {
case <-p.stopCh: case <-p.stopCh:
return return
case <-ticker.C: case <-ticker.C:
now := time.Now()
p.mu.Lock() p.mu.Lock()
for id, s := range p.sessions { for id, s := range p.sessions {
if time.Since(s.createdAt) >= s.timeout { s.mu.Lock()
log.Printf("[%s] cleanup: browser session %s expired", p.name, id) closesAt := s.createdAt.Add(s.timeout)
expired := !now.Before(closesAt)
s.mu.Unlock()
if expired {
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
delete(p.sessions, id) delete(p.sessions, id)
s.Close() s.Close()
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id)) // NoMemory:会话生命周期通知,不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
} }
} }
p.mu.Unlock() p.mu.Unlock()

View File

@ -0,0 +1,62 @@
package main
import (
"strings"
"testing"
"time"
)
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
t.Fatalf("expected required timeout error, got %v", err)
}
}
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
if err != nil {
t.Fatal(err)
}
if got != 2*time.Hour+30*time.Minute {
t.Fatalf("timeout=%v", got)
}
}
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
for _, value := range []string{"invalid", "0s", "-1m"} {
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
t.Errorf("timeout %q should be rejected", value)
}
}
}
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
p := &Plugin{
name: "browser",
sessions: map[string]*BrowserSession{
"browser_1": {
id: "browser_1",
shared: true,
sessionKey: "qq",
createdAt: time.Now().Add(-time.Hour),
timeout: time.Minute,
currentURL: "https://example.com",
},
},
}
before := time.Now()
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
if err != nil {
t.Fatal(err)
}
out := result.(map[string]interface{})
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
t.Fatalf("unexpected result: %#v", out)
}
s := p.sessions["browser_1"]
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
}
}