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

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. 目标

  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):

{ "<扩展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. 复现方式

# 三种模式对照,各连开 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 数据跨更新保留。