Files
SerpMax/docs/extension-install-events-findings.md
T

312 lines
17 KiB
Markdown

# 插件加载实测:onInstalled("install") 触发机制与 bind mount 方案可行性
本文是 `docs/extension-bind-mount-minimal.md` 的实测补充与结论。
全部结论均在**生产同款浏览器 CloakBrowser 146.0.7680.177** 上实测得到
(不是 Playwright 自带的 Chromium 153),可用 `node/scripts/probe-install-modes.ts` 复现。
## 1. 目标
1. 按 bind mount 文档重构插件加载方式(共享扩展代码目录,不再每 Profile 复制一份)。
2. 开发一个小插件,测试**每次浏览器启动是否都会触发 `onInstalled("install")`**。
3. 目标:让 `install` **只在首次触发**。
## 2. 实测环境
| 项 | 值 |
|---|---|
| 浏览器 | `/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome`(CloakBrowser 146) |
| 启动方式 | 直接 `spawn` 真实二进制 + `--headless=new`(**不走 Playwright**,原因见文末) |
| 探针扩展 | `node/tests/fixtures/probe-extension/`(MV3,`key` 内嵌固定公钥,ID 恒为 `lhfmmbkophlpeoebdjpgfampblhkdmjg`) |
| 观测方式 | CDP 在扩展 service worker 上下文求值 `chrome.storage.local.get(['installs','boots'])` |
探针扩展把**每次** `onInstalled` 追加写入 `chrome.storage.local.installs`,并用 `boots` 计数
区分「扩展被加载」与「install 事件触发」——这两者不是一回事,必须分开看。
> 踩过的坑:第一版探针用 `fetch` 上报到本地收集器,结果 146 上被策略拦截,读数恒为 0,
> 一度误判成「扩展没加载」。**观测必须走扩展自己的 storage,不能走网络。**
## 3. 核心实测结果
### 3.1 现状(`--load-extension`,即 cloakbrowser 的 `extensionPaths`)
```
launch 1: installs=1 boots=1 reasons=["install"]
launch 2: installs=2 boots=2 reasons=["install"]
launch 3: installs=3 boots=3 reasons=["install"]
```
**每次启动都触发 `onInstalled(reason="install")`,3 次启动 3 次事件。**
原因在 Chromium 内部写得很清楚:这种扩展在 `Default/Preferences` 里记为
```
location = 8 (COMMAND_LINE)
creation_flags = 38
path = <共享解压目录>
disable_reasons = []
```
`location=8` 意味着它**与命令行绑定**:每次启动命令行都重新「安装」一遍,故 `install` 必然重发。
这是 `--load-extension` 的固有语义,不是 bug。
### 3.2 替代路线逐条实测
| # | 方案 | install 次数 | 扩展是否加载 | 关键证据 |
|---|---|---|---|---|
| 1 | bind mount 到 `Default/Extensions/<id>/<version>`,启动不带任何扩展参数 | 0 | **否** | 目录里文件在(只读、hash 一致),但 Chromium 根本不扫描 `Default/Extensions` |
| 2 | 手改 `Default/Preferences` 伪造注册条目(`location=4`) | 0 | **否** | 回写后 `disable_reasons=[16777216]`(MAC 校验失败被禁用) |
| 3 | CDP `Extensions.loadUnpacked`(浏览器级) | — | — | 146 上 `Extensions.getExtensions` 未实现、`loadUnpacked` 报 `Method not available`;加 `--enable-unsafe-extension-debugging` 仍不行 |
| 4 | 策略 `ExtensionSettings` + `force_installed` + 本地 CRX/update 服务 | 0 | **否** | 日志确认读到策略文件(`Found mandatory policy file`),但外部安装器**从不启动**,update 服务 0 次请求 |
| 5 | CRX 外部安装,只放文件不写对清单键名 | 0 | **否** | 见 3.3 的键名问题,`external_extensions.json` 用文档里的 `path` 会被判 `key external_extensions is not a valid id` |
| 6 | `chrome://extensions` UI「加载未打包的扩展程序」 | — | — | 按钮点击后弹**系统原生文件框**;无 `xdotool`、`Page.setInterceptFileChooserDialog` 拦不到(`fileChooserOpened` 事件 0 次) |
| 7 | **真实外部安装**:`external_extensions.json` + 内部键名 + 浏览器自打 CRX | **3/3** | **是** | 装成 `location=2`(EXTERNAL_DOWNLOAD),`Default/Extensions/<id>/1.0.0_0` 落盘,跨启动存活、`disable_reasons=[]`——**但 install 事件照样每次启动重发** |
第 7 条是唯一拿到「正式注册」的路线(详见 3.3),但它**依然没解决本问题**:
Chromium 每次启动都会把外部安装重新走一遍,于是每次都重新发 `install`。
补充:CRX 打包时**签名私钥必须与 `manifest.json` 的 `key` 同一把**,否则 CRX 头里的
`crx_id` 与 manifest 推导出的 ID 不一致,Chromium 直接拒装。
第一版测试就栽在这里(`scripts/mkcrx.py` 现在会强制回写 `key` 对齐)。
### 3.3 真实外部安装能装上,但仍然每次触发 install
这是本轮最关键的一条,补充记录成方法论:
**能装上的写法**(位置 `<USER_DATA>/External Extensions/external_extensions.json`):
```json
{ "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } }
```
三个坑,每个都对应一次失败:
1. **键名不是文档里的那套。** 顶层必须是 `{"<id>": {...}}` 字典(不是
`{"external_extensions":[...]}` 列表);条目里认的是 `external_crx` / `external_update_url`
这类内部键,不是 `path`。用错时的报错分别是
`key external_extensions is not a valid id`、
`Exactly one of the following keys should be used: external_crx, external_update_url`、
`external_crx and external_version must be used together`。
2. **CRX 必须用浏览器自己打。** 手写的 CRX3 被拒:`程序包无效:"CRX_REQUIRED_PROOF_MISSING"`。
可用 `chrome --pack-extension=<dir> --pack-extension-key=<pem>` 生成。
3. **CRX 的 manifest 里不能留 `key` 字段**(或必须与签名公钥一致),否则 ID 对不上。
装上之后的实测:
```
location = 2 (EXTERNAL_DOWNLOAD)
path = <id>/1.0.0_0 # 版本目录带 _N 后缀 = 每次启动重新装一遍
disable_reasons = []
```
`external_update_url` 那条路在这个浏览器上是死的:`ExternalCrxInstaller` /
`ExternalDownloader` 符号在二进制里 **0 命中**(所有 build 都没有),
只有 `CrxUpdateService` 在,拉不到 CRX。本地 update 服务 0 次请求。
**而事件计数(3 次启动):**
```
launch 1: installs=1
launch 2: installs=2
launch 3: installs=3
```
**结论:在 CloakBrowser 146 上,不存在任何一种加载方式能让
`onInstalled("install")` 只触发一次。** 连「正式的外部安装」都做不到,
因为它每次启动同样会重跑安装流程。
### 3.3 重要副作用:扩展数据其实是持久的
首启写入 `marker`,后续不带任何标记重启:
```
B marker = {"boots":2,"marker":"written-in-first-launch"}
C marker = {"boots":3,"marker":"written-in-first-launch"}
```
即 **`chrome.storage.local` 跨启动保留,数据不丢**;重发的只是 `install` 事件本身。
另外 `Default/Extensions` 目录**根本不存在**——扩展代码只有共享目录那一份,
`USER_DATA` 里只留运行态数据(`Default/Local Extension Settings/<id>`)。
## 4. 结论
### 4.1 关于 bind mount 文档
文档第 12.4 节自己已经写明了这个前提:
> bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。
> 要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。
实测把这句话补全了:**在 CloakBrowser 146 上,「正常的安装/注册方式」无法自动化。**
「只触发一次」要求扩展被写进 `Secure Preferences` 且 HMAC 正确,唯一能产生这个结果的
入口(外部安装器 / `loadUnpacked` / UI 加载)在此版本全部不可用。
所以:**照 bind mount 文档改,能达成「共享代码、不复制多份」,但达不成「install 只触发一次」。**
### 4.2 一个反直觉的结论:bind mount 想要的收益,现状已经拿到了
现状(`--load-extension` + 共享解压目录)其实**已经**满足文档第 1 节的目标
「让多个 Profile 共用同一份 Extension 文件」:
- 扩展代码:只有 `data/extensions/<extId>/` 一份,各 Profile 共享;
- `USER_DATA/Default/Extensions` 不存在,没有重复副本;
- 运行态数据:每 Profile 独立在 `Default/Local Extension Settings/<id>`。
也就是说,**改 bind mount 主要是把「共享」这件事从「共享目录 + 命令行指路」换成
「共享目录 + 挂载到 Profile 内」,收益是结构上的整洁,不是磁盘占用的大幅下降。**
(除非某些扩展会往自身目录写文件,那种情况下 bind mount 还能顺带省空间。)
### 4.3 想要「install 只触发一次」的可落地做法
> **2026-10-01 更新:本节"做不到"的结论已被静默安装方案推翻**,见 §7——
> 第 7 条路线的探针把 CRX 放在 Profile **外部**,这是「每次重装」的混淆变量;
> CRX 放进 Profile 内部(External Extensions 目录)后 install 天然只发一次。
> 下面的垫片方案保留,现仅用于 `EXTENSION_INSTALL_MODE=loadext` 回退链路。
**先说死结论:在 CloakBrowser 146 上做不到。** 3.2 的七条路线里,连拿到正式注册的
第 7 条(真实外部安装)都每次重发 `install`。事件本身无法阻止。
能落地的是**让这件事无害**,SerpMax 侧已实现的做法是「幂等安装垫片」:
- 实现:`node/src/core/browser/extension.install-shim.ts`
- 落点:`extension.sync.ts` 解压完插件后,把垫片拼到后台脚本(`sw.js` /
`service_worker.js` / `background.js`)最前面,把 `chrome.runtime.onInstalled.addListener`
包一层幂等闸门——首次放行,之后直接吞掉。
- 开关:`EXTENSION_INSTALL_SHIM`(默认开,置 `0` 关闭回到插件原始行为)。
- 标记键:`chrome.storage.local.__serpmax_init_done`,随 Profile 持久化,
因此「首次」是**每个 Profile 的首次**,跨浏览器重启有效。
实测(同一 Profile 连开 3 次,真实二进制 CloakBrowser 146):
| 模式 | install 事件 | 插件初始化真正执行次数 |
|---|---|---|
| `loadext`(现状) | 1 → 2 → 3 | 1 → 2 → 3 |
| `external`(真实外部安装) | 1 → 2 → 3 | 1 → 2 → 3 |
| `shim`(垫片) | 1 → 1 → 1 | 1 → 1 → **1** |
`shim` 那一行 `install 事件` 也是 1,是因为连探针自己的计数回调都被闸门挡住了——
这正是效果:**从插件视角看,install 只发生了第一次**。
> 垫片的取舍:它改的是第三方插件的后台脚本。SerpMax 的插件是用户上传的产物,
> 我们不掌握其源码,所以只能像这样在分发前注入。风险点是改坏了插件,
> 因此默认开但可一键关闭,且注入位置/幂等性都有单测覆盖
> (`node/tests/extension-install-shim.test.ts`)。
> 如果某个插件自己已经幂等,注入是无害的(闸门只会让它更早返回)。
## 5. 复现方式
```sh
# 三种模式对照,各连开 3 次统计 install 与初始化次数
cd node
bun scripts/probe-install-modes.ts loadext 3 # 现状:--load-extension
bun scripts/probe-install-modes.ts shim 3 # 加幂等垫片
# 真实外部安装模式需要先用浏览器自己打 CRX
EXE=/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome
cp -r tests/fixtures/probe-extension /tmp/pack-ext && rm -f /tmp/pack-ext/README.md
python3 - <<'PY'
import json; p='/tmp/pack-ext/manifest.json'
m=json.load(open(p)); m.pop('key', None); json.dump(m, open(p,'w'), indent=2, ensure_ascii=False)
PY
openssl genrsa -out /tmp/pack-key.pem 2048
$EXE --headless=new --no-sandbox --pack-extension=/tmp/pack-ext --pack-extension-key=/tmp/pack-key.pem
bun scripts/probe-install-modes.ts external 3 \
CRX_PATH=/tmp/pack-ext.crx EXT_ID=$(python3 -c "
import hashlib,subprocess
subprocess.run(['openssl','rsa','-in','/tmp/pack-key.pem','-pubout','-outform','DER','-out','/tmp/pub.der'],check=True)
h=hashlib.sha256(open('/tmp/pub.der','rb').read()).hexdigest()[:32]
print(''.join(chr(ord('a')+int(c,16)) for c in h))")
# 需要自己写 CRX 打包时(会强制回写 manifest.key 与签名私钥对齐)
python3 scripts/mkcrx.py tests/fixtures/probe-extension /tmp/probe.crx /tmp/probe.pem
```
## 6. 涉及的仓库文件
- `node/tests/fixtures/probe-extension/` — 探针扩展(`manifest.json` / `sw.js` / `README.md`)
- `node/scripts/probe-install-modes.ts` — 三模式(loadext / external / shim)多启动计数脚本
- `node/scripts/probe-silent-install.ts` — **静默安装端到端探针(生产模块链,见 §7)**
- `node/scripts/mkcrx.py` — CRX3 打包(强制 manifest.key 与签名私钥一致)
- `node/src/core/browser/extension.install-shim.ts` — 幂等安装垫片(注入 + 幂等闸门,现仅 loadext 回退用)
- `node/src/core/browser/extension.silent.ts` — **静默安装(稳定私钥/CRX 打包/External Extensions 写入)**
- `node/src/core/browser/extension.sync.ts` — 同步分流:silent(默认)/ loadext 回退
- `node/tests/extension-install-shim.test.ts` — 垫片单测(定位/幂等/语法/异常兜底)
- `node/tests/extension.silent.test.ts` — 静默安装单测(buildVersion/extIdFromPem/installSilently)
> 脚本里刻意**不用 Playwright** 启动:Playwright 默认带的是**旧 headless**
> (`--headless`),旧 headless 根本不加载扩展,会读出「扩展没装」的假象。
> 必须 `--headless=new`,或直接在有显示的环境里有头启动。
## 7. 静默安装定版(2026-10-01,推翻 §4.3 的"做不到")
Policy 变更(用户定版):**插件装载从「共享目录 + `--load-extension` 命令行装载」
改为「每个 Profile 静默安装」,不再坚持插件文件共享。**
### 7.1 为什么旧探针得出"外部安装每次重发 install"的假结论
§3.2 第 7 条(真实外部安装)的探针把 CRX 放在 **Profile 外部**(如 `/tmp/probe.crx`),
`external_extensions.json` 指过去。实测版本目录出现 `_N` 后缀、install 每次重发。
2026-10-01 复测发现混淆变量:**CRX 放进 Profile 内部**(`<USER_DATA>/External Extensions/`
目录)后,install 天然只在首次触发(同 Profile 连开 3 次 installs = 1→1→1,
boots = 1→2→3,SW 每次正常唤醒)。此 build 的外部安装不落
`Default/Extensions/<id>/<version>`(始终为空),扩展直接从 CRX 路径装载。
### 7.2 两个杀手旗标(Playwright 默认参数集)
裸 spawn 下能装 ≠ 生产 launchPersistentContext 下能装。逐旗标二分(每轮全新 Profile)
定位出两个必须经 `launchOptions.ignoreDefaultArgs` 剔除的 Playwright 默认旗标:
| 旗标 | 效果 |
|---|---|
| `--disable-extensions` | 扩展系统整体关闭(§3.2 已知) |
| `--disable-default-apps` | **关闭 ExternalProvider,外部 CRX 静默安装直接不执行(新发现)** |
注:仅有 `--disable-extensions-except`(不带 `--load-extension`)**救不回来**——
实测装不上。cloakbrowser 的 `ignoreDefaultArgs` 字段被 `launchOptions` 展开
顺序覆盖(`dist/playwright.js` 装配顺序),所以生产代码经
`launchOptions: { ignoreDefaultArgs: ["--enable-automation", "--enable-unsafe-swiftshader", "--disable-extensions", "--disable-default-apps"] }`
一并剔除(cloak 原有两项必须保留,否则 `--enable-automation` 回来破坏隐身)。
### 7.3 稳定扩展 ID 与更新语义
- 扩展 ID = 签名公钥推导(SPKI DER → sha256 前 16 字节 → a..p)。私钥按 extId
固定存 `{extensionDir}/{extId}.key.pem`(600)→ 重打包 ID 不变。
**私钥不能放扩展目录内**——`--pack-extension` 遇目录内 .pem 直接拒绝
(「此扩展程序包含密钥文件」)。manifest 必须删 `key` 字段(实测保留会 ID 漂移)。
- CRX 只能浏览器自打(§3.2 第 7 条),命令:
`$EXE --headless=new --no-sandbox --pack-extension=<dir> --pack-extension-key=<pem>`
- **更新语义**(每轮全新 Profile 实测):
- 同 version 覆盖 CRX → **不会更新**,一直跑旧代码;
- manifest 版本追加第 4 段(如 `1.0.0` → `1.0.0.2`)且 `external_version` 同步 →
下次启动 Chromium 重装,install 以 `reason="update"` 恰好重发一次
(`previousVersion` 正确),新代码从再下一次 SW 启动生效。
- 因此 staging 状态文件记录 `{ sha256, origVersion, build }`:zip sha256 每变化
一次 build+1,打包版本 = `buildVersion(origVersion, build)`。
### 7.4 生产链路落点(node v0.8.0)
```
extension.sync.ts ensureExtensionsReady → { dirs(loadext), silent(silent) }
staging:下载/解压/sha 增量;sha 变化时删外层旧 CRX + build+1
extension.silent.ts 稳定私钥 → manifest 变换(删 key/版本对齐)→ 浏览器自打 CRX
→ installSilently(复制 CRX 进 Profile + external_extensions.json
整文件重写 + 无主 CRX 清退)
browser.manager.ts silent:installSilently 写 Profile + ignoreDefaultArgs 剔除两旗标
(不再传 extensionPaths);无插件仍 --disable-extensions
```
配置:`EXTENSION_INSTALL_MODE`(默认 `silent`,`loadext` 回退旧链路,垫片只在
loadext 生效)、`EXTENSION_INSTALL_SHIM`(不变)。
复现:
```sh
cd node
xvfb-run -a bun scripts/probe-silent-install.ts 3 # 生产模块链端到端,预期 installs 1→1→1
bun test tests/extension.silent.test.ts tests/extension.sync.test.ts
```
实测记录(2026-10-01,真实二进制 cloak 146):
首装 installs=1;三连启 1→1→1;更新启动后 installs=["install","update"]、
codeTags 出现新版本标记;同 Profile 数据跨更新保留。