# 插件加载实测: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//`,启动不带任何扩展参数 | 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//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 这是本轮最关键的一条,补充记录成方法论: **能装上的写法**(位置 `/External Extensions/external_extensions.json`): ```json { "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } } ``` 三个坑,每个都对应一次失败: 1. **键名不是文档里的那套。** 顶层必须是 `{"": {...}}` 字典(不是 `{"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= --pack-extension-key=` 生成。 3. **CRX 的 manifest 里不能留 `key` 字段**(或必须与签名公钥一致),否则 ID 对不上。 装上之后的实测: ``` location = 2 (EXTERNAL_DOWNLOAD) path = /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/`)。 ## 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//` 一份,各 Profile 共享; - `USER_DATA/Default/Extensions` 不存在,没有重复副本; - 运行态数据:每 Profile 独立在 `Default/Local Extension Settings/`。 也就是说,**改 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 内部**(`/External Extensions/` 目录)后,install 天然只在首次触发(同 Profile 连开 3 次 installs = 1→1→1, boots = 1→2→3,SW 每次正常唤醒)。此 build 的外部安装不落 `Default/Extensions//`(始终为空),扩展直接从 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= --pack-extension-key=` - **更新语义**(每轮全新 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 数据跨更新保留。