# 68 · 候选池启停的 root 属主污染:根治 - 日期:2026-09-12 - 触发:投放 `@dsh-local/business-plugins@0.2.4` 时,**guest 升级失败**(`EACCES: permission denied, open '.../node_modules/.bin/modlens'`) - 结论一句话:**不停在"把它 chown 回去",而是三层一起做** —— 修根因(候选池启停改为 `setpriv` 降权)、加防复发(每次改插件前自动属主自愈)、清存量(561 项)。 - 状态:✅ 全部完成并验证 > **TL;DR**|**结论**:门户候选池的 `install/uninstall` 一直**以平台进程身份(root)跑 pnpm**,装出来的文件属主是 root → 该用户此后**任何** `pnpm add/remove` 都必然 `EACCES`。 > **关键**:guest profile 下积了 **561** 个 root 属主项;平台脚本(`ensure-biz-plugins.cjs` 等)用的是 setpriv 正姿,两条路线不同所以这个 bug 长期没暴露。 > **状态**:✅ 已上线(修根因 + 自愈 + 清存量三层) --- ## 一、现象与规模 | 用户 | profile 下 root 属主项 | 分布 | |---|---|---| | **guest**(uid 100002) | **561** | `.pnpm` 555 / `@liustack` 2 / `.bin` 2 / `dsh-univer-office` 1 / 一个 `.bak` | | admin(uid 114801) | 5 | 全在 `.pnpm` | **表现**:该用户无法升级或启用任何插件 —— 以用户身份跑 `pnpm add` 时连旧文件都覆盖不了。 ## 二、根因 `src/web/routes/business-plugins.ts` 的 `install()` / `uninstall()`(改前): ```ts execFileSync('pnpm', ['add', 'file:' + plugin.tgzPath, ...], { cwd: dir, env: pnpmEnv(user.id) }) ``` **没有 `setpriv` 降权** → 以平台进程身份(root)执行 pnpm → 产物属主是 root。 **证据吻合**:`audit_log` 记录 guest 在 01:58 通过候选池启用 `@liustack/modlens` + `dsh-univer-office` —— 正是这批 `.pnpm` / `@liustack` / `dsh-univer-office` 项的来源。 **为什么长期没暴露**:平台侧脚本(`ensure-biz-plugins.cjs` / `ensure-portal-entry.cjs`)用的是 `setpriv --reuid --regid --clear-groups` 正姿;档案 43 修「平台 root 污染用户工作区」时也只覆盖了脚本那条线。**门户候选池是第三条线,没人走过**。 ## 三、修复(三层,按"长期最佳"而非"先能跑") ### 3.1 修根因 —— `install()` / `uninstall()` 改为以用户身份执行 新增两个 helper 并替换两处调用: | helper | 职责 | |---|---| | `uidOf(userId)` | 取该用户 uid(读 `/home` 属主 —— 编排器创建时已 chown,少一处 DB 耦合) | | `runPnpmAs(userId, args, cwd)` | `setpriv --reuid --regid --clear-groups env HOME= pnpm …` | | `healOwnership(userId)` | 见 §3.2 | `HOME` 仍指向 `/ws`(pnpm 的 store/cache 落在这里,不设会报 `ERR_PNPM_UNEXPECTED_STORE`)—— 原 `pnpmEnv()` 因此变成死代码,已删,其说明移到 `wsDir()` 上。 ### 3.2 防复发 —— 每次改插件前跑一次属主自愈 `healOwnership(userId)`:`find -user root` 数一遍,有则 `find … -exec chown -h : {} +`。 - 放在 `install()` / `uninstall()` **最前面**(否则以用户身份跑 pnpm 连旧文件都覆盖不了); - 用 `-exec … +` 而非一次传全部路径 —— 561 条会撞命令行长度上限; - 用 `chown -h` —— `.bin/*` 是指向别处的符号链接,要改链接本身而非其目标; - 命中时写 `heal_plugin_ownership` 审计(含阶段与条数)。 **这一层才是"长期"的关键**:即便将来**别的**写入路径又污染了属主,用户下次启停插件时会被自动清理,不必再人工排查一次。 ### 3.3 清存量 —— 手工跑一次同样的清理 对两个用户的 profile 执行与 `healOwnership` 等价的 `find … -exec chown -h … {} +`(属主污染是**数据错误**,修代码不会自动修正已存在的 561 个文件)。 ## 四、验证 | 项 | 结果 | |---|---| | `npm run typecheck` / `build` | exit 0 | | 部署 | 备份 `.bak-ownfix-20260912` → scp → **md5 一致**;服务 `active` + `HTTP=200` | | 清存量 | guest 剩余 **0** / admin 剩余 **0** | | **投放 0.2.5** | **admin ✅ 0.2.4 → 0.2.5**;**guest ✅ 0.2.3 → 0.2.5**(修复前该步必失败) | | 平台代码的 setpriv 路径 | ⏳ 待用户下次在「功能管理」里启停插件时自然验证(该路径需用户会话) | ## 五、附带修复:`npm pack` 把上一版 tgz 打进了产物 打包 0.2.5 时发现包体从 8.9 KB 涨到 19.3 KB —— 因为目录里上一版留下的 `business-plugins-0.2.4.tgz` 被一并打进 `package/`,**且会被永久带进用户的 node_modules**。 **修复**:加 `.npmignore`(`*.tgz` 等)而不是"每次记得先手工删" —— 同样是"长期最佳优先于补丁"。重打包后 9.5 KB / 4 个文件,与预期一致。 ## 六、红线遵守 - **R7**:批量 `chown` 限于**两个用户的 profile 目录**、只改属主不改内容;执行前已在汇报中列出污染清单与规模(561 / 5)。 - **R8**:重启平台服务(实测有实例在跑时约 90 秒、无实例约 3 秒),已在上一轮汇报中说明影响。