Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/144-插件规模化投放与版本一致性.md
T
admin e6207aa691
build / build-and-scan (push) Canceled after 0s
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

27 KiB
Raw Blame History

144 · 插件规模化投放与版本一致性(几百~上千用户时"这类问题"怎么办)

  • 日期:2026-09-20
  • 触发:用户「guest 的也处理,后续几百上千个用户的时候,遇到种类问题如何处理,有没有更好的方案」
  • 对象:功能插件的「投放 / 升级 / 版本一致性 / 契约失效」整条链路
  • 状态:方案(待拍板选档)。本轮只落地了 P0 的基元(scripts/install-plugin-for-user.cjs)+ guest 补齐, 未做架构改造;孪生交接单待选定档位后再出(避免造出会被推翻的执行单)。 ⤵️ 2026-09-21 追加 §八 可行性复核:A 档(Worker 共享只读插件层)判定 可行,成本下调至 2~3 天; 同时发现 2 条原方案未提到的硬约束(写 profile 清单的通路 / ro 语义变化),其中一条直接影响 B 档能否开工。 🔴 2026-09-21 范围修正:§八 的「2~3 天」只覆盖"47 单机把共享层立起来"那一段,⛔ 不是这条需求的总量 —— 该判断默认了"所有用户都在 47 本机"。整张网摊开后,还缺一条今天完全不存在的承重墙 (「控制面 → 某一台节点上的某一个用户」的投放通路:mine/apply 全程本机路径与本机 uid, 跨机唯一那条腿 writeHandoff 是空转,DSHS_ENABLE_PATCH 默认 false)。 ⇒ 三问(组网 / 分工 / 各节点类型用户的插件分发与数据库连接)逐条答 + 五条硬缺口 + 推进顺序,见 147-多节点形态下的插件投放与数据面.md。

TL;DR|今天这类问题的真实瓶颈不是耗时,而是"看不见 + 不一致 + 静默失效":

  • 耗时是可算的:单用户 pnpm add 实测秒级 ⇒ 1000 用户串行 ≈ 1 小时级;按 Worker 分组并行后是分钟级; "停机"实际影响 = 每用户下次访问慢一次(平台按需拉起,非批量停服)。
  • 真正贵的是四件事:① 编排脚本在本机枚举用户 ⇒ 迁到别的 Worker 的用户被静默跳过(今天 guest 就是这么被漏的, 输出里分不出"没装"与"装失败");② 没有版本台账 ⇒ 漏装只能靠人发现;③ 每用户一份拷贝 + 无版本协商 ⇒ 同一句"点了没反应"在新旧版本上根因不同、排查成本翻倍;④ 契约失效零报错(档案 143 的两条真因都不抛异常)。
  • 推荐路线:把必装插件从「每用户安装」改成「Worker 共享只读插件层 + 每用户只留开关」 —— 与现有 bundled-skills 同族(沙箱 scope 里已有 --ro-bind-try /var/lib/dshs/bundled-skills 先例), 升级从 O(N) 次安装降到 O(1) 份内容;配套补台账(PG)+ 按 Worker 分组编排 + 契约哨兵。
  • 🔒 共享的是「不可变代码」,不是数据:每用户仍是各自 uid + 各自 bwrap 的独立实例,不存在「一个共享进程服务所有用户」;用户数据仍按 home 隔离(证据与四条红线见 §五)。共享唯一真实新增的风险是爆炸半径 1 → N(共享层被投毒会同时影响所有人)。
  • 📂 运行期产生的文件(数据 / 产物 / 日志 / 临时文件)不落共享层:按「五问判据」落到 <userRoot>/home(权威)· <userRoot>/ws(可见)· 实例 /tmp(每用户私有,临时)· 平台库(平台级)—— 见 §六。

一、现状机制与四处硬伤(都有今天的实证)

层 现状 硬伤 今天的实证
编排 ensure-*.cjs = 在本机枚举用户(SQLite/PG)+ 逐个 pnpm add 用户迁到别的 Worker ⇒ existsSync(<home>/profiles/...) 落空 ⇒ 静默跳过(与"装失败"在输出里无法区分) portal-entry 0.5.6 只落到 admin;guest 打印 NO_PROFILE → 跳过
可见性 无任何"谁装着哪个版本"的记录;dsh_instances 只管进程 漏装/版本漂移只能靠人发现 guest 被漏是靠用户追问才暴露
存储/安装 每个用户各自一份 node_modules(各自 store/依赖树) 4 个必装插件 × N 用户 = 4N 次安装、4N 份磁盘、4N 个可能不一致的版本 同一平台里 admin=0.5.6 / guest=0.5.5 并存
契约 插件把 dsh 的外部契约写死在代码里(槽位名 / 官方 API 形态) dsh 升级后静默失效(不抛错、不崩,只是"点不动");且无版本协商(npm 只校验包版本,不校验宿主 API) conversation→main.conversation;getLocale() 由 id 字符串变快照对象

附:插件"per-user 可变数据"(如 mcn 自己的 DB)不受本方案影响 —— 那些数据本来就在用户 home 里,必须跟着用户走。


二、本轮已落地的基元(P0 的一块):scripts/install-plugin-for-user.cjs

把「给某台机上的某个用户装某个 tgz」收敛成一个机器无关的原子动作(已用于 guest,实测 OK):

node install-plugin-for-user.cjs --home <home_dir> --uid <uid> --tgz <abs path> [--profile web] [--expect <ver>] [--dry-run] [--no-stop]
  • 机器无关:入参只有 home / uid / tgz ⇒ Manager 或任意 Worker 上都能跑同一条命令。
  • 幂等 + 可断言:包名/版本从 tgz 里读(不靠参数);--expect 不符或实装版本≠tgz 版本 ⇒ 退出码 2(编排层可判、可重试)。
  • 不静默:tgz 不可读、不是 bundle、未进 dsh.profile.bundles、pnpm add 失败 ⇒ 各自独立报错 + 非零退出(对照:老脚本把"跳过"混在成功里)。
  • 姿势沿用既有脚本:暂存 <home>/.dsh-stage → setpriv 降权 → pnpm add file:(store/cache 沿用既有,不新建)→ reconcileBundles → 停该 uid 的实例 scope。
  • ⚠️ 它只解决"单用户单插件"。要支撑 N 用户,还缺"谁该装 → 按机分组 → 汇总三态 → 台账"这一层(见 §三 C)。

三、候选方案(做法 / 优点 / 缺点 / 成本)

A. 必装插件改成「Worker 共享只读插件层」(推荐治本)

做法:/var/lib/dshs/bundled-plugins/<pkg>/<version>/(每台 Worker 一份,版本目录内容不可变); per-user 只留开关清单(PG)+ 启动时在该 profile 的 node_modules 建软链;实例启动把共享层 ro-bind 进沙箱 (照抄 bundled-skills 的做法)。

  • 优点:升级 = 放一份新版本 + 改指向 ⇒ O(1) 份磁盘、0 次 pnpm;同机版本天然一致;回滚 = 改回指向;跨机 = 每机一份(可由节点自举通道分发)。
  • 缺点:要动实例启动链路(沙箱白名单 + profile 装配),改动面比脚本大;dsh 的插件解析是否跟随软链需先 PoC。
  • 成本:3~5 天(含 PoC)。
  • 必须先验证:① node_modules/<pkg> 为 symlink 时 dsh 能否正常解析并加载 client bundle;② ro-bind 共享目录后实例启动正常; ③ 旧版本目录保留策略(建议只保留最近 K=2~3 个版本)。

B. 用户可选插件同构(共享层 + 每用户启用位)

做法:同 A,但"启用位"由用户自助(现有「功能管理」UI 直接读写开关表);候选人池语义从"上传 tgz → 每人装"变成"上架到共享层"。

  • 优点:管理员上架一次;用户开关不产生安装动作;插件版本在平台内可收敛。
  • 缺点:多版本共存(A 用 v1、B 用 v2)会吃掉"共享一份"的收益;需要明确"允许几个版本在线"的策略。
  • 成本:A 之上 +1~2 天。
  • 依赖:A 先立住。

C. 编排层与台账(低成本 · 立刻可做 · 与 A/B 正交)

做法(四件,彼此独立):

  1. 单一事实来源 = PG:新增 user_plugin_state(user_id, plugin, version, worker_id, status, updated_at, last_error); 脚本不再"枚举本机用户",而是从 PG 取目标集合 → 按 worker 分组 → 逐机执行 → 回写状态。
  2. 分发器:复用 §二的基元,只负责"打哪个 tgz 到哪台机的哪个 uid + 重试 + 汇总"。
  3. 三态输出:成功 / 失败 / 未达预期;⛔ 禁止把"跳过"混进成功(今天 guest 就是这么被漏的);--dry-run 先出"谁要升、从哪版到哪版"清单。
  4. 管理面可见:门户加「插件版本分布」(按插件显示各版本人数 + 异常用户清单)。
  • 优点:成本最低、当天可做;立刻消除"漏用户/版本漂移不可见";对 A/B 是前置能力(改造后仍需要它来管开关)。
  • 缺点:不减少安装动作本身(仍 O(N) 次 pnpm add)——但在"分组并行"下这已不是瓶颈。
  • 成本:半天~1 天。

D. 契约失效治理(与用户量无关,但必须一起做)

  1. 契约收口:插件侧不写死外部契约(档案 143 已把槽位名做成双名兼容);dsh 版本敏感的取值/选择器集中到一个适配模块。
  2. 契约哨兵:把"官方契约快照"做成断言(现成入口 scripts/verify-dsh-compat.mjs):槽位名清单、 ctx.locale.getLocale() 返回类型、sidebar.footer.action 注入的 props ⇒ 升级 dsh 前必跑,不符就拦住升级或先改插件。
  3. 失效可见化:插件挂载失败必须出声(console.error + 页面提示条)⇒ 把"静默点不动"变成"用户能报障"。
  4. 版本协商:插件声明 dshCompat(如 >=0.1.5 <0.2),平台安装/启动时校验,不符 ⇒ 拒绝启用 + 明确错误(而不是半死不活)。
  • 优点:成本极低、收益极高 —— 下次 dsh 升级不会被静默打穿(今天的排查成本 = 整个会话)。
  • 缺点:哨兵需要随 dsh 升级维护(每次升级补几条断言)。
  • 成本:D2+D3 = 1~2 天;D4 = +1 天。

四、建议顺序(我的倾向)

档 内容 成本 解决的痛
P0 C1+C2+C3(PG 台账 + 按 Worker 分组分发 + 三态输出)+ 把 install-plugin-for-user.cjs 铺到 Manager/各 Worker 固定路径 半天~1 天 "漏用户"与"看不见"
P1 D2+D3(契约哨兵 + 失效可见化) 1~2 天 升级 dsh 被静默打穿
P2 A(必装插件共享只读层,含 PoC + 沙箱白名单 + 启动装配) 3~5 天 每用户安装的根
P3 B + C4(管理面版本分布)+ D4(版本协商) 2~3 天 可选插件与长期一致性
  • 若只做一件事:P0 —— 它把"不可见的漏装"变成"可见、可重试、可汇总",今天这条 bug 的代价(漏一个用户 = 一轮会话)当场归零。
  • 若只做一件治本的:P2(=A) —— 它把"每用户安装"这个形态本身去掉。
  • 不建议:先做 B(B 依赖 A 的形态)。

五、共享层的安全面(回答"不同用户是否都能访问到各自/别人的数据")

一句话:共享的是不可变的代码文件,不是运行态或数据。每个用户仍是各自 uid + 各自 bwrap 的独立实例进程 (插件代码只是同一份文件被多个进程只读加载,类似"同一个二进制被不同用户各起一个进程")—— **不存在"一个共享进程服务所有用户"**那种进程内状态混用。

实测证据(2026-09-20,47):

事实 实测
用户目录互不可达 /var/lib/dshs/users/<uuid> = drwx------、属主 = dsh-<hash>(该用户 uid);/var/lib/dshs 与 users/ = 711 root ⇒ 用户 A 的进程是 A 的 uid,连 B 的目录都进不去
沙箱内根本不存在别人的路径 实例 scope:--tmpfs /var/lib/dshs + --tmpfs /var/lib/dshs/users(整棵用户树被空 tmpfs 覆盖)后,只把自己 <uuid> 与 <uuid>/tmp 绑回
/tmp 每用户私有 --bind /var/lib/dshs/users/<uuid>/tmp /tmp
已有"共享只读层"先例 --ro-bind-try /var/lib/dshs/bundled-skills /var/lib/dshs/bundled-skills —— 技能共享层就是以只读挂进来的
插件自有数据天然隔离 MCN 的 DB 在 <uuid>/home/.dsh/mcn-plugin.db,属主 = 该用户 uid(且在 700 的 home 内)⇒ 共享代码后仍是每用户一份,互不可见

共享化改变的是"代码的可写性",不是"数据的可见性":

  • 更好:今天插件副本在 <uuid>/home/profiles/web/node_modules/(属主=用户、用户可写)⇒ 用户能改自己那份插件代码; 共享层做成 root:root 0755 + 沙箱内 ro-bind ⇒ 插件代码用户不可篡改(对"篡改插件"这类风险是增强)。
  • 更差(唯一真实新增风险):爆炸半径从 1 变 N —— 共享层被误投/被投毒,会同时影响所有用户。这与"互读数据"无关,但更需要防。

四条红线(缺一条就别做共享层):

  1. 共享层只放不可变代码与静态资源(client/host 代码、web 静态、只读 skills)。 ⛔ 绝不放 DB / 缓存 / 日志 / 上传物等任何可变状态 —— 那才是真正的跨租户泄露。
  2. 沙箱内必须 ro-bind(照抄 bundled-skills),宿主侧 root:root 0755 且用户不可写。 若以 rw 绑定 ⇒ 任一用户可改共享代码影响全平台 —— 这比"互读数据"更严重。
  3. 版本目录内容不可变(同名版本永不覆盖)+ 投放只走 admin 通道 + 产物哈希清单 (复用 skills/.manifest.json 的 sha256 手法)+ 定期完整性校验(漂移即告警)。
  4. 插件数据落点仍走既有判据(档案 27 §一 / 集群化数据分层):跟着用户走的 ⇒ 实例 home(uid 隔离;host 半边写 home 必须走 UserFs); 平台级 ⇒ 平台库(带插件前缀 + 内核 ACL 注入)。⛔ 禁止插件写宿主机固定路径(/tmp/xxx、/var/lib/dshs/xxx)。

附带收益:灰度与回滚比现在更容易 —— 共享层是"版本目录 + 每用户指向" ⇒ 可先 1 台 Worker / 1 个用户组指向新版本, 出问题改回指向即可(今天"每用户安装"的回滚要再跑一遍 N 次 pnpm)。


六、运行期数据的落点(数据 / 产物 / 日志 / 临时文件)

一句话:共享层只放"永远不会变的东西"(代码 + 随包静态资源);一切运行期产生的可变状态都按下面的判据落到 每用户自己的目录或平台库。共享插件不会让这些文件跑到一个公共目录里去。

6.1 先分清"三个半边"(能写什么,取决于代码跑在哪)

半边 跑在哪 能写 不能写
client(浏览器) 用户浏览器里 只有浏览器侧存储(localStorage 等,天然按用户浏览器隔离);要落盘必须调 host 半边 文件系统(本来就没有)
host(实例进程) 该用户的 dsh 实例进程,uid = 该用户 自己的 home / 自己的 ws / 自己的 /tmp ⛔ 共享层(ro 绑定)、⛔ 别人的 home(700 + 不同 uid)
平台(dshs 进程) 平台服务里 平台库(PG)· /opt/dsh/{state,backups} ⛔ 直写用户 home(必须走 UserFs,档案 138 §五:直写 = 静默空操作)

6.2 五问判据(照这个走,不会错)

  1. 这份数据"跟着用户走"吗?(换 Worker / 迁移后必须还在)→ 是 ⇒ <userRoot>/home(权威面;host 半边写 home 必须走 UserFs)。
  2. 用户要看得见 / 下载 / 在工作区里引用它吗? → 是 ⇒ <userRoot>/ws(可见面)。⚠️ 可见面 ≠ 权威面,同一份真值不要双写(双写 = 脑裂)。
  3. 它是"整个平台共享的一份"吗?(不属任何单个用户,如榜单/房间/平台视图)→ 是 ⇒ 平台库 PG(表名带插件前缀 + 内核 ACL 注入,档案 142 §九),⛔ 不放共享目录(既写不进,也会被所有人看到)。
  4. 只是本次运行的中间物、丢了可以重建吗? → 是 ⇒ 实例内 /tmp(每用户私有)或 home 下的 cache 子目录,自带清理策略。
  5. 是平台运维 / 审计吗?(升级记录、版本台账、备份)→ 是 ⇒ /opt/dsh/{state,backups} + PG,只平台写。

6.3 一类一表(含 47 上 admin 实例的实测)

类别 落点 性质 实测证据(2026-09-20)
插件代码 + 随包静态资源(web / skills / 模板) Worker 共享只读层(每机一份) 不可变;⛔ 任何可变状态不得落这里 已有先例:scope 里 --ro-bind-try /var/lib/dshs/bundled-skills
插件自有 DB / 索引 / 用户偏好 <userRoot>/home/.dsh/<plugin>.db(由平台注入的 DSH_HOME 决定) 权威;用户不可见(home 700) home/.dsh/mcn-plugin.db(77 KB)✓
用户产物(报告 / 导出 Excel / 拆解结果) <userRoot>/ws/... 用户可见可下载 ws/{mcntimo, mcnworkspace}
临时文件 沙箱内 /tmp = --bind <userRoot>/tmp /tmp 每用户私有;可留可清(⛔ 不是"共享 tmp") scope 参数实测
运行期缓存(Node 编译 / 下载 / 模型片段) home/.cache、home/.node-compile-cache,或 /tmp(要求可丢) 不参与备份/迁移语义 home/.node-compile-cache/
日志(插件自己写的) <home>/.dsh/logs/<plugin>.log(或平台侧 per-user 备份目录) 只读诊断;⚠️ 写共享层 = 全平台互见 同族现状:平台 ensure 脚本写 home/.dsh-biz-plugins.log、home/.dsh-poc-portal-entry.log
日志(实例进程 stdout/stderr) 由 systemd 按 unit 采集(journal) 平台侧统一看,不落 per-user 文件 实例以 systemd-run --scope 起,输出归 journal
平台级 / 跨用户数据 平台库 PG(插件前缀表 + ACL) 权威;插件不直连 DB 144 §三 C1 的台账表
运维 / 审计 /opt/dsh/{state,backups} + PG 只平台写 —

6.4 两条铁律(踩了就出事)

  1. ⛔ 可变状态一律不进共享层。技术上也写不进去(ro 绑定),设计上更不允许 —— 一旦放进去就是跨租户互见 + 无法清理。
  2. ⛔ 不许写宿主机"固定路径"(/var/lib/dshs/logs/x.log、/tmp/plugin-y 这类不区分用户的路径)。 多用户写同一路径 ⇒ 互见 + 冲突 + 无法按用户清理。host 半边要写"用户自己的文件",必须走 UserFs。 (§6.3 表里 home/mcn-plugin.db 那条旧残留,就是"落点没按判据走"留下的历史垃圾。)

6.5 对迁移 / 备份的连带影响(这也是共享层的一个附带好处)

  • 权威数据(home)+ 产物(ws)都在 <userRoot> 下 ⇒ 跨 Worker 迁移 / 备份只搬 <userRoot>(档案 120 的流程不变); 缓存与 tmp/ 不参与语义(可丢、可重建)。
  • 共享层只是"代码",用户数据一个字都不用碰 ⇒ 共享化后迁移/升级时少了 N 份代码搬迁。

七、待拍板(选定后即出孪生交接单)

  1. 起步档位: C 先行(P0 台账+编排)—— 优点:成本最低、当天见效,且是 A/B 的前置;缺点:安装动作仍是 O(N) 次。 A 直接做(共享层)—— 优点:一次到位、根治形态;缺点:改动面在实例启动链路,需 3~5 天且要先 PoC。 A+C 并行 —— 优点:可见性与形态同时推进;缺点:两处并行改动,验证面更大。 ▶ 我倾向 C 先行、A 紧随(C 是 A 的前置能力,且能立刻止血)。
  2. 共享层试点范围:仅 47 试点(优点:风险最小;缺点:跨机分发链路没被验证)/ 47+106 同时(优点:一次验证分发与跨机;缺点:两台一起动)。
  3. 多版本策略:同机只允许一个版本(优点:最简、最一致;缺点:灰度要停机切换)/允许 K 个版本并存(优点:可灰度;缺点:磁盘与心智成本上升)。

八、可行性复核(2026-09-21 · 取 dsh 运行时+线上活实例实据)

触发:用户「之前讨论过用户共享只读插件目录的需求,分析看是否可行」。 方法:读 dsh 0.1.5-rc.1 的 @deepseek-ai/dsh-app-boot 解析契约 + 线上活实例 的 bwrap 实参 + 平台侧 UserFs seam。

8.1 结论

可行。 §三 A 里那三条「必须先验证」,① ② 已由代码/现网证据判定通过,③ 只是设计选择; ⇒ 成本从原估 3~5 天 下调至 2~3 天(含 PoC)。 但 §8.4 有 两条原文没提到的硬约束,其中一条会改变 **B 档「用户自助开关」**的落地方式。

8.2 逐条判定原「必须先验证」三项

原待验证项 判定 证据
① node_modules/<pkg> 为 symlink 时,dsh 能否解析并加载 client bundle ✅ 通过(且已是现网常态) resolveBundleDir() 实现 = 纯 node_modules 目录树走查(existsSync(join(candidate,'package.json')) → readFileSync),没有任何 realpath 限制。现网该 profile 里 dsh-plugin-mcn-suite 本身就是软链(→ .pnpm/…),另有 20+ 依赖软链到 .dsh-module-fallback/node_modules/…
② ro-bind 共享目录后实例能否正常启动 ✅ 机制同构,且有在跑的先例 活实例 scope 实参里已有 --ro-bind-try /var/lib/dshs/bundled-skills /var/lib/dshs/bundled-skills。平台侧改动 = 在 orchestrator.ts 同一处数组再加一条(沿用现有写法 ...(config.bundledSkillDir !== '' ? ['--ro-bind-try', d, d] : []))+ config.ts 加一个字段(照 bundledSkillDir)
③ 旧版本目录保留策略 设计选择(建议 K=2~3),不阻塞 —

8.3 解析契约(照它做才不白做)

resolveBundleDir(binName, packageName, installAnchor, profileDir):
  for (anchor of [installAnchor, join(profileDir,"package.json")])   // ← 顺序有契约含义
    dir = packageDirFromAnchor(anchor, packageName)                  // 逐级上溯找 node_modules/<pkg>
    if (dir) return dir
  throw "cannot resolve profile bundle …"
  • bundles 是层列表(按序叠加),每个 bundle 必须自带 dsh.bundle.patch(→ cordis.patch.yml),否则抛错(不静默)。
  • 🔴 搜索顺序 = dsh 安装优先,profile 次之。官方注释原话:@deepseek-ai/dsh-base(及所有 in-box bundle)永远取自与运行中 dsh 同一份安装,绝不取 profile 本地副本。 ⇒ 共享层只对「非 in-box」的第三方/自研插件生效;⛔ 插件名必须避开 @deepseek-ai/*,否则放进去也不会被取到(会被 dsh 自带那份压掉)。
  • 版本目录内必须自带完整依赖树(插件运行时要 import 自己的依赖)⇒ 省的是 N 份 → 1 份,不是"零依赖"。⚠️ 这也意味着共享层要能容纳 .pnpm 这类隐藏目录(ro-bind 整棵即可)。

8.4 🔴 两条 144 原文未提到的硬约束

① 平台侧写不到 <profile>/package.json —— 直接决定 B 档能不能做。 UserFs 的 home 面白名单只有 3 个裸文件名:settings.yaml、.credentials.yaml、.overlay-device.json (src/fs/user-fs.ts:126)。profiles/web/package.json 不在其中;而用户卷在别的 Worker 上时, 平台侧直写 fs = 静默空操作(档案 138 §五)。 现状靠 ensure-*.cjs 在用户所在那台机上直写该文件 —— 这正是 §一 硬伤①("本机枚举用户 ⇒ 跨机静默跳过")的同一病根。 ⇒ B 档「用户自助开关」不能直接开工,必须先解决「跨机写用户 profile 清单」这条通路(补 UserFs 能力 / 把开关挪进白名单文件 / 走一个新通道); ⇒ A 档(只读共享,开关仍由平台投放)不受此约束,可独立开工。

② ro 会改变插件的写入语义(EROFS)。 共享层内插件不能写自己的包目录。抽样扫描(poc/** 全部插件源码 + 端口探针)结果:

  • 未发现插件写包内路径,也未发现硬编码宿主机固定路径(唯二 /var/lib/dshs 命中都在平台侧 installer 脚本里读 DB,不是插件运行时);
  • portal-entry 写的是 DSH_HOME;mcn-suite 的 DB 在 <home>/.dsh/mcn-plugin.db —— 均在 §六 判据内。 ⇒ 判定大概率 ro-safe,但样本只有自家 3 个插件 + 探针。 ⇒ PoC 时必须对每个待上共享层的插件做一次「包内写入」扫描(writeFileSync|mkdirSync|appendFileSync|createWriteStream 且路径由 __dirname/import.meta.url 派生)。

8.5 装配机制两个候选(PoC 要选定一个)

A1 · 手工软链:平台在 <profile>/node_modules/<pkg> 建指向共享层的软链。

  • 优点:最直接,零 pnpm 调用。
  • 缺点:跑一次 pnpm add 的 reconcile 就可能把这条链抹掉 ⇒ 要重铺,且失败是静默的。

A2 · file: 目录依赖:dependencies 写 "<pkg>": "file:/var/lib/dshs/bundled-plugins/<pkg>/<ver>",由 pnpm 自己建链。

  • 优点:扛得住 reconcile,与现有 @softspark/dsh-file-preview: "file:/var/lib/dshs/business-plugins/…tgz" 同一套路(该写法现网已在用)。
  • 缺点:仍需在该机跑一次 pnpm(但不下载、不装依赖,秒级)。

▶ 倾向 A2 —— 耐久性优先;"零 pnpm"省下的那点时间,不值一次静默失效。

8.6 成本 / 收益 / 风险(修正)

  • 成本 2~3 天。改动面(比原文更具体):① 共享层目录规范 bundled-plugins/<pkg>/<ver>/(每机一份、内容不可变); ② 平台侧投放脚本(复用 §二的 install-plugin-for-user.cjs 姿势);③ orchestrator.ts 加一条 --ro-bind-try(必须排在 --tmpfs /var/lib/dshs 之后); ④ config.ts 加 bundledPluginDir(照抄 bundledSkillDir);⑤ 每用户装配写(受 §8.4-① 约束)。
  • 收益:每机 4N 次安装 → 4 次;同机版本天然一致;回滚 = 改指向;插件代码用户不可篡改(§五 已述)。
  • 风险:爆炸半径 1 → N(§五 红线,不变);ro 语义变化(§8.4-②,新增,需逐插件扫)。

8.7 建议下一步

🔴 2026-09-21 范围修正(用户当场指出「方案还没做完整」):下面这条只算"技术可行性验证",⛔ 不是"这条需求快做完了"的证据。 它不回答「别的节点上的用户怎么用上」—— 而那条通路今天不存在(§八 未提,见 147 §三 G1/G2: mine/apply 全程 Manager 本机路径与本机 uid;跨机唯一那条腿 writeHandoff 因 DSHS_ENABLE_PATCH=false 空转)。 ⇒ 完整的三问(组网 / 分工 / 各节点类型用户的插件分发与数据库连接)+ 推进顺序 ⇒ 04-调整方案/147-*.md。

先做 §8.1③ 的 PoC,范围压到最小:1 个插件 × 47 单机,观察三件事 —— ① A1/A2 两种装配在跑过一次 pnpm add reconcile 之后是否存活;② 实例正常启动(bundled-plugins 在 tmpfs 之后 bind 的顺序正确性); ③ 故意指向不存在的版本时,resolveBundleDir 的报错是否可读(决定故障可诊断性)。PoC 通过再谈 47+106 与档位。