From 29181245ce750195dc8610dbd0516785fedab253 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:03:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=8E=A2=E6=B5=8B=E5=BA=93=EF=BC=88?= =?UTF-8?q?internal/capability/browsercfg=EF=BC=8C=E7=BA=AF=E5=BA=93?= =?UTF-8?q?=E9=9B=B6=E6=8E=A5=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json (0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、 视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium 可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用); profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。 本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。 --- internal/capability/browsercfg/config.go | 185 ++++++++++++ internal/capability/browsercfg/config_test.go | 194 ++++++++++++ internal/capability/browsercfg/doc.go | 22 ++ internal/capability/browsercfg/errors.go | 54 ++++ .../capability/browsercfg/helpers_test.go | 36 +++ internal/capability/browsercfg/probe.go | 275 +++++++++++++++++ internal/capability/browsercfg/probe_test.go | 281 ++++++++++++++++++ internal/capability/browsercfg/store.go | 142 +++++++++ internal/capability/browsercfg/store_test.go | 209 +++++++++++++ 9 files changed, 1398 insertions(+) create mode 100644 internal/capability/browsercfg/config.go create mode 100644 internal/capability/browsercfg/config_test.go create mode 100644 internal/capability/browsercfg/doc.go create mode 100644 internal/capability/browsercfg/errors.go create mode 100644 internal/capability/browsercfg/helpers_test.go create mode 100644 internal/capability/browsercfg/probe.go create mode 100644 internal/capability/browsercfg/probe_test.go create mode 100644 internal/capability/browsercfg/store.go create mode 100644 internal/capability/browsercfg/store_test.go diff --git a/internal/capability/browsercfg/config.go b/internal/capability/browsercfg/config.go new file mode 100644 index 0000000..88ecda3 --- /dev/null +++ b/internal/capability/browsercfg/config.go @@ -0,0 +1,185 @@ +package browsercfg + +import ( + "fmt" + "net/url" + "path/filepath" + "strings" + "time" +) + +// ---- 默认值与硬上限(design/28 §3;一切具名,本包不存在裸数字)---- +// +// 口径(design/28 §8 B1):`.env` 已冻结为「最小化功能可跑」的引导面 ⇒ B1 的一切可调项 +// **只**落在 `$DATA_DIR/config/plugins/browser.json` + 插件面板上,代码只提供默认值与 +// 自动探测。因此下面的常量是「配置文件缺失 / 字段 ≤0」时的回落值。 +const ( + // DefaultHeadless 默认档 = **有头**(design/28 §2:「默认档 = 有头 + 持久化 profile」)。 + // 有头的目的是过风控;无头只在「对反爬不敏感 / 要省资源」时由面板显式降级。 + DefaultHeadless = false + + // DefaultViewportWidth / DefaultViewportHeight 是视口默认 1280×800:桌面常见分辨率的 + // 下界,且与 design/28 §2「指纹一致性」对齐 —— 刻意避开 800×600 那种一眼自动化的尺寸。 + DefaultViewportWidth = 1280 + DefaultViewportHeight = 800 + + // MaxViewportWidth / MaxViewportHeight 是视口硬上限 7680×4320(8K,design/28 §3 + // 「视口尺寸上限」)。超过即判配置非法:不设上限等于允许一次渲染把整机内存交出去。 + MaxViewportWidth = 7680 + MaxViewportHeight = 4320 + + // DefaultTimeoutSeconds 是单次浏览器动作超时 30s:够一次冷启动导航 + 渲染完成。 + // 它是**正常路径**的预算,不是重试预算(design/28 §3)。 + DefaultTimeoutSeconds = 30 + + // DefaultIdleTimeoutSeconds 是空闲回收 300s(design/28 §3「空闲 N 秒回收」的常量): + // 5 分钟无动作即回收会话,避免一个 profile 被长期占着。 + DefaultIdleTimeoutSeconds = 300 + + // DefaultConcurrency 并发上限恒为 1,**不是可调项**:同一 user-data-dir 起两个实例 + // 会互锁(design/28 §3、§6 坑 2),且 profile 含凭据,串行最省心。Normalize 把任何 + // >1 的取值夹回 1,而不是照单收下。 + DefaultConcurrency = 1 + + // DefaultScreenshotMaxBytes 是单张截图字节上限 8 MiB:1280×800 的 PNG/JPEG 远小于它, + // 而 vision 管线也只该吃这么大的图(design/28 §3「超限如实截断/缩放并标注」)。 + DefaultScreenshotMaxBytes = 8 << 20 + + // MaxScreenshotMaxBytes 是单张截图字节上限的**硬上限** 32 MiB(design/28 §3 + // 「单张字节上限」):在默认 8 MiB 之上给面板留「显式放大」的余量,但绝不让单张截图无界。 + MaxScreenshotMaxBytes = 32 << 20 + + // DefaultCDPEndpoint 是「已在跑的容器」的候选端点:CDP 默认调试端口 9222 绑回环。 + // 它只是**出站探测目标**,不新开我们的监听端口(AGENTS §9 红线、design/28 §3)。 + DefaultCDPEndpoint = "http://127.0.0.1:9222" +) + +// Config 是 browser 插件的落盘配置(`$DATA_DIR/config/plugins/browser.json`, +// design/28 §1 M2、§8 B1)。零值经 Normalize 即为「全默认」,所以 `Config{}` 是安全的。 +type Config struct { + // Headless 为 true 用无头;默认 false(有头,见 DefaultHeadless)。 + // 用 plain bool 而不是 *bool:默认值本身就是 false,「缺失」与「显式 false」同义,无歧义。 + Headless bool `json:"headless"` + + // Executable 显式指定本机 Chrome/Chromium 可执行文件;空 ⇒ 交给自动探测。 + Executable string `json:"executable,omitempty"` + + // CDPEndpoint 显式指定已在跑的 CDP 端点(如容器)。空 ⇒ 交给自动探测;**显式给出时 + // 它取代自动探测**:连不上就如实报 cdp_unreachable,不偷偷回退本机(design/28 §6 坑 5)。 + CDPEndpoint string `json:"cdpEndpoint,omitempty"` + + // ProfileDir 是持久化 profile(user-data-dir)目录,**含 cookie / 登录态等凭据** + // (design/28 §2 三件套之 2):空 ⇒ 默认 `$DATA_DIR/browser/profile`。使用前须过 + // CheckProfileLocation(不得进 git 工作树),目录 0700、内容绝不进上下文。 + ProfileDir string `json:"profileDir,omitempty"` + + // ViewportWidth / ViewportHeight 是视口尺寸;≤0 ⇒ 用默认(见 DefaultViewport*), + // 超过上限 ⇒ Validate 判非法(见 MaxViewport*)。 + ViewportWidth int `json:"viewportWidth"` + ViewportHeight int `json:"viewportHeight"` + + // TimeoutSeconds 是单次动作超时;≤0 ⇒ 用默认(见 DefaultTimeoutSeconds)。 + TimeoutSeconds int `json:"timeoutSeconds"` + + // IdleTimeoutSeconds 是空闲回收秒数;≤0 ⇒ 用默认(见 DefaultIdleTimeoutSeconds)。 + IdleTimeoutSeconds int `json:"idleTimeoutSeconds"` + + // Concurrency 是并发上限;≤0 ⇒ 用默认、>1 ⇒ 夹回 1(见 DefaultConcurrency:profile 锁)。 + Concurrency int `json:"concurrency"` + + // ScreenshotMaxBytes 是单张截图字节上限;≤0 ⇒ 用默认、超过硬上限 ⇒ Validate 判非法。 + ScreenshotMaxBytes int `json:"screenshotMaxBytes"` +} + +// Defaults 返回一组全默认的配置(ProfileDir 落在 dataDir 下)。 +func Defaults(dataDir string) Config { + return Config{}.Normalize(dataDir) +} + +// Normalize 用常量默认补足「≤0 / 缺失」的字段,并把并发夹到 1。它**只填不改**: +// 越界的值原样保留,交给 Validate 拒绝 —— 悄悄改小会让面板显示的与生效的不是一回事。 +func (c Config) Normalize(dataDir string) Config { + if c.ViewportWidth <= 0 { + c.ViewportWidth = DefaultViewportWidth + } + if c.ViewportHeight <= 0 { + c.ViewportHeight = DefaultViewportHeight + } + if c.TimeoutSeconds <= 0 { + c.TimeoutSeconds = DefaultTimeoutSeconds + } + if c.IdleTimeoutSeconds <= 0 { + c.IdleTimeoutSeconds = DefaultIdleTimeoutSeconds + } + if c.Concurrency <= 0 { + c.Concurrency = DefaultConcurrency + } + if c.Concurrency > DefaultConcurrency { + c.Concurrency = DefaultConcurrency + } + if c.ScreenshotMaxBytes <= 0 { + c.ScreenshotMaxBytes = DefaultScreenshotMaxBytes + } + if strings.TrimSpace(c.ProfileDir) == "" { + c.ProfileDir = DefaultProfileDir(dataDir) + } + return c +} + +// Validate 检查配置是否在硬上限内(design/28 §3)。它判的是**原始值**(未 Normalize 也可用): +// 负值/零按「用默认」放行(≤0 的语义由 Normalize 承担),只有**越上限**与**端点非法**算错。 +// 错误一律 %w 包住 ErrInvalidConfig;配置错误里不含任何凭据(本类型的字段就没有凭据)。 +func (c Config) Validate() error { + if c.ViewportWidth > MaxViewportWidth { + return fmt.Errorf("%w: viewportWidth=%d 超过上限 %d", ErrInvalidConfig, c.ViewportWidth, MaxViewportWidth) + } + if c.ViewportHeight > MaxViewportHeight { + return fmt.Errorf("%w: viewportHeight=%d 超过上限 %d", ErrInvalidConfig, c.ViewportHeight, MaxViewportHeight) + } + if c.ScreenshotMaxBytes > MaxScreenshotMaxBytes { + return fmt.Errorf("%w: screenshotMaxBytes=%d 超过上限 %d", ErrInvalidConfig, c.ScreenshotMaxBytes, MaxScreenshotMaxBytes) + } + if raw := strings.TrimSpace(c.CDPEndpoint); raw != "" && !isHTTPAbsolute(raw) { + return fmt.Errorf("%w: cdpEndpoint %q 不是可用的 http(s) 绝对地址", ErrInvalidConfig, raw) + } + return nil +} + +// Timeout 返回单次动作超时;调用前应已 Normalize(否则可能为 0)。 +func (c Config) Timeout() time.Duration { + return time.Duration(c.TimeoutSeconds) * time.Second +} + +// IdleTimeout 返回空闲回收时长;调用前应已 Normalize(否则可能为 0)。 +func (c Config) IdleTimeout() time.Duration { + return time.Duration(c.IdleTimeoutSeconds) * time.Second +} + +// isHTTPAbsolute 报告 raw 是否为带主机的 http(s) 绝对地址(照 config.ResolveEndpoint 口径: +// `ftp://x`、`127.0.0.1:9222` 这类一律不算)。 +func isHTTPAbsolute(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// DefaultConfigPath 返回 browser 插件配置文件的落点: +// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1「一个插件一个文件,文件名 = 插件 id」 +// + design/28 §1 M2)。 +func DefaultConfigPath(dataDir string) string { + return filepath.Join(dataDir, "config", "plugins", "browser.json") +} + +// DefaultProfileDir 返回持久化 profile 目录的默认落点:`$DATA_DIR/browser/profile`。 +// **它含凭据**:0700、不进 git、内容不进上下文(见 EnsureProfileDir / CheckProfileLocation)。 +func DefaultProfileDir(dataDir string) string { + return filepath.Join(dataDir, "browser", "profile") +} + +// DefaultShotsDir 返回截图落盘目录的默认落点(design/28 §4:「截图存 +// `$DATA_DIR/browser/shots/`,0600」)。本片不落盘,只给 B3 一个一致的默认位置。 +func DefaultShotsDir(dataDir string) string { + return filepath.Join(dataDir, "browser", "shots") +} diff --git a/internal/capability/browsercfg/config_test.go b/internal/capability/browsercfg/config_test.go new file mode 100644 index 0000000..734a50c --- /dev/null +++ b/internal/capability/browsercfg/config_test.go @@ -0,0 +1,194 @@ +package browsercfg + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +// TestConfigNormalizeFillsDefaults 钉住「零值 + Normalize = 全默认」(design/28 §8 B1: +// 代码只提供默认值)。Config 全字段可比较,故整struct 比对。 +func TestConfigNormalizeFillsDefaults(t *testing.T) { + got := Config{}.Normalize("/data") + want := Config{ + Headless: DefaultHeadless, + ProfileDir: filepath.Join("/data", "browser", "profile"), + ViewportWidth: DefaultViewportWidth, + ViewportHeight: DefaultViewportHeight, + TimeoutSeconds: DefaultTimeoutSeconds, + IdleTimeoutSeconds: DefaultIdleTimeoutSeconds, + Concurrency: DefaultConcurrency, + ScreenshotMaxBytes: DefaultScreenshotMaxBytes, + } + if got != want { + t.Fatalf("Normalize 默认值回落不符\n got=%+v\nwant=%+v", got, want) + } + if got.Headless != false { + t.Fatalf("默认档必须是有头(design/28 §2),got headless=%v", got.Headless) + } +} + +// TestConfigNormalizeKeepsExplicit 钉住「显式值不被默认覆盖」。 +func TestConfigNormalizeKeepsExplicit(t *testing.T) { + in := Config{ + Headless: true, + Executable: "/opt/chrome", + CDPEndpoint: "http://127.0.0.1:9333", + ProfileDir: "/tmp/p", + ViewportWidth: 1920, + ViewportHeight: 1080, + TimeoutSeconds: 5, + IdleTimeoutSeconds: 60, + Concurrency: 1, + ScreenshotMaxBytes: 1 << 20, + } + if got := in.Normalize("/data"); got != in { + t.Fatalf("Normalize 改动了显式值\n got=%+v\nwant=%+v", got, in) + } +} + +// TestConfigNormalizeNonPositiveUsesDefault 钉住「≤0 = 用默认」(含负数)。 +func TestConfigNormalizeNonPositiveUsesDefault(t *testing.T) { + in := Config{ + ViewportWidth: -1, + ViewportHeight: 0, + TimeoutSeconds: -30, + IdleTimeoutSeconds: 0, + Concurrency: -2, + ScreenshotMaxBytes: -5, + } + got := in.Normalize("/data") + if got.ViewportWidth != DefaultViewportWidth || got.ViewportHeight != DefaultViewportHeight { + t.Fatalf("视口未回落默认:%d×%d", got.ViewportWidth, got.ViewportHeight) + } + if got.TimeoutSeconds != DefaultTimeoutSeconds || got.IdleTimeoutSeconds != DefaultIdleTimeoutSeconds { + t.Fatalf("超时未回落默认:timeout=%d idle=%d", got.TimeoutSeconds, got.IdleTimeoutSeconds) + } + if got.Concurrency != DefaultConcurrency { + t.Fatalf("并发未回落默认:%d", got.Concurrency) + } + if got.ScreenshotMaxBytes != DefaultScreenshotMaxBytes { + t.Fatalf("截图字节上限未回落默认:%d", got.ScreenshotMaxBytes) + } +} + +// TestConfigNormalizeClampsConcurrency 钉住并发上限恒为 1(design/28 §3、§6 坑 2:profile 锁)。 +func TestConfigNormalizeClampsConcurrency(t *testing.T) { + for _, in := range []int{2, 8, 100} { + if got := (Config{Concurrency: in}).Normalize("/data").Concurrency; got != DefaultConcurrency { + t.Fatalf("Concurrency=%d 未被夹到 %d,got=%d", in, DefaultConcurrency, got) + } + } +} + +// TestDefaultsMatchesZeroNormalize 钉住 Defaults 与「零值 Normalize」是同一件事。 +func TestDefaultsMatchesZeroNormalize(t *testing.T) { + if got, want := Defaults("/d"), (Config{}).Normalize("/d"); got != want { + t.Fatalf("Defaults 与 Normalize 口径不一致\n got=%+v\nwant=%+v", got, want) + } +} + +// TestConfigValidateAcceptsDefaults 钉住「全默认配置合法」——Validate 判的是原始值, +// 零值代表「用默认」,不该被判非法。 +func TestConfigValidateAcceptsDefaults(t *testing.T) { + if err := (Config{}).Validate(); err != nil { + t.Fatalf("零值配置应合法(= 全默认),got err=%v", err) + } +} + +// TestConfigValidateRejectsOverCaps 钉住硬上限(design/28 §3:视口尺寸上限 + 单张字节上限)。 +func TestConfigValidateRejectsOverCaps(t *testing.T) { + cases := []struct { + name string + cfg Config + }{ + {"视口宽超上限", Config{ViewportWidth: MaxViewportWidth + 1}}, + {"视口高超上限", Config{ViewportHeight: MaxViewportHeight + 1}}, + {"截图字节超上限", Config{ScreenshotMaxBytes: MaxScreenshotMaxBytes + 1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("应返回 ErrInvalidConfig,got=%v", err) + } + }) + } +} + +// TestConfigValidateAcceptsAtCaps 钉住边界值(正好等于上限)合法,防止把 > 写成 >=。 +func TestConfigValidateAcceptsAtCaps(t *testing.T) { + cfg := Config{ + ViewportWidth: MaxViewportWidth, + ViewportHeight: MaxViewportHeight, + ScreenshotMaxBytes: MaxScreenshotMaxBytes, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("边界值应合法,got err=%v", err) + } +} + +// TestConfigValidateEndpoint 是 cdpEndpoint 合法性的表驱动:非 http(s) 绝对地址一律非法 +// (照 config.ResolveEndpoint 口径),合法与空(=交给探测)放行。 +func TestConfigValidateEndpoint(t *testing.T) { + cases := []struct { + endpoint string + wantErr bool + }{ + {"", false}, + {" ", false}, + {"http://127.0.0.1:9222", false}, + {"https://browser.internal:9222", false}, + {"ftp://127.0.0.1:9222", true}, + {"127.0.0.1:9222", true}, + {"ws://127.0.0.1:9222", true}, + {"http://", true}, + } + for _, tc := range cases { + err := (Config{CDPEndpoint: tc.endpoint}).Validate() + if tc.wantErr && !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("endpoint=%q 应判非法,got err=%v", tc.endpoint, err) + } + if !tc.wantErr && err != nil { + t.Fatalf("endpoint=%q 应合法,got err=%v", tc.endpoint, err) + } + } +} + +// TestConfigTimeoutAccessors 钉住秒数字段到 time.Duration 的换算。 +func TestConfigTimeoutAccessors(t *testing.T) { + cfg := Config{TimeoutSeconds: 12, IdleTimeoutSeconds: 90} + if got := cfg.Timeout(); got != 12*time.Second { + t.Fatalf("Timeout()=%v,want %v", got, 12*time.Second) + } + if got := cfg.IdleTimeout(); got != 90*time.Second { + t.Fatalf("IdleTimeout()=%v,want %v", got, 90*time.Second) + } +} + +// TestDefaultPaths 钉住三个落点(design/13 §2.1 + design/28 §1 M2/§4)。 +func TestDefaultPaths(t *testing.T) { + data := filepath.Join("root", "data") + if got, want := DefaultConfigPath(data), filepath.Join(data, "config", "plugins", "browser.json"); got != want { + t.Fatalf("DefaultConfigPath=%q,want %q", got, want) + } + if got, want := DefaultProfileDir(data), filepath.Join(data, "browser", "profile"); got != want { + t.Fatalf("DefaultProfileDir=%q,want %q", got, want) + } + if got, want := DefaultShotsDir(data), filepath.Join(data, "browser", "shots"); got != want { + t.Fatalf("DefaultShotsDir=%q,want %q", got, want) + } +} + +// TestReasonMessage 钉住分类原因码都有中文文案(上层直接写日志,避免两处措辞漂移)。 +func TestReasonMessage(t *testing.T) { + for _, r := range []Reason{ReasonUsable, ReasonExecutableMissing, ReasonCDPUnreachable, ReasonNoSource} { + if r.Message() == "" { + t.Fatalf("原因码 %q 缺中文文案", r) + } + } + if got := Reason("unknown").Message(); got != "" { + t.Fatalf("未知原因码应返回空串,got=%q", got) + } +} diff --git a/internal/capability/browsercfg/doc.go b/internal/capability/browsercfg/doc.go new file mode 100644 index 0000000..9008753 --- /dev/null +++ b/internal/capability/browsercfg/doc.go @@ -0,0 +1,22 @@ +// Package browsercfg 只做两件事:**浏览器能力的配置**与**可用性判定**。它是 +// design/28(浏览器与页面截图)B1 的产物,配置源是 +// `$DATA_DIR/config/plugins/browser.json`(design/28 §1 M2、§8 B1、design/13 §2.1 +// 的插件配置落点),并按「本机 Chrome/Chromium 可执行文件 → 已在跑的容器(CDP 端点)」 +// 的优先级探测,给出**可证伪的可用性结论**和「为什么不可用」的**分类原因**。 +// +// 它是**纯库**:不注册任何工具、不建长连接、不接线(工具面在 B2,闭环在 B3)。 +// 除 CheckCDP 会发一次出站 HTTP GET 外,本包不启动进程、不开监听端口。 +// +// 红线(design/28 §3/§4,AGENTS §9/§10): +// +// - **不进 `.env`**:一切可调项只从 `browser.json` 读。本包不读任何**配置来源**的 +// 环境变量,也不新增任何 env 键;唯一的 getenv 用途是从 `%ProgramFiles%` / +// `%LOCALAPPDATA%` 定位本机 Chrome 安装目录(与配置无关的系统环境)。 +// - **不新开监听端口**:我们是 CDP 的**出站客户端**,探测只连调用方给的地址 +// (默认 `127.0.0.1:9222`)。 +// - **不假装可用**:探测不到就返回 `Usable=false` + 分类原因,由上层**如实降级** +// (照 web-search `off` 的口径:不注册工具,而不是注册一把抡起来必报错的锤子)。 +// - **profile 目录含凭据**(cookie / 登录态)⇒ 目录 0700、文件 0600、**不得位于 +// git 工作树内**(见 CheckProfileLocation)、内容**绝不进上下文/长期记忆** +// (design/28 §2 三件套之 2、§4)。截图落盘同属敏感面,默认不进长期记忆。 +package browsercfg diff --git a/internal/capability/browsercfg/errors.go b/internal/capability/browsercfg/errors.go new file mode 100644 index 0000000..8f61111 --- /dev/null +++ b/internal/capability/browsercfg/errors.go @@ -0,0 +1,54 @@ +package browsercfg + +import "errors" + +// 哨兵错误。AGENTS §10 只放行 `var ErrXxx` 作为包级变量,故原因码与文案用常量。 +var ( + // ErrInvalidConfig 表示 browser.json 解码失败、是空文件,或字段越界: + // 视口/截图字节超过硬上限、cdpEndpoint 不是 http(s) 绝对地址。 + ErrInvalidConfig = errors.New("browsercfg: 配置非法") + // ErrProfileInGitTree 表示 profile 目录会落在 git 工作树内 —— profile 含凭据 + // (cookie / 登录态),被 `git add` 收走就是把主人的登录态提交出去(design/28 §4)。 + ErrProfileInGitTree = errors.New("browsercfg: profile 目录不得位于 git 工作树内") +) + +// Reason 是可用性判定「为什么不可用」的**分类原因码**。上层据它如实降级与记日志, +// 不许把它含糊成「探测失败」四个字(design/28 §6 坑 5:失败要如实报)。 +type Reason string + +// 分类原因码。取值稳定、可断言;人类可读文案见 Message()。 +const ( + // ReasonUsable 表示探测到可用来源(Usable=true 时使用)。 + ReasonUsable Reason = "usable" + // ReasonExecutableMissing 表示配置显式给出的浏览器可执行文件不存在或不可执行。 + ReasonExecutableMissing Reason = "executable_missing" + // ReasonCDPUnreachable 表示 CDP 端点不可达 —— 配置里显式给定的那个,或兜底候选。 + ReasonCDPUnreachable Reason = "cdp_unreachable" + // ReasonNoSource 表示本机既无 Chrome/Chromium 可执行文件、也没有在跑的 CDP 端点。 + ReasonNoSource Reason = "no_source" +) + +// 分类原因的中文文案:上层直接写进日志/面板,避免两处各写一份措辞 +// (照 config.WebSearchReason* 的既有口径)。 +const ( + messageUsable = "可用" + messageExecutableMissing = "指定的浏览器可执行文件不存在或不可执行" + messageCDPUnreachable = "CDP 端点不可达" + messageNoSource = "本机未发现 Chrome/Chromium,也没有在跑的 CDP 端点" +) + +// Message 返回原因码对应的中文文案;未知码返回空串(调用方回落到 Detail)。 +func (r Reason) Message() string { + switch r { + case ReasonUsable: + return messageUsable + case ReasonExecutableMissing: + return messageExecutableMissing + case ReasonCDPUnreachable: + return messageCDPUnreachable + case ReasonNoSource: + return messageNoSource + default: + return "" + } +} diff --git a/internal/capability/browsercfg/helpers_test.go b/internal/capability/browsercfg/helpers_test.go new file mode 100644 index 0000000..dfa7c2d --- /dev/null +++ b/internal/capability/browsercfg/helpers_test.go @@ -0,0 +1,36 @@ +package browsercfg + +import ( + "context" + "errors" + "io/fs" + "os" + "time" +) + +// testFileInfo 是最小的 fs.FileInfo 实现,让单测能伪造 Stat 的返回,从而不依赖真实装没装浏览器。 +type testFileInfo struct { + mode fs.FileMode + dir bool +} + +func (f testFileInfo) Name() string { return "browser" } +func (f testFileInfo) Size() int64 { return 0 } +func (f testFileInfo) Mode() fs.FileMode { return f.mode } +func (f testFileInfo) ModTime() time.Time { return time.Time{} } +func (f testFileInfo) IsDir() bool { return f.dir } +func (f testFileInfo) Sys() any { return nil } + +// executableFileInfo 造一个带执行位的普通文件信息(非 Windows 平台才算「可执行」)。 +func executableFileInfo() fs.FileInfo { return testFileInfo{mode: 0o755} } + +// noBrowserProber 返回一个「本机没有浏览器、也没有 CDP 端点」的 Prober(全阴性基线)。 +func noBrowserProber() *Prober { + return &Prober{ + LookPath: func(string) (string, error) { return "", errors.New("not found") }, + Stat: func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }, + Getenv: func(string) string { return "" }, + GOOS: "linux", + CheckCDP: func(context.Context, string) error { return errors.New("connection refused") }, + } +} diff --git a/internal/capability/browsercfg/probe.go b/internal/capability/browsercfg/probe.go new file mode 100644 index 0000000..ee30051 --- /dev/null +++ b/internal/capability/browsercfg/probe.go @@ -0,0 +1,275 @@ +package browsercfg + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// probeHTTPTimeout 是 CDP 存活探测的单次超时(2s)。探测问的是「有没有在听」,不是 +// 「页面能不能加载」,所以给得短:连不上就是连不上,不该拖住启动路径。 +const probeHTTPTimeout = 2 * time.Second + +// Source 标识浏览器从哪来(探测优先级见 design/28 §8 B1)。 +type Source string + +// 来源取值。SourceNone 用于不可用(Usable=false)的结论。 +const ( + // SourceNone 表示没有可用来源。 + SourceNone Source = "" + // SourceLocalChrome 表示本机 Chrome/Chromium 可执行文件(默认有头)。 + SourceLocalChrome Source = "local-chrome" + // SourceCDP 表示一个已在响应 CDP 的端点(容器,或本机手动起的实例)。 + SourceCDP Source = "cdp" +) + +// Availability 是可用性判定的结论。**不可用也是结论**:Usable=false 时 Reason 说明分类 +// 原因、Detail 给出可排查的细节,上层据此如实降级(不注册工具)与记日志。 +type Availability struct { + // Usable 为 true 表示探测到可用来源。 + Usable bool + // Source 是选定的来源;不可用时为 SourceNone。 + Source Source + // Executable 是选定的本机可执行文件(Source=SourceLocalChrome 时有值)。 + Executable string + // Endpoint 是选定的 CDP 端点(Source=SourceCDP 时有值)。 + Endpoint string + // Headless 是本次判定采用的档位(来自配置,默认有头)。 + Headless bool + // Reason 是分类原因码(可用时恒为 ReasonUsable)。 + Reason Reason + // Detail 是可排查的补充说明(含具体路径/端点/错误),进日志与面板,**不进提示词**。 + Detail string +} + +// Prober 执行可用性探测。字段可注入,使单测无需真的装浏览器 / 起容器;零接线:除 +// CheckCDP 会发一次出站 HTTP GET 外,它不建立任何长连接、不启动任何进程。 +type Prober struct { + // LookPath 在 PATH 上查找可执行文件(默认 exec.LookPath)。 + LookPath func(string) (string, error) + // Stat 检查候选绝对路径(默认 os.Stat)。 + Stat func(string) (os.FileInfo, error) + // Getenv 读系统环境(默认 os.Getenv),只用于定位 Windows 的安装目录。 + Getenv func(string) string + // GOOS 决定候选路径表(默认 runtime.GOOS)。 + GOOS string + // CheckCDP 探测一个 CDP 端点,返回 nil 表示可达(默认对 /json/version 发 GET)。 + CheckCDP func(ctx context.Context, endpoint string) error +} + +// NewProber 返回接好真实现的 Prober。 +func NewProber() *Prober { return (&Prober{}).withDefaults() } + +// withDefaults 给零值字段补上真实现,使 `&Prober{}` 也安全(照 transcode.Limits 的口径: +// 零值 = 全默认)。测试只覆写关心的那几个字段即可。 +func (p *Prober) withDefaults() *Prober { + if p.LookPath == nil { + p.LookPath = exec.LookPath + } + if p.Stat == nil { + p.Stat = os.Stat + } + if p.Getenv == nil { + p.Getenv = os.Getenv + } + if p.GOOS == "" { + p.GOOS = runtime.GOOS + } + if p.CheckCDP == nil { + p.CheckCDP = checkCDP + } + return p +} + +// Resolve 判定浏览器是否可用并给出分类原因。探测优先级(design/28 §8 B1): +// +// 1. 配置**显式**给出的来源(executable / cdpEndpoint)—— 面板是权威:连不上就如实报 +// 对应原因,**不偷偷回退**到自动探测(design/28 §6 坑 5:失败要如实报); +// 2. 无显式来源 ⇒ 本机 Chrome/Chromium 可执行文件; +// 3. 仍无 ⇒ 已在跑的 CDP 端点(容器,或本机手动实例); +// 4. 都没有 ⇒ Usable=false、Reason=no_source —— 上层据此**不注册工具**,不假装可用。 +// +// cfg 为 nil 表示没有配置文件(Load 返回 nil, nil),直接走 2→3→4。p 为 nil 时用真实现。 +func Resolve(ctx context.Context, cfg *Config, p *Prober) Availability { + if p == nil { + p = &Prober{} + } + p = p.withDefaults() + headless := cfg != nil && cfg.Headless + if cfg != nil { + if exe := strings.TrimSpace(cfg.Executable); exe != "" { + return resolveConfiguredExecutable(p, exe, headless) + } + if endpoint := strings.TrimSpace(cfg.CDPEndpoint); endpoint != "" { + return resolveConfiguredEndpoint(ctx, p, endpoint, headless) + } + } + return resolveByProbe(ctx, p, headless) +} + +// resolveConfiguredExecutable 处理配置显式给的浏览器可执行文件:不存在即 +// Reason=executable_missing(不做「换一个」的猜测——面板写错了就该让人看见)。 +func resolveConfiguredExecutable(p *Prober, exe string, headless bool) Availability { + if !p.isExecutable(exe) { + return Availability{ + Headless: headless, + Reason: ReasonExecutableMissing, + Detail: fmt.Sprintf("配置的 executable 不存在或不可执行:%s", exe), + } + } + return Availability{ + Usable: true, + Source: SourceLocalChrome, + Executable: exe, + Headless: headless, + Reason: ReasonUsable, + Detail: "配置指定的本机浏览器:" + exe, + } +} + +// resolveConfiguredEndpoint 处理配置显式给的 CDP 端点:连不上即 Reason=cdp_unreachable, +// 绝不悄悄改用本机浏览器(否则「我配的容器没生效」会变成静默的语义漂移)。 +func resolveConfiguredEndpoint(ctx context.Context, p *Prober, endpoint string, headless bool) Availability { + if err := p.CheckCDP(ctx, endpoint); err != nil { + return Availability{ + Headless: headless, + Reason: ReasonCDPUnreachable, + Detail: fmt.Sprintf("配置的 cdpEndpoint 不可达:%s(%v)", endpoint, err), + } + } + return Availability{ + Usable: true, + Source: SourceCDP, + Endpoint: endpoint, + Headless: headless, + Reason: ReasonUsable, + Detail: "配置指定的 CDP 端点:" + endpoint, + } +} + +// resolveByProbe 按优先级自动探测:本机可执行文件 → 已在跑的 CDP 端点 → 都没有。 +func resolveByProbe(ctx context.Context, p *Prober, headless bool) Availability { + if exe, ok := p.FindLocalBrowser(); ok { + return Availability{ + Usable: true, + Source: SourceLocalChrome, + Executable: exe, + Headless: headless, + Reason: ReasonUsable, + Detail: "自动探测到的本机浏览器:" + exe, + } + } + if err := p.CheckCDP(ctx, DefaultCDPEndpoint); err == nil { + return Availability{ + Usable: true, + Source: SourceCDP, + Endpoint: DefaultCDPEndpoint, + Headless: headless, + Reason: ReasonUsable, + Detail: "自动探测到在跑的 CDP 端点:" + DefaultCDPEndpoint, + } + } + return Availability{ + Headless: headless, + Reason: ReasonNoSource, + Detail: fmt.Sprintf("本机未发现 Chrome/Chromium,%s 也没有在跑的 CDP 端点", DefaultCDPEndpoint), + } +} + +// FindLocalBrowser 按「PATH 上的常见命令名 → 各平台固定安装位置」查找本机 Chrome/Chromium, +// 返回第一个可执行文件。找不到返回 ("", false)。 +func (p *Prober) FindLocalBrowser() (string, bool) { + for _, name := range lookPathNames(p.GOOS) { + if path, err := p.LookPath(name); err == nil && strings.TrimSpace(path) != "" { + return path, true + } + } + for _, path := range absoluteCandidates(p.GOOS, p.Getenv) { + if p.isExecutable(path) { + return path, true + } + } + return "", false +} + +// isExecutable 判定 path 是否是一个可执行文件:存在、不是目录,且在非 Windows 平台上有 +// 执行位(Windows 不看执行位)。 +func (p *Prober) isExecutable(path string) bool { + info, err := p.Stat(path) + if err != nil || info == nil || info.IsDir() { + return false + } + if p.GOOS == "windows" { + return true + } + return info.Mode().Perm()&0o111 != 0 +} + +// checkCDP 对 `/json/version` 发一次出站 GET:CDP 端点活着就会回版本 JSON。 +// 它是**出站**请求,只连调用方给的地址(默认 127.0.0.1),不新开任何监听端口(AGENTS §9)。 +func checkCDP(ctx context.Context, endpoint string) error { + base := strings.TrimRight(strings.TrimSpace(endpoint), "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil) + if err != nil { + return fmt.Errorf("browsercfg: 构造探测请求: %w", err) + } + client := &http.Client{Timeout: probeHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("browsercfg: 探测 %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("browsercfg: 探测 %s: HTTP %d", endpoint, resp.StatusCode) + } + return nil +} + +// lookPathNames 是各平台 PATH 上常见的浏览器命令名。 +func lookPathNames(goos string) []string { + switch goos { + case "windows": + return []string{"chrome.exe", "chrome", "chromium.exe", "chromium"} + case "darwin": + return []string{"google-chrome", "chromium", "chrome"} + default: + return []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"} + } +} + +// absoluteCandidates 是各平台**不在 PATH 上**的固定安装位置。 +func absoluteCandidates(goos string, getenv func(string) string) []string { + switch goos { + case "windows": + var out []string + for _, key := range []string{"ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"} { + if base := strings.TrimSpace(getenv(key)); base != "" { + out = append(out, + filepath.Join(base, "Google", "Chrome", "Application", "chrome.exe"), + filepath.Join(base, "Chromium", "Application", "chrome.exe"), + ) + } + } + return out + case "darwin": + return []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + default: + return []string{ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/chrome", + } + } +} diff --git a/internal/capability/browsercfg/probe_test.go b/internal/capability/browsercfg/probe_test.go new file mode 100644 index 0000000..193c39c --- /dev/null +++ b/internal/capability/browsercfg/probe_test.go @@ -0,0 +1,281 @@ +package browsercfg + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// TestResolveNoSource 钉住「都没有 ⇒ 不假装可用」:本机无浏览器、无 CDP 端点 ⇒ +// Usable=false、Reason=no_source(design/28 §8 B1、§7 验收判据 1)。 +func TestResolveNoSource(t *testing.T) { + got := Resolve(context.Background(), nil, noBrowserProber()) + if got.Usable { + t.Fatalf("应判不可用,got=%+v", got) + } + if got.Reason != ReasonNoSource { + t.Fatalf("Reason=%q,want %q", got.Reason, ReasonNoSource) + } + if got.Source != SourceNone { + t.Fatalf("不可用时应无来源,got=%q", got.Source) + } +} + +// TestResolveLocalBrowserFromPath 钉住优先级第 1 档:PATH 上的 Chrome/Chromium 命中即用本机。 +func TestResolveLocalBrowserFromPath(t *testing.T) { + p := noBrowserProber() + p.LookPath = func(name string) (string, error) { + if name == "google-chrome" { + return "/usr/bin/google-chrome", nil + } + return "", errors.New("not found") + } + got := Resolve(context.Background(), nil, p) + if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/usr/bin/google-chrome" { + t.Fatalf("应命中本机浏览器,got=%+v", got) + } + if got.Reason != ReasonUsable { + t.Fatalf("可用时 Reason 应为 usable,got=%q", got.Reason) + } +} + +// TestResolveLocalBrowserFromAbsolutePath 钉住 PATH 未命中时会把固定安装位置当候选。 +func TestResolveLocalBrowserFromAbsolutePath(t *testing.T) { + p := noBrowserProber() + p.Stat = func(path string) (os.FileInfo, error) { + if path == "/usr/bin/chromium" { + return executableFileInfo(), nil + } + return nil, os.ErrNotExist + } + got := Resolve(context.Background(), nil, p) + if !got.Usable || got.Executable != "/usr/bin/chromium" { + t.Fatalf("应命中固定安装位置 /usr/bin/chromium,got=%+v", got) + } +} + +// TestResolveLocalBrowserSkipsDirectory 钉住目录不算可执行文件。 +func TestResolveLocalBrowserSkipsDirectory(t *testing.T) { + p := noBrowserProber() + p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{dir: true, mode: 0o755}, nil } + if got := Resolve(context.Background(), nil, p); got.Usable { + t.Fatalf("目录不该被当浏览器,got=%+v", got) + } +} + +// TestResolveLocalBrowserNeedsExecBit 钉住非 Windows 上无执行位不算可执行(Windows 跳过)。 +func TestResolveLocalBrowserNeedsExecBit(t *testing.T) { + p := noBrowserProber() + p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{mode: 0o644}, nil } + if got := Resolve(context.Background(), nil, p); got.Usable { + t.Fatalf("无执行位不该算可执行,got=%+v", got) + } + + p.GOOS = "windows" + p.Getenv = func(key string) string { + if key == "ProgramFiles" { + return `C:\PF` + } + return "" + } + if got := Resolve(context.Background(), nil, p); !got.Usable { + t.Fatalf("Windows 不看执行位,应判可用,got=%+v", got) + } +} + +// TestResolveCDPWhenNoLocalBrowser 钉住优先级第 2 档:本机没有时看已在跑的 CDP 端点。 +func TestResolveCDPWhenNoLocalBrowser(t *testing.T) { + p := noBrowserProber() + p.CheckCDP = func(_ context.Context, endpoint string) error { + if endpoint == DefaultCDPEndpoint { + return nil + } + return errors.New("connection refused") + } + got := Resolve(context.Background(), nil, p) + if !got.Usable || got.Source != SourceCDP || got.Endpoint != DefaultCDPEndpoint { + t.Fatalf("应命中 CDP 端点,got=%+v", got) + } +} + +// TestResolveLocalPreferredOverCDP 钉住优先级顺序(design/28 §8 B1:本机 → 容器)。 +func TestResolveLocalPreferredOverCDP(t *testing.T) { + p := noBrowserProber() + p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil } + p.CheckCDP = func(context.Context, string) error { return nil } + got := Resolve(context.Background(), nil, p) + if got.Source != SourceLocalChrome { + t.Fatalf("本机应优先于 CDP,got=%+v", got) + } +} + +// TestResolveConfiguredExecutable 钉住面板显式指定的可执行文件:存在即可用、缺失即分类报错。 +func TestResolveConfiguredExecutable(t *testing.T) { + ok := noBrowserProber() + ok.Stat = func(path string) (os.FileInfo, error) { + if path == "/opt/chrome" { + return executableFileInfo(), nil + } + return nil, os.ErrNotExist + } + got := Resolve(context.Background(), &Config{Executable: "/opt/chrome"}, ok) + if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/opt/chrome" { + t.Fatalf("配置的可执行文件应被采纳,got=%+v", got) + } + + got = Resolve(context.Background(), &Config{Executable: "/opt/missing"}, noBrowserProber()) + if got.Usable || got.Reason != ReasonExecutableMissing { + t.Fatalf("缺失应报 %q,got=%+v", ReasonExecutableMissing, got) + } +} + +// TestResolveConfiguredEndpoint 钉住面板显式指定的 CDP 端点:可达即可用、不可达分类报错。 +func TestResolveConfiguredEndpoint(t *testing.T) { + p := noBrowserProber() + p.CheckCDP = func(_ context.Context, endpoint string) error { + if endpoint == "http://127.0.0.1:9333" { + return nil + } + return errors.New("connection refused") + } + got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9333"}, p) + if !got.Usable || got.Source != SourceCDP || got.Endpoint != "http://127.0.0.1:9333" { + t.Fatalf("配置的 CDP 端点应被采纳,got=%+v", got) + } + + got = Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p) + if got.Usable || got.Reason != ReasonCDPUnreachable { + t.Fatalf("不可达应报 %q,got=%+v", ReasonCDPUnreachable, got) + } +} + +// TestResolveConfiguredEndpointDoesNotFallBack 是「不许静默回退」的守门测试:面板配了端点、 +// 端点连不上时,**即使本机明明有浏览器**也必须如实报 cdp_unreachable,不许改走本机。 +func TestResolveConfiguredEndpointDoesNotFallBack(t *testing.T) { + p := noBrowserProber() + p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil } + got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p) + if got.Usable || got.Source != SourceNone || got.Reason != ReasonCDPUnreachable { + t.Fatalf("配了端点并连不上时必须如实报错、不得回退本机,got=%+v", got) + } +} + +// TestResolveConfiguredExecutableWinsOverEndpoint 钉住显式来源的优先序:executable 先于 +// cdpEndpoint(同一个「本机 → 容器」的口径),命中 executable 时不该再去探测端点。 +func TestResolveConfiguredExecutableWinsOverEndpoint(t *testing.T) { + p := noBrowserProber() + p.Stat = func(path string) (os.FileInfo, error) { + if path == "/opt/chrome" { + return executableFileInfo(), nil + } + return nil, os.ErrNotExist + } + probedEndpoint := false + p.CheckCDP = func(context.Context, string) error { probedEndpoint = true; return nil } + got := Resolve(context.Background(), &Config{Executable: "/opt/chrome", CDPEndpoint: "http://127.0.0.1:9333"}, p) + if got.Source != SourceLocalChrome { + t.Fatalf("executable 应优先,got=%+v", got) + } + if probedEndpoint { + t.Fatal("命中 executable 时不该再探测 CDP 端点") + } +} + +// TestResolveHeadlessFromConfig 钉住档位来自配置(默认有头,显式 true 才无头)。 +func TestResolveHeadlessFromConfig(t *testing.T) { + p := noBrowserProber() + p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil } + if got := Resolve(context.Background(), &Config{}, p); got.Headless { + t.Fatalf("默认档应为有头,got=%+v", got) + } + if got := Resolve(context.Background(), &Config{Headless: true}, p); !got.Headless { + t.Fatalf("显式无头应被采纳,got=%+v", got) + } + if got := Resolve(context.Background(), nil, p); got.Headless { + t.Fatalf("无配置时应为有头默认,got=%+v", got) + } +} + +// TestProberWithDefaults 钉住零值 Prober 也被补上真实现(零值 = 全默认)。 +func TestProberWithDefaults(t *testing.T) { + p := (&Prober{}).withDefaults() + if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" { + t.Fatalf("withDefaults 未补齐字段:%+v", p) + } +} + +// TestNewProber 钉住 NewProber 返回的也是补齐过的实现(生产路径取它)。 +func TestNewProber(t *testing.T) { + p := NewProber() + if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" { + t.Fatalf("NewProber 未补齐字段:%+v", p) + } +} + +// TestCheckCDP 用本机 httptest 服务钉住默认探测实现(生产路径,不能不测): +// `/json/version` 返回 2xx 即可达;非 2xx、连不上都算不可达;尾部斜杠容错。 +func TestCheckCDP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"Browser":"Chrome/120"}`)) + })) + defer srv.Close() + + if err := checkCDP(context.Background(), srv.URL); err != nil { + t.Fatalf("可达端点应返回 nil,got=%v", err) + } + if err := checkCDP(context.Background(), srv.URL+"/"); err != nil { + t.Fatalf("尾部斜杠应被容错,got=%v", err) + } + + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer bad.Close() + if err := checkCDP(context.Background(), bad.URL); err == nil { + t.Fatal("非 2xx 应判不可达") + } + if err := checkCDP(context.Background(), "http://127.0.0.1:1"); err == nil { + t.Fatal("连不上应报错") + } +} + +// TestCandidateTables 钉住三平台的候选表都非空(避免某个平台分支写成空表 ⇒ 该平台永远 +// 探测不到浏览器),以及 Windows 候选完全由安装目录环境变量驱动。 +func TestCandidateTables(t *testing.T) { + for _, goos := range []string{"windows", "darwin", "linux"} { + if len(lookPathNames(goos)) == 0 { + t.Fatalf("%s 的 PATH 候选为空", goos) + } + } + env := map[string]string{"ProgramFiles": `C:\PF`, "LOCALAPPDATA": `C:\LA`} + getenv := func(k string) string { return env[k] } + for _, goos := range []string{"windows", "darwin", "linux"} { + if got := absoluteCandidates(goos, getenv); len(got) == 0 { + t.Fatalf("%s 的绝对路径候选为空", goos) + } + } + if got := absoluteCandidates("windows", func(string) string { return "" }); len(got) != 0 { + t.Fatalf("无安装目录环境变量时 windows 候选应为空,got=%v", got) + } +} + +// TestResolveNilProberDoesNotPanic 钉住 p=nil 不 panic(不连真实端点:用一个必失败的 +// 上下文超时让它立刻返回)。 +func TestResolveNilProberDoesNotPanic(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + defer func() { + if r := recover(); r != nil { + t.Fatalf("Resolve(nil prober) panic: %v", r) + } + }() + _ = Resolve(ctx, nil, nil) +} diff --git a/internal/capability/browsercfg/store.go b/internal/capability/browsercfg/store.go new file mode 100644 index 0000000..f8e1521 --- /dev/null +++ b/internal/capability/browsercfg/store.go @@ -0,0 +1,142 @@ +package browsercfg + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Load 从 path 读 browser 插件配置,返回**归一化且校验过**的配置。 +// +// 返回 `(nil, nil)` 表示**文件不存在** —— 即「未配置」,交给探测自动判定 +// (design/28 §8 B1:「无配置文件时按序探测本机 Chrome/Chromium → 已在跑的容器」)。 +// 文件存在但读不动 / 是空文件 / JSON 非法 / 字段越界 ⇒ 返回非 nil 错误,调用方应如实 +// 报错并**不注册工具**,绝不静默拿默认值顶替(design/28 §6 坑 5)。 +// +// dataDir 只用于把空的 ProfileDir 落成默认位置(`$DATA_DIR/browser/profile`)。 +func Load(path, dataDir string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("browsercfg: 读 %s: %w", path, err) + } + if len(strings.TrimSpace(string(raw))) == 0 { + return nil, fmt.Errorf("%w: 空文件 %s", ErrInvalidConfig, path) + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("%w: 解码 %s: %w", ErrInvalidConfig, path, err) + } + cfg = cfg.Normalize(dataDir) + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// Save 把 cfg 归一化后以 **0600** 写入 path:先写 `.tmp` 再 rename(照 +// runtimeconfig.SavePlugins 的范式),保证并发读者不会看到半个文件。写之前先 Validate: +// 非法配置**不落盘**(宁可没有配置文件,也不留一个半对的)。 +// +// 0600 的依据(design/28 §2/§4):browser.json 可能带上显式 CDP 端点与凭据化 profile 的 +// 位置,属于本地运行期配置,不进 git、不进上下文。 +func Save(path, dataDir string, cfg Config) error { + cfg = cfg.Normalize(dataDir) + if err := cfg.Validate(); err != nil { + return err + } + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("browsercfg: 编码配置: %w", err) + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("browsercfg: 建配置目录 %s: %w", dir, err) + } + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("browsercfg: 写临时文件 %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("browsercfg: 重命名 %s -> %s: %w", tmp, path, err) + } + return nil +} + +// EnsureProfileDir 以 **0700** 建好(或确认已存在)profile 目录,并在建之前核查它 +// **不会进 git**(CheckProfileLocation)。0700 而非 0755:目录里的 cookie / 登录态是凭据 +// (design/28 §2 三件套之 2、§4)。 +func EnsureProfileDir(dir, dataDir string) error { + if strings.TrimSpace(dir) == "" { + return fmt.Errorf("%w: profile 目录为空", ErrInvalidConfig) + } + if err := CheckProfileLocation(dir, dataDir); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("browsercfg: 建 profile 目录 %s: %w", dir, err) + } + // 已存在的目录 MkdirAll 不会改权限,这里补一次 Chmod,保证「长期保留的凭据目录」 + // 不被更宽松的旧权限一直带着(Windows 上 Chmod 只影响只读位,属尽力而为)。 + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("browsercfg: 收敛 profile 目录权限 %s: %w", dir, err) + } + return nil +} + +// CheckProfileLocation 核查 dir **不会进 git**。判定口径(务实版,见 design/13 §2.1): +// profile 目录要么落在**数据目录**内(`$DATA_DIR` 按项目约定是 gitignored 的运行期区, +// 仓库 `.gitignore` 有 `data/`),要么落在**任何 git 工作树之外**;两者都不满足 ⇒ +// ErrProfileInGitTree。这样默认落点 `./data/browser/profile` 合法,而把它指到 `./configs/` +// 或仓库根这种会被 `git add` 收走的位置就会被拦下。 +func CheckProfileLocation(dir, dataDir string) error { + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("browsercfg: 解析 profile 目录 %s: %w", dir, err) + } + if strings.TrimSpace(dataDir) != "" { + root, err := filepath.Abs(dataDir) + if err != nil { + return fmt.Errorf("browsercfg: 解析数据目录 %s: %w", dataDir, err) + } + if withinDir(abs, root) { + return nil + } + } + if worktree, ok := hasGitAncestor(abs); ok { + return fmt.Errorf("%w: %s 位于工作树 %s 内", ErrProfileInGitTree, abs, worktree) + } + return nil +} + +// withinDir 报告 child 是否等于 parent 或位于其下(两者都会先转绝对路径再比)。 +func withinDir(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + if rel == "." { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// hasGitAncestor 从 dir 起向上逐级找 `.git`(目录,或 worktree 的 `.git` 文件)。 +// 命中即返回该工作树根;到文件系统根仍未命中返回 false。 +func hasGitAncestor(dir string) (string, bool) { + for cur := dir; ; { + if _, err := os.Stat(filepath.Join(cur, ".git")); err == nil { + return cur, true + } + parent := filepath.Dir(cur) + if parent == cur { + return "", false + } + cur = parent + } +} diff --git a/internal/capability/browsercfg/store_test.go b/internal/capability/browsercfg/store_test.go new file mode 100644 index 0000000..3576845 --- /dev/null +++ b/internal/capability/browsercfg/store_test.go @@ -0,0 +1,209 @@ +package browsercfg + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +// TestLoadMissingFileReturnsNil 钉住「文件不存在 = 未配置」不是错误(design/28 §8 B1: +// 无配置文件时按序探测)。 +func TestLoadMissingFileReturnsNil(t *testing.T) { + path := filepath.Join(t.TempDir(), "browser.json") + cfg, err := Load(path, t.TempDir()) + if err != nil { + t.Fatalf("缺文件不该报错,got err=%v", err) + } + if cfg != nil { + t.Fatalf("缺文件应返回 nil 配置,got=%+v", cfg) + } +} + +// TestLoadRejectsBadFiles 钉住「空文件 / JSON 非法 / 字段越界」都如实报错,不静默顶替。 +func TestLoadRejectsBadFiles(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + body string + }{ + {"空文件", " \n"}, + {"JSON 非法", "{not json"}, + {"视口越上限", `{"viewportWidth": 999999}`}, + {"截图字节越上限", `{"screenshotMaxBytes": 999999999}`}, + {"端点非法", `{"cdpEndpoint": "ftp://127.0.0.1:9222"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".json") + if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { + t.Fatalf("准备文件失败: %v", err) + } + cfg, err := Load(path, dir) + if !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("应返回 ErrInvalidConfig,got cfg=%+v err=%v", cfg, err) + } + if cfg != nil { + t.Fatalf("非法配置不该返回配置,got=%+v", cfg) + } + }) + } +} + +// TestLoadNormalizesAndValidates 钉住 Load 返回的是**归一化且校验过**的配置:缺省字段被 +// 默认补齐,ProfileDir 落到 dataDir 下。 +func TestLoadNormalizesAndValidates(t *testing.T) { + data := t.TempDir() + path := DefaultConfigPath(data) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("建目录失败: %v", err) + } + if err := os.WriteFile(path, []byte(`{"headless": true}`), 0o600); err != nil { + t.Fatalf("写文件失败: %v", err) + } + cfg, err := Load(path, data) + if err != nil { + t.Fatalf("合法配置不该报错,got err=%v", err) + } + if cfg == nil { + t.Fatal("合法配置不该返回 nil") + } + want := Config{ + Headless: true, + ProfileDir: DefaultProfileDir(data), + ViewportWidth: DefaultViewportWidth, + ViewportHeight: DefaultViewportHeight, + TimeoutSeconds: DefaultTimeoutSeconds, + IdleTimeoutSeconds: DefaultIdleTimeoutSeconds, + Concurrency: DefaultConcurrency, + ScreenshotMaxBytes: DefaultScreenshotMaxBytes, + } + if *cfg != want { + t.Fatalf("Load 未归一化\n got=%+v\nwant=%+v", *cfg, want) + } +} + +// TestSaveRoundTrip 钉住写入后可原样读回(归一化后的形态)。 +func TestSaveRoundTrip(t *testing.T) { + data := t.TempDir() + path := DefaultConfigPath(data) + in := Config{Headless: true, ViewportWidth: 1920, CDPEndpoint: "http://127.0.0.1:9222"} + if err := Save(path, data, in); err != nil { + t.Fatalf("Save 失败: %v", err) + } + got, err := Load(path, data) + if err != nil { + t.Fatalf("Load 失败: %v", err) + } + if got == nil || *got != in.Normalize(data) { + t.Fatalf("往返不一致\n got=%+v\nwant=%+v", got, in.Normalize(data)) + } +} + +// TestSaveFileMode0600 钉住配置文件是 0600(design/28 §4;Windows 不看权限位,跳过)。 +func TestSaveFileMode0600(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 不体现 POSIX 权限位") + } + data := t.TempDir() + path := DefaultConfigPath(data) + if err := Save(path, data, Config{}); err != nil { + t.Fatalf("Save 失败: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat 失败: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("配置权限 = %o,want 600", perm) + } +} + +// TestSaveRejectsInvalidWithoutWriting 钉住非法配置不落盘(宁可没有配置文件)。 +func TestSaveRejectsInvalidWithoutWriting(t *testing.T) { + data := t.TempDir() + path := DefaultConfigPath(data) + err := Save(path, data, Config{CDPEndpoint: "not-a-url"}) + if !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("应返回 ErrInvalidConfig,got=%v", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("非法配置不该落盘,Stat err=%v", statErr) + } +} + +// TestSaveLeavesNoTempFile 钉住 temp+rename 收尾干净(不留 .tmp 残留)。 +func TestSaveLeavesNoTempFile(t *testing.T) { + data := t.TempDir() + path := DefaultConfigPath(data) + if err := Save(path, data, Config{}); err != nil { + t.Fatalf("Save 失败: %v", err) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("不该留临时文件,Stat err=%v", err) + } +} + +// TestEnsureProfileDirCreates 钉住 profile 目录按 0700 建好(Windows 不看权限位)。 +func TestEnsureProfileDirCreates(t *testing.T) { + data := filepath.Join(t.TempDir(), "data") + dir := DefaultProfileDir(data) + if err := EnsureProfileDir(dir, data); err != nil { + t.Fatalf("EnsureProfileDir 失败: %v", err) + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + t.Fatalf("profile 目录未建好: info=%v err=%v", info, err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("profile 权限 = %o,want 700", perm) + } + } +} + +// TestEnsureProfileDirRejectsEmpty 钉住空目录被拒。 +func TestEnsureProfileDirRejectsEmpty(t *testing.T) { + if err := EnsureProfileDir(" ", "/data"); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("空目录应返回 ErrInvalidConfig,got=%v", err) + } +} + +// TestCheckProfileLocationAllowsDataDirInWorktree 钉住默认落点合法:数据目录按项目约定是 +// gitignored 的运行期区,即使它落在 git 工作树内也放行。 +func TestCheckProfileLocationAllowsDataDirInWorktree(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatalf("造假工作树失败: %v", err) + } + data := filepath.Join(root, "data") + if err := CheckProfileLocation(DefaultProfileDir(data), data); err != nil { + t.Fatalf("数据目录内的 profile 应合法,got err=%v", err) + } +} + +// TestCheckProfileLocationRejectsWorktreePath 钉住「会进 git」的落点被拦下(design/28 §4)。 +func TestCheckProfileLocationRejectsWorktreePath(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatalf("造假工作树失败: %v", err) + } + dir := filepath.Join(root, "configs", "profile") + err := CheckProfileLocation(dir, filepath.Join(root, "data")) + if !errors.Is(err, ErrProfileInGitTree) { + t.Fatalf("工作树内、且不在数据目录下的 profile 应被拒,got err=%v", err) + } +} + +// TestCheckProfileLocationAllowsOutsideWorktree 钉住工作树之外的目录合法(临时目录自身若 +// 恰好在某个工作树内则跳过,保持测试与机器无关)。 +func TestCheckProfileLocationAllowsOutsideWorktree(t *testing.T) { + dir := t.TempDir() + if worktree, ok := hasGitAncestor(dir); ok { + t.Skipf("临时目录位于工作树 %s 内,跳过", worktree) + } + if err := CheckProfileLocation(dir, ""); err != nil { + t.Fatalf("工作树之外的 profile 应合法,got err=%v", err) + } +}