Files
dsh_ai1net_server/交付物/pnpm-EPERM根因与越权软链修法-20260925.md
admin 318430c9e9 chore(工作区): 插件投放与分库线收口入库(第 35 棒 + 22:44 拍板落盘)
范围 = 本线(插件投放与分库线)产物 + 记忆类,共 27 件:
- 接续入口_插件投放与分库线(1 件):§0 新增 22:44 拍板行;§2 第 36 棒范围改 5 步、条件步转无条件
- 交付物(22 件):MCN 数据面接入阶段一/阶段二系列(含 B 案落地与 shadow 读数)、
  P0 修通与移动端真机验收、pnpm-EPERM、两机 lib 差异、共享层台账语义、
  基础插件身份与回滚、插件接入验收、插件数据面取数口、移动端迁包与字号扩面、跨机错误消息
- 记忆(4 件):2026-09-24 / 2026-09-25 日志、MEMORY.md、本棒 automation 执行记录

⛔ 未含他线在途改动(只报告、不代提交):机制层 CODEBUDDY.md / state.py /
.codebuddy/rules/server-ops.md、接续入口_IM线、接续入口_StoryForge验收线、
其余 29 个 automation 目录、docs/规则与载体/、归档/、接续包_*。
2026-09-25 23:06:29 +08:00

9.4 KiB
Raw Permalink Blame History

pnpm exit 255 根因与越权软链修法(插件投放与分库线 · 第 20 棒)

日期:2026-09-25 11:0x–11:3x|棒次:第 20 棒 · 执行棒|工作区:E:/ProgramData/AIProject/aliyun-dsh-server 代码基线:D:/github/dsh_shenxian(生产口径 dshs)|真机:47.77.182.89(Manager dshs + worker dshs-worker) 本轮唯一动作:pnpm exit 255 可见化 + 根因修复(P0)。原第 2 件(基础插件判据 ①③)已于规划侧按用户 11:0x 口径收口,本轮未动代码。


一、结论(三句话)

  1. exit 255 不是偶发、不是并发、也不是第 19 棒推测的 store 口径,而是 EPERM: chmod:pnpm 在 linkBin 阶段对一个未声明的 extraneous 软链的 bin 文件做 chmod,而该文件实体在共享只读层、属主是 root ⇒ 非属主 chmod 一律 EPERM。
  2. 修法 = 跑 pnpm 的那一小段里把这类软链改名移开,finally 无条件放回(不 chown、不复制、不 root 跑,三条旁路均已真机排除)。
  3. 判据 ①(真机原文)②(存量 profile 装配成功 + 双向一致)③(单测钉住)全绿;47 已上线,106 已投件但未重启(零中断,待下次重启生效)。

二、判据①:真机真实报错原文(第 19 棒假设被证伪)

平台调用复刻(47 · admin profile,只把 --reporter silent 换成 default):

cd /var/lib/dshs/users/<uid>/home/profiles/web
setpriv --reuid <uid> --regid <uid> --clear-groups env HOME=/var/lib/dshs/users/<uid>/ws \
  pnpm install --reporter default
⇒ PNPM_RC=255
EPERM  EPERM: operation not permitted, chmod '…/profiles/web/node_modules/xlsx/bin/xlsx.njs'
    at async chmod (node:internal/fs/promises:1087:10)
    at async linkBin (…/pnpm/dist/pnpm.cjs:102416:9)
    at async installInContext (…/pnpm/dist/pnpm.cjs:181904:9)

为什么 --reporter silent 下看不到:silent 把 pnpm 的 stdout/stderr 全吞掉 ⇒ stderr=- stdout=-(第 19 棒真机读数),只剩退出码。

第 19 棒的 store 假设为何不成立(真机核对):

  • plugin-assembly.ts:499 的形参名叫 homeDir,但调用方传的是 wsDirOf(userRoot)=<root>/ws;
  • 该 profile 的 .modules.yaml 记 storeDir=<root>/ws/.local/share/pnpm/store/v3 ⇒ 两者逐字一致,不可能触发 ERR_PNPM_UNEXPECTED_STORE。
  • ⇒ 结论:别再按 store 方向改(scripts/ensure-biz-plugins.cjs 的那套口径属于"另一条链",与本缺陷无关)。

三、根因与三条旁路(为什么只能"让它看不见")

3.1 事实链

# 事实 取证
1 node_modules/xlsx = 软链 → .dsh-module-fallback/node_modules/xlsx → 共享层实体 readlink -f
2 该实体属主 = root、links=3(硬链到共享层 store) stat -c "%h %U %a"
3 它不在 package.json 的 dependencies、pnpm-lock.yaml 里零命中 ⇒ extraneous cat package.json / grep xlsx pnpm-lock.yaml
4 pnpm 对顶层 extraneous 包仍会做 linkBin ⇒ 对 bin 无脑 chmod 0755(不看当前是否已 755) 报错栈 pReflect → linkBin
5 chmod 要求调用者是文件属主(或持 CAP_FOWNER)⇒ 非属主 EPERM(不是 EACCES) 真机原文

3.2 三条旁路为何都不行(逐条真机/文档排除)

  1. 把共享层文件 chown 给用户 —— 一份实体多用户共享,A 拿到 B 就没有(破 D2 拍板口径)。
  2. 复制一份用户属主的实体 —— 同样破 D2,且破坏模块同一性(第 18 棒已论证:软链解到宿主同一份实体,拷贝会让单例/instanceof 分叉)。
  3. 让 pnpm 跳过 linkBin —— pnpm 9.15.9 没有这个开关(真机 pnpm install --help 无 bin 项);且共享层里 4 个包自身都没有 bin 字段 ⇒ 触发者只可能是"外部塞进来的 extraneous 软链"。

⇒ 剩下的正解:跑 pnpm 的那一小段里把它移开。


四、修法(src/supervisor/plugin-assembly.ts,一件文件)

新增 作用
quarantineSharedLayerLinks(dir, sharedRoot, declared) 摘除「顶层(含 @scope/name)∧ 是软链 ∧ 不在声明集合(dependencies ∪ dsh.profile.bundles)∧ 解到底落在共享层内」的软链;改名为 .dsh-quarantine-<name>(⛔ 不删、⛔ 不碰真目录/. 旁挂物/悬空链)
restoreQuarantinedLinks(links) 原样放回;原名被占 ⇒ 跳过并具名返回(⛔ 不覆盖、⛔ 不丢隐藏项);⛔ 不抛
withQuarantinedLinks(...) 包住 runPnpm:finally 无条件放回,放回失败落 console.error(显形,不静默)
pnpmDiagArgs(args) --reporter silent ⇒ --reporter default(其余参数逐字保留、顺序不变;原本没写则补一个)
pnpmDiagRerun(...) 失败后只重跑一次诊断(同 uid/HOME/cwd,⛔ 不递归、⛔ 不重试到成功、⛔ 不抛);重跑成功本身就是"首次失败是时序"的证据
pnpmExecError(..., diag = '') 诊断读数进 message(跨机后只剩 message 可读)+ 结构化字段 pnpmDiag + journald

接线:applyProfileChanges 的 remove 分支与 install 分支的 runPnpm 各自包进 withQuarantinedLinks(声明集合在函数入口一次算好,与 pruneOrphanDepLinks 同一份口径)。


五、判据②:真机验收(47 · admin 存量 profile)

验收对象:/var/lib/dshs/users/cce6d1cd-b376-4304-80f0-0e1c58c9ffde(uid 114801,profile 建于 HOME=ws 时代)。

项 读法 修前 修后
装配调用 平台模块 applyProfileChanges(items = 共享层内已声明项) 抛 pnpm_failed(255) APPLY_OK(enabled:[dsh-plugin-mcn-suite]、refused:[])
dependencies ↔ node_modules 逐项 existsSync — DEPS 4 MISSING [](双向一致)
隐藏项残留 readdirSync 过滤 .dsh-quarantine- — 0
三条越权软链 lstat/readlink 在 原样在盘(xlsx/react/react-dom → fallback)
对照组(同 profile、不经修法手动复刻) setpriv … pnpm install --reporter default 255 仍 255,同一条 EPERM
平台日志 journalctl -u dshs -u dshs-worker 20 min 内 plugin-assembly — 0 条
实例健康 [rehydrate] probe OK … :20000 — ✅

对照组的价值:把「修法」与「偶发」区分开 —— 同一份 profile、同一条命令,不经修法仍 255。


六、判据③:单测(test/plugin-assembly.test.mjs,+5 用例)

  • 越权软链:只摘「顶层 ∧ 未声明 ∧ 落在共享层」的软链;声明内 link: / 真目录 / 非共享层链 / 悬空链一律不碰;放回逐字还原;两轮摘放不累积副作用。
  • 放回时原名被占 ⇒ 具名返回且不覆盖、隐藏项不丢。
  • applyProfileChanges 跑完 ⇒ 越权软链仍在盘(finally 放回,⛔ 不留隐藏态)。
  • 失败取证:pnpmDiagArgs 只换 reporter;诊断读数进 message + pnpmDiag;不传 diag 时与旧版逐字相同。

门禁:npm run build rc=0|npm test = 584 tests / 582 过 / 0 败 / 2 跳过(第 19 棒 577 ⇒ +5)|check:layering ✅ 无新增违规。


七、部署与回滚

机 文件 md5 重启 备份
47 /opt/dshs/lib/supervisor/plugin-assembly.js 3c10048a7b9d0a6eceb48c0e1a385d07(本地逐字一致) restart dshs dshs-worker ⇒ 双 active / NRestarts=0 /opt/dsh/backups/lib-p20/47-pre-p20-20260925-111517.tgz
106 /opt/dshs-cluster/lib/supervisor/plugin-assembly.js 3c10048a7b9d0a6eceb48c0e1a385d07 🔴 未重启(ExecMainStartTimestamp 仍 09:52:11)⇒ 新代码待下次重启生效(零中断取向) /opt/dsh/backups/lib-p20/106-pre-p20-20260925-112626.tgz
  • 回滚 = 用备份 tgz 还原该文件 → systemctl restart dshs dshs-worker(47)/dshs-worker(106)。
  • ⚠️ 重启 worker 会中断该机在跑的实例(本次 47 上 2 个 main 进程 → 重启后平台 rehydrate 已重新拉起并探活通过)。106 因此刻意未重启。

八、未闭合项(⛔ 本轮不擅自做)

  1. 🔴 那三条软链是"历史人工兜底遗留" —— dsh-module-fallback 在工作区代码里零命中(不是平台代码建的),且 pnpm 语义上本来会把 extraneous 清掉 ⇒ 是否清理需用户拍板(清理会改变实例的模块解析面)。⇒ 本修法只保证"平台装配不受影响",不等于这些软链该存在。
  2. 🔴 106 的新代码未生效(worker 未重启)。是否开窗口重启 = 「中断在线用户」类,待用户拍板。
  3. 🟡 本修法的代价(如实记):每次跑 pnpm 会多两次 rename(微秒级);pnpm 运行期间(秒级)这几条软链短暂不可见 —— 若实例恰在此刻解析它们,需等放回后自愈。

九、本轮纪律

⛔ 未 commit / push|⛔ src/web/routes/im.ts 零改动|⛔ 未手改用户 home 的 profile / .pnpm(诊断与验收均走平台代码路径或平台命令复刻)|⛔ 未扩大范围(未顺手重构 plugin-assembly、未动共享层属主、未清人工软链)。远端临时件(/tmp/p20-*)已清零。