Files

129 lines
9.4 KiB
Markdown
Raw Permalink Normal View 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-*`)**已清零**。