Files
dsh_ai1net_server/交付物/pnpm-EPERM根因与越权软链修法-20260925.md
T
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

130 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-*`)**已清零**。