Files
Cyrene_v2/internal/capability/browsercfg/store.go
T
AskaEth 29181245ce 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。
2026-10-06 09:03:34 +08:00

143 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}