feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)

按 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。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:34 +08:00
1 parent b7fb4dd1c7
commit 29181245ce
9 files changed
+1398

No files matched your search

+142
View File
@@ -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:先写 `<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
}
}