范围 = 本线(插件投放与分库线)产物 + 记忆类,共 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/规则与载体/、归档/、接续包_*。
9.4 KiB
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(Managerdshs+ workerdshs-worker) 本轮唯一动作:pnpmexit 255可见化 + 根因修复(P0)。原第 2 件(基础插件判据 ①③)已于规划侧按用户 11:0x 口径收口,本轮未动代码。
一、结论(三句话)
exit 255不是偶发、不是并发、也不是第 19 棒推测的 store 口径,而是EPERM: chmod:pnpm 在linkBin阶段对一个未声明的 extraneous 软链的 bin 文件做chmod,而该文件实体在共享只读层、属主是 root ⇒ 非属主 chmod 一律EPERM。- 修法 = 跑 pnpm 的那一小段里把这类软链改名移开,
finally无条件放回(不 chown、不复制、不 root 跑,三条旁路均已真机排除)。 - 判据 ①(真机原文)②(存量 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 三条旁路为何都不行(逐条真机/文档排除)
- 把共享层文件 chown 给用户 —— 一份实体多用户共享,A 拿到 B 就没有(破 D2 拍板口径)。
- 复制一份用户属主的实体 —— 同样破 D2,且破坏模块同一性(第 18 棒已论证:软链解到宿主同一份实体,拷贝会让单例/
instanceof分叉)。 - 让 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 因此刻意未重启。
八、未闭合项(⛔ 本轮不擅自做)
- 🔴 那三条软链是"历史人工兜底遗留" ——
dsh-module-fallback在工作区代码里零命中(不是平台代码建的),且 pnpm 语义上本来会把 extraneous 清掉 ⇒ 是否清理需用户拍板(清理会改变实例的模块解析面)。⇒ 本修法只保证"平台装配不受影响",不等于这些软链该存在。 - 🔴 106 的新代码未生效(worker 未重启)。是否开窗口重启 = 「中断在线用户」类,待用户拍板。
- 🟡 本修法的代价(如实记):每次跑 pnpm 会多两次
rename(微秒级);pnpm 运行期间(秒级)这几条软链短暂不可见 —— 若实例恰在此刻解析它们,需等放回后自愈。
九、本轮纪律
⛔ 未 commit / push|⛔ src/web/routes/im.ts 零改动|⛔ 未手改用户 home 的 profile / .pnpm(诊断与验收均走平台代码路径或平台命令复刻)|⛔ 未扩大范围(未顺手重构 plugin-assembly、未动共享层属主、未清人工软链)。远端临时件(/tmp/p20-*)已清零。