17 KiB
插件加载实测: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. 目标
- 按 bind mount 文档重构插件加载方式(共享扩展代码目录,不再每 Profile 复制一份)。
- 开发一个小插件,测试每次浏览器启动是否都会触发
onInstalled("install")。 - 目标:让
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):
{ "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } }
三个坑,每个都对应一次失败:
- 键名不是文档里的那套。 顶层必须是
{"<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。 - CRX 必须用浏览器自己打。 手写的 CRX3 被拒:
程序包无效:"CRX_REQUIRED_PROOF_MISSING"。 可用chrome --pack-extension=<dir> --pack-extension-key=<pem>生成。 - 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. 复现方式
# 三种模式对照,各连开 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(不变)。
复现:
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 数据跨更新保留。