Files
dsh_ai1net_server/dsh-server-docs/skills/dsh-change-workflow/SKILL.md
T
admin bc0dd2c96d docs(dsh-server-docs): mksess 口径校正(DB 直插→PG 直插)+ 技能 dsh-auto-handoff-chain 三处同步建立(序⑰ S1/S2)
- 02-运维手册.md:mksess 命中处补「PG 直插 + 连接串来源」+ D1 勘误指针两行(档案 77 的旧形态说明,正文不改)
- skills/dsh-change-workflow/SKILL.md(146/395/495 三行)、skills/dsh-env-bootstrap/references/常驻规则-快照.md(55 行):同口径校正
- skills/dsh-auto-handoff-chain/:新建文档库副本(SKILL.md v1.3.2 + scripts/chain_report.py),此前该技能三处同步从未建立
- README.md / INDEX.md:登记该技能(技能表 + 「什么时候查什么」+ 技能清单表)

判据:E1 = 0 行、E2 两副本 md5 全同、E3 diff -r = 0 行、E4 登记命中 README 1 / INDEX 2、镜像 47 同值
2026-09-17 17:03:34 +08:00

1039 lines
114 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.
---
name: dsh-change-workflow
description: dsh 多租户平台(alotbuy.com / dshs,服务器 47.77.182.89)功能改造与优化任务的完整工作流。当用户提出平台需求、改造、优化、缺陷修复、功能调研,或要求"按既有流程执行/沉淀/整理处理机制"时触发。核心:需求识别→调研→规划→开发→验证→归档清理 六阶段 + 红线机制(不自动升级 dsh / 不改官方主程序 / client bundle 禁 exports.default / 不用真实账号测登录 / **R7 禁未经确认的批量·全仓写入** / **R8 会中断在线用户的生产变更须先知会**)+ 备份先行 + append-only 日志 + 表格式交付 + 文档「服务器唯一源 + docs-status 单目录同步」。
version: 2.9.2-playwright-banned
updated_at: 2026-09-15
last_change: 【2026-09-12 新增「三把锁」落地机制 + 更正过期指向】把「多任务并行调度协议」从**设计**补成**可强制执行的判据** —— 全局执行锁 `交接单/.exec-lock`(同一时刻只允许一个执行会话)+ 单级占用锁 `.doing-<单号>` + **服务器侧操作锁 `/opt/dsh/state/.op-lock/`**(管生产态:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书,因「服务器态变更看不出来」故必须显式加锁);工装 `scripts/handoff-guard.sh`(新增【1d】分支)与 `scripts/op-lock.sh`。同时**更正产出闭环里的过期指向**:原文写 `docs-status/文档库状态备注.md` + `bash scripts/docs-status-sync.sh --pull`,**该机制已不存在** → 改为 `INDEX.md §二` 登记 + 收尾四件套(audit → manifest → sync-check → `PUSH=1 handoff-guard`)。出处:当日 3 次并行事故 + `04-调整方案/69`。此前】**R7 禁止未经确认的批量 / 全仓写入**(由来:为"让 scp 出去的文件行尾干净",用脚本把 **147 个文件** CRLF→LF —— 当期只被要求改一个 UI 字符串;被用户指为「容易把服务器搞崩」,且随后 `cp -r` 确实把**未改动的** 2 个文件覆盖到了服务器)+ **R8 会中断在线用户的生产变更须先知会**(由来:档案 58/59 连续两次重启,直接引发用户"会话连接异常"报障)。【2026-09-11 晚,用户当面纠正后大修】① **阶段 0 新增第 0 条「开工前置检查」并标明"本技能最重要的一步"**(装工具/写自动化/查机制前先看:可用技能列表 → 本机/项目已有技能与记忆 → "本机已有的能否满足")+ 红线 **R6「先查已有资产,再动手」**;② **浏览器自动化定型**:用 `browser-harness`(CDP 9223,Windows 调用方式 + helper 清单),**禁再装 agent-browser / Playwright**;⛔ **铁律:browser-harness 附着的是「用户正在用的 Chrome」→ 动手前先 `list_tabs()`,不是全新空浏览器就立刻停手**(本轮真实事故:在其浏览器里登录测试号 → 覆盖用户 `.alotbuy.com` 的 `sid`);dsh composer 是 Lexical(`data-lexical-editor`),`type_text`/`fill_input` 均未生效;③ **更正「空闲回收」认知**:回收**从未生效**(TTL 默认 7 天 / cap 4 / 每请求 touch),但 **"回收没生效"≠"实例不会中断"** —— 中断真凶 = **服务重启(09-11 达 48 次)→ 旧实例变孤儿 → 另起新实例 → 旧页面 401** + **实例崩溃自动重启(近 4 天 69 次;admin 根因 `duplicate loader entry id: permission`)**;附三数核对手法;④ 阶段 1 新增「**用户报障第一步:先读 journal 拿真实失败请求**」(三分钟定位,判据速查 401/502/crash);⑤ 阶段 4 新增「**验证用例必须同时覆盖「有请求体」与「无请求体」**」(档案 51 事后修正实证:只测 POST 会整条漏掉 GET/SSE)+ 「模拟浏览器语义要测到底」(只带最初旧 cookie、显式断言 Set-Cookie 回写);⑥ R4 补**专用测试账号模板**(`role='active'`、`approved_by` 需真实 admin id;`users` 表无 `status` 列)。此前新增「**本环境 TLS 走中间人代理 → Python 脚本必须 SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt**」(两份 CA bundle 的区别 + 零平台改动正解 + 为何不走 R5 注入 env)+「职责边界:技能投放=admin 的事,平台只提供机制与把关」;此前 R5 追加「**安装类操作=扩大,必须先出安装确认清单(七项)**;技能/插件依赖体检只报告+拦截、绝不代装」;另把「复杂引号一律本地写文件→scp(禁 ssh 内 inline node -e/sed)」从建议升为**硬性要求**(本轮同类错误连犯 4 次)。此前补环境事实「正确域名 alotbuy.com(dsh.alotbuy.com 是旧域 301,抓页面只会拿到 301 HTML)」+ 档案 45 存量会话提示落点(login.html);此前新增「**基础运行时版本冻结**」节(档案 44:唯一缝隙=Python user-site 会盖平台包;修法 PYTHONNOUSERSITE + PYTHONUSERBASE 双保险;版本漂移巡检 scripts/runtime-baseline.cjs + cron 05:10);补「3.6 不开放」的正解(引导优先,遮蔽不可行)+ 属主自愈(ws-cleanup --reclaim-only,平台 root 跑 pnpm 污染用户 ws);**R5 已立**。此前新增红线 R5「权限可见面只准收窄,扩大必须先确认」**(含「R5 权限扩大门禁」整节:扩大/收窄方向判定表、权限影响评估四问、触发文件清单、以「实例可访问路径清单」做 diff 验收);阶段 2 红线自查与影响评估同步加入 R5 门禁;此前新增「**无法遮蔽 /usr 内文件**」(ro-bind 目录无法创建挂载点 → 叠加遮蔽会让实例起不来;判据「只有先 tmpfs 再白名单的目录才可自由增删」+ 改 bwrap 参数后必须真启动实例验证)+「**实例共享 Python 运行时**」(可移植 3.12 装到 /usr/local/dsh-runtime,迁移打包单元,pip 落点与护栏)+「平台 root 污染用户工作区」待治本项;【档案 39 事后修正】`/etc` 白名单补 **`/etc/alternatives`** —— 它是「软链枢纽目录」,漏掉后 `/usr/bin/python3` 两跳软链断裂,**21 个命令静默 command not found**(python3/pip3/ld…);新增判定准则「必须枚举整条链条会穿过 /etc 的条目」+ 探测器命令 + 「白名单类改动必须跑工具清单前后对比」;此前新增「**技能上传安全**」节(zip symlink 是**元数据**攻击面 → scanDir 内容扫描天然看不到;必须同时覆盖「内容」与「元数据/文件类型」两维度;三项加固);API 表扩到 enable/disable/delete 与 `locked` 语义;安全判据改为「**分清谁在执行**:运行=用户沙箱=自伤 / 上传解压=root=平台风险」;**撤回**我上一轮「scanDir 只告警不拦截」的错判(它内部会对 P0 throw 400);此前更正「bundled 层已修复(档案 40,commit 37e016f)」+ 实例可访问路径清单补 `bundled-skills` 行 + 判据「实例内 resolve+读盘的资源必须真的挂进命名空间(只注入 env 无效)」;此前新增「**实例可访问路径清单(权威版)**」表(能读/读不到/残留三类,写文档与答用户按此口径)+「`/etc` 白名单」(14 项最小集 + symlink 须 realpath 绑目标 + 222→13/576→27 实测)+「**H5 红线:OUTPUT 链按目的地址封本机服务=自伤**」(症状指纹 timeout vs refused、必须只匹配主动发起 TCP 纯 SYN / UDP ct state new、验证闭环三条);bwrap 参数照抄清单与只读诊断配方同步改为 `--tmpfs /etc`;出网护栏补 H1/H5 与一键复现脚本;技能投放通道补 API↔层映射表·平铺要求·宿主基线约束
agent_created: true
---
# dsh-change-workflow — dsh 平台改造工作流
dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改造的标准处理方法。把平台运维历史中反复验证过的处理方式固化为可执行流程:**先识别边界 → 源码级调研 → 方案对比确认 → 小步开发 → 全链路验证 → 归档清理**。
## 平台速查(硬编码事实,勿猜)
> ⚠️ **正确域名 = `alotbuy.com`**;`dsh.alotbuy.com` 是**旧域名、nginx 301 跳转**
> ——对它抓页面只会拿到 301 HTML,**别误判为"改动没生效"**(2026-09-11 踩过)。
> 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。
> 存量会话提示(档案 45)落在 `web/login.html`:告知「shell 频繁要求审批 → **新开一个会话**即可」。
>
> ⚠️ **`*.alotbuy.com`(含每个用户子域)都由同一个 vhost 先 `proxy_pass 127.0.0.1:3080`(门户)**,
> 再由门户的 `src/supervisor/proxy.ts` 转发到实例 → **想改"子域访问行为"要改 `proxy.ts`,
> nginx 的 `error_page` / `location` 到不了实例层**(档案 49 实测)。
> 实例空闲回收后首次访问的链路(档案 49):**导航**(GET+`Accept: text/html`)→ 立刻 302 到
> `web/wake.html`(门户同源,动画 + 自动调 `/api/dsh/enter` 跳回);**XHR/API** → **等实例就绪后
> 继续转发**(不是回错误/动画 —— 让请求最终成功,dsh 自身 loading 即反馈)。
>
> ⚠️ **实例的浏览器凭证 cookie 名带随机后缀**:`dsh-auth-<随机后缀>=v1.<base64>.<sig>`(HttpOnly/Secure)。
> **实例重建后这个后缀会变**(不是"过期",是"名字都不对")→ 已打开的页面手里那份对新实例**永远无效**。
> 档案 51 的修法在 `proxy.ts`:**非导航请求遇实例侧 401 → 取当前实例 token → `GET /?token=` 换新 cookie
> → 覆盖 cookie 头透明重放同一请求 → 响应追加 `Set-Cookie` 回写浏览器**(此后含 SSE 自动重连直接成功)。
> 重放闸门是 `canReplay = mayHaveBody ? bodyBuf !== undefined : true` —— **GET/HEAD 无请求体,一样要能重放**。
>
> ⚠️ **"空闲回收"实际从未生效(2026-09-11 核实,别再拿它解释现象)**:
> ① **TTL** `instanceIdleTtlSeconds` 默认 = **7 天**(`src/config.ts:167` `60*60*24*7`),线上 env 未设 → 等于不会触发;
> ② **LRU 上限** `maxIdleInstances` 默认 = **4**,只有**第 5 个用户**拉起实例时才淘汰最久未活跃者(现有 2 用户永远达不到);
> ③ `reapOnce()` 只看 `lastActive`,而 `proxy.ts::resolveSubdomainAccess()` **每个子域请求都 `touch()`**,
> dsh 前端还有常驻 SSE `/plugins/events` → **页面开着就永远"活跃"**,TTL 规则不可能命中。
> **历史实证**:全历史 `grep -c "idle-reap"` = **1**(`09-09 12:24 stop … idle>20s`,是档案 08 拿 TTL=20s 做验证那次)。
> ### ⛔ **别把"回收没生效"外推成"实例不会中断"**(2026-09-11 我犯过这个错,被用户当面纠正)
>
> 「页面等一会就模型断开、必须刷新」的真凶是**中断**,与"空闲回收"是两码事。实测统计:
>
> | 中断来源 | 实证 | 机制 |
> |---|---|---|
> | **① 门户服务重启**(主因) | 2026-09-11 一天重启 **48 次**(全是部署/探活) | 重启后 supervisor **内存 `mains` 表清空** → 旧实例变**孤儿**(进程还在但不被跟踪)→ 再访问判 `not_running` → **重新拉起新实例(新端口/token/cookie 名)** → 旧页面带旧 cookie → **401**。**这正是档案 51 的触发源** |
> | **② 实例崩溃后自动重启** | 近 4 天 `crash-restart` **69 次**(admin 34 / guest 16 / 测试号 19) | admin 根因 = `duplicate loader entry id: permission`(`exitCode 1`)——**启用与官方 `permission` 撞 id 的候选插件**所致;guest = 信号终止(无 exitCode),紧跟服务重启 |
> | ③ 空闲回收 | 全历史 `[idle-reap]` **仅 1 次**(09-09 测试值 20s) | **从来没生效**,不构成中断来源 |
>
> **必查手法**:`journalctl -u dshs --since "-1d" | grep -c "Started DSH server login orchestrator"`(重启次数)
> + `grep -c crash-restart` + `grep -c idle-reap` —— 三个数一比就知道该往哪查。
>
> **治本方向(待办)**:① 部署**攒批重启**(别每改一处重启一次);② **supervisor 启动时收养已存在 scope**
> (本地模式目前**无**收养逻辑,`reconcile.ts` 只覆盖 k8s)→ 重启不再产生"新实例 + 401";③ 业务插件探活失败**自动摘 bundle**。
> ⚠️ **配置风险**:`maxIdleInstances=4` × `MemoryMax=1024M`(硬限上界) = 4096M **远超** 宿主总量 1870MB(**上限非预留**,但真跑满必然换页)→ **撑不住,建议调 2**。
>
> ⚠️ **dsh web 客户端的会话事件流走 SSE(`EventSource`),不是 WebSocket**(源码
> `dsh-api-session-controller/lib/types/client/sessions/session.js`)。**凡"页面内自愈"类注入脚本,
> 只 hook `fetch`/`XMLHttpRequest` 是够不到主链路的** —— EventSource 遇 401 只静默 `onerror` 重连,
> 界面什么都不显示(档案 50 因此对真实用户无效,档案 51 才在传输层根治)。
> 判断前端用什么传输**别猜**:`grep -rl "new WebSocket\|EventSource" <dsh 包>`。
>
> ⛔ **注入脚本(`src/supervisor/proxy.ts` 的 `SESSION_*_JS`)两条铁律 —— 2026-09-13 事故换来的**:
> ① **它是 TS 模板字面量,里面的 `\n` / `${` / 反引号会在模板求值时先被处理一次。**
> &nbsp;&nbsp;写 `.join('\n')`(单反斜杠)⇒ 求值后变**裸换行** ⇒ 注入的 JS 直接 **SyntaxError** ⇒
> &nbsp;&nbsp;**整段脚本静默不执行**(浮层 / 自愈 / 助手面板全废,页面只剩 dsh 自己的「连接异常」)。
> &nbsp;&nbsp;→ 用 `String.fromCharCode(10)`;**校验必须"先模板求值、再 `node --check`"**:
> &nbsp;&nbsp;`node scripts/verify-inject.cjs lib/supervisor/proxy.js`(**已接入 `npm test`,勿绕过**)。
> &nbsp;&nbsp;⚠️ `new Function(原文)` 与「grep 页面 HTML 有没有标记」**都是假绿** —— 前者跳过求值,后者验不出"跑不跑得起来"。
> ② **触发面必须覆盖"页面开着不动"**:用户盯着页面时无任何事件;现已有 **可见时 25 s 心跳**
> &nbsp;&nbsp;+ `EventSource` / `WebSocket` 断流包装,且两者都**连续两次失败才恢复**(恢复=原地 replace,会丢未保存输入)。
>
> 🔴 **浏览器自动化:只允许两件工具;Playwright 全面禁止**(用户 2026-09-13 明令:「**playwright 禁止使用,加到规则中**」):
> ① **`agent-browser`**(WorkBuddy 内置技能,**独立浏览器,不碰用户环境**)—— **本项目默认走这条**(2026-09-13 选定);
> ② **`browser-harness`**(CDP **9223**,**附着用户正在用的 Chrome**)—— 只在需要看用户现场时用,
> &nbsp;&nbsp;⛔ **动手前必须先 `list_tabs()`**;旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.alotbuy.com` 的 `sid`**。
> ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)—— 本行**更正**此前把它写成「只读验收默认路径」的错
> &nbsp;&nbsp;(那是 2026-09-11 加的,与同页「禁 Playwright」**自相矛盾**;2026-09-13 已按用户明令统一为**禁止**)。
> 静态页与 API 取数优先 `curl` / WebFetch,**别动浏览器**。
| 项 | 值 |
|----|-----|
| 服务器 | 47.77.182.89(Alibaba Cloud Linux al8);SSH:用别名 `bt-server`(`~/.ssh/config`:`HostName 47.77.182.89`、`Port 32022`、`User root`、`IdentityFile ~/.ssh/id_ed25519`) |
| 门户 | dshs(systemd `dshs`,127.0.0.1:3080);源码 `/opt/dshs`(git master) |
| 数据 | `/var/lib/dshs/users/<id>/{home,ws}`;DB `/var/lib/dshs/dshs.db`(root 600) |
| 域名 | **alotbuy.com**(门户,CF 代理 + 通配证书,源站 443);用户子域 **`<用户名>.alotbuy.com`** 经 portal proxy 转发实例;旧 `dsh.alotbuy.com` 301 → `alotbuy.com`(档案 22 域名迁移) |
| 实例 | 门户 spawn `node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port <动态>`;ISOLATION account(setpriv,uid∈[100000,199999];admin=114801) |
| 文档 | **本机 git 工作树 = 唯一源**:`E://ProgramData//AI技能//aliyun-dsh-server//dsh-server-docs`(仓库 `dsh_shenxian_doc`)→ scp 到服务器 `/opt/dsh/docs`(root 600 / README 644,**无 .git 的部署镜像**)。⛔ **本行 2026-09-13 更正**:原文写「服务器唯一源 / 本地不保留副本 / `dsh-server-docs/` 已废弃」——那是**更早的状态,现已作废** |
| 文档同步 | 改完跑**四件套**:`docs-audit.py` → `docs-index-stats.py --write`(INDEX 状态摘要**机器生成**)→ `docs-manifest.py` → `docs-sync-check.sh` → `docs-consistency.py`;**只 scp 自己本次改的文件**、推前复跑对账。⛔ `docs-status/` 机制**已不存在**(原文作废) |
| 硬规则 | 动**文档 / 代码 / 服务器**之前先抢**全局执行锁**(`bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<会话名>"`);要动**生产**再占服务器侧 `scripts/op-lock.sh`;**同一时刻只放一个执行会话**。规则实体在项目根 `CODEBUDDY.md`(§3 红线 R1–R9 / §6 并发纪律)—— **本技能不复述、也不得与之冲突**。⚠️ 释放 op-lock 必须带 `ME=<原占用者>`,否则按 R9 被拒 |
| 档案号 | **复跑取号,勿写死**(`ls 04-调整方案/ | sort -n | tail -1`;并行改动会打穿)(01-52 已落;**50/51/52** = 会话过期自愈注入(已被51取代) / 实例侧401透明重放 / 新用户实例无法启动;⚠️ 无 48;**37/38 各有两条**(并行通道撞号)→ **归档前先 `mkdir <目录>/.lock-<n>` 原子占号**,光"先 ls 再写"不够) |
## 六阶段流程
### 阶段 0 · 需求识别(先盘点再动手,勿跳步)
0. **⛔ 开工前置检查(2026-09-11 用户点名要求,血的教训)**:**这是本技能最重要的一步**。
病根 = 不先查「已经有什么」就凭直觉装工具/猜机制;出错后要用户追问,才把**早就写在文档里的说明**翻出来
(最刺眼的一次:我早在技能里写了「勿走 agent-browser」,自己却忘了,绕最远的路)。
任何「**装工具 / 写自动化 / 查机制**」之前,按序做完这三条:
1. **先看可用技能列表**(会话开始就在)→ 有对应的**立刻加载技能**,禁止另起炉灶;
2. **先看本机/项目已有资产**:`~/.workbuddy/skills/`、项目 `.workbuddy/skills/`(尤其本技能)、`MEMORY.md`;
3. **要装任何东西前自问:本机已有的能否满足?** 能→不装;不能→**先说清楚再装**。
→ **固化结论(别再重新发现)**:浏览器自动化**只允许两件** —— **`agent-browser`(默认,独立浏览器)** 与
**`browser-harness`(看用户现场用,先 `list_tabs()`)**;⛔ **禁止 Playwright / `playwright-core`**(2026-09-13 用户明令);
静态页与 API 取数用 `curl`/WebFetch,别动浏览器。
1. **环境盘点**:本机/服务器/工具链/权限/DB/实例状态先行(历史教训:未盘点就默认本机装 Docker 走错路线,被用户批评)。
2. **需求边界**:区分"方案请求"(只输出方案,P0 硬规则:**方案与执行分离,未经明确授权不改文件**)与"任务明确直接执行"。
3. **用户确认**:关键分叉用选项表收敛(AskUserQuestion:推荐项置首标注 "(Recommended)",2-4 选项,不设"其他"占位)。
4. **输出格式**:一句话结论先行 + 表格化细节(状态/结果列)。
### 阶段 1 · 调研(源码级实证优先,禁止只靠文档推断)
0. **★ 用户报障第一步:先拿「真实失败请求」,别猜**(2026-09-11 档案 51 实证,省掉全部试错)。
用户只会说"报错/没反应/发消息失败",而**平台 journal 里有精确的 URL + method + status**:
```bash
ssh ... 'journalctl -u dshs --since "-30min" --no-pager \
| grep -E "401|403|404|500|502|503|crash-restart|dsh-child|not_running|YAMLException|ERR_MODULE" \
| grep -v "level.:30.*200"'
# 顺带看 host / remoteAddress(= 用户真实 IP)定位是谁、连的哪个子域
```
判据速查:
- `POST /api/session/prompt → 401` = **实例侧鉴权**(档案 51 的旧 cookie 问题);
- `502` = 实例根本没起来 → 翻同时间段的 `YAMLException` / `ERR_MODULE_NOT_FOUND` /
`crash-loop-circuit-open`(档案 52 就是被这一步牵出来的);
- 401 先分辨**平台 401**(body `{"error":"unauthorized"}`)还是**实例 401**
(body `dsh web authentication required…`)——**改的地方完全不同**。
1. **只读分析官方包**(允许):`/usr/local/lib/node_modules/@deepseek-ai/dsh/...` 内 bundle 读代码定位机制(slot 声明、inject 契约、fiber 注入)。**绝不写官方主程序**。
2. **服务器实测 > 推断**:区分 fence 层 / 应用层错误;浏览器问题抓 console **完整 err 对象**(`description`/堆栈,勿只看字符串字段——`at new apply` 类堆栈是定位关键)。
3. **双端对照**:archive 源 ↔ 实例安装副本 md5 对账;官方行为对照第三方插件写法差异逐字段对比(如 register 的 `locale`/`children`/`label` 函数形式)。
4. **多模型交叉验证**:关键结论交叉核对,不轻信单点推断。
### 阶段 2 · 规划(文档先行,方案确认后动工)
1. **方案对比表**:≥2 选项 + 各自影响/风险 + 建议(用户偏好:结构表对比+理由)。
2. **红线自查**(每项改造必过):
- R1 不触发 dsh 自动获取最新版本;版本升级走独立"升级测试→评估→修复"流程(档案 07)
- R2 不改官方 dsh 主程序与缓存;扩展只走 profile 层 `dsh plugin remove/add`(tgz)官方机制
- R3 client bundle **严禁 `exports.default = apply`**——loader ESM/CJS interop 会取纯函数为插件主体 → 无 inject → `ctx.<svc>` 抛 `cannot get property ... without inject`;官方 bundle 只导出 `apply`+`inject`
- **R4 禁止用真实账号(`admin` / `guest`)做 API 登录测试**——`auth.ts` 登录时 `deleteUserSessions(user.id)` 是 last-wins 单活跃会话,**会当场踢掉用户正在用的浏览器会话**(2026-09-10 实证:curl 登录 guest 把用户浏览器 guest 会话顶掉,页面刷出 `unauthorized`)。要测登录态用 `node /opt/dshs/mksess.cjs` (**PG 直插**)建临时 session,或注册专用测试账号用完即删
- **专用测试账号模板(2026-09-11 实证)**:`POST /api/auth/register` → DB
`UPDATE users SET role='active', approved_by=<真实 admin id>`。⚠️ 两个坑:① `users` 表**没有 `status` 列**,
审批就是改 `role`,CHECK 只允许 `admin/pending/active/disabled`(写 `'user'` 会 CHECK 失败);
② `approved_by` 有 **FK 指向 `users.id`**,填字符串会 `FOREIGN KEY constraint failed`。
**凡要"操作该用户自己的实例"(停实例/回收/重建/看它的 profile 文件)→ 必须用独立账号**,
不能借 admin/guest 的 session(借了就没法停实例、也污染真实用户)。用完 `DELETE sessions + users` 并 `rm -rf users/<id>`
- **R5 权限可见面只准收窄,扩大必须先确认**(2026-09-11 用户新增)——**先判方向**:这次改动是"扩大"还是"收窄"?命中「扩大」定义(新增 bwrap 挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽 `ALLOWED_ENV` / 放松 nft / 提高权限档位或放宽 approval / 新增用户可读写路径 / 让 root 执行链路的对象变成用户可控)→ **必须在方案里写出「权限影响评估」四问并等用户明确同意**,不得顺手做;改了「触发文件」清单里的文件就一律按 R5 走。收窄可直接做,但遮蔽类必须**真启动一次实例**验证(档案 42:遮蔽 `/usr` 内文件让实例起不来)
3. **影响评估**:实例重启会换端口(authority 变)→ 旧 WS 断连预期;登录 session 删除预期;数据目录权限影响。**若命中 R5,另附「权限影响评估」四问 + 改动前后 diff「实例可访问路径清单(权威版)」**。
4. **文档占位**:`04-调整方案/` 建编号档案(模板见下),先写需求与改动设计。
### 阶段 3 · 开发(小步 + 备份 + 逐条验证)
**服务器 TS/配置改码标准流程**(多文件改动):
1. `scp` 服务器源码 → 本地工作区 → 本地 Edit(**读一条→改一条→验证一条**,禁止批量读全部文件)
2. `scp` 回服务器 `/tmp/` → `cp` 覆盖前先备份 `bak-<功能>-<YYYYMMDD>/`
3. `npm run build`(tsc 零报错)→ `systemctl restart dshs`
4. 本机直连验证(绕 DNS/代理):
```bash
# SECURE_COOKIES=true → 必须走 TLS;用 --resolve 把域名指到回环
curl -sk --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/api/...
# 带登录态:-H "Cookie: sid=<token>"
```
(明文 `http://127.0.0.1:3080` 在 SECURE_COOKIES 下不通)
> ⚠️ **全量覆盖 `/opt/dshs/lib/` 之前,必须先在 HEAD 上 `npm run build`**(2026-09-13 22:16 实测事故)
> 并行会话做「整包 lib/ 覆盖」时用了一份**不含他人已提交改动**的陈旧构建 ⇒ **把别人的已上线修复整体回退**(本例:`/api/dsh/status` 的 `quota` 字段凭空消失)。
> - **指纹**:`lib/**` 一整批文件 mtime 相同 + 服务重启时间对得上 ⇒ 就是整包覆盖,不是点改。
> - **判据**:部署后立刻 `md5sum` 对账本机构建,或 `grep -c <你上次加的符号>` 目标产物。
> - **铁律**:**部署完立刻复验「上一次刚验收过的东西」** —— 本例正是靠这一步才发现的。
> - 另:**「已部署」≠「已提交」**;**不能拿服务器状态当版本事实源**(服务器曾跑着未 commit 的版本)。
**客户端插件(client bundle)部署流程**:
> ⚠️ **动手改之前先做「基线校验」(2026-09-13 补,改第三方 tgz 插件时尤其必须)**
> 插件以 tgz 铺到实例,**本地那个「源码目录」不一定是线上正在跑的那份**(可能漂移 / 被别的会话改过 / 是旧快照)。
> 步骤:把**服务器上正在用的 tgz** 拉下来解包 → 与本地源码目录做**忽略行尾**的全量比对(`diff --strip-trailing-cr`)→
> 逐文件确认「**差异只有我准备改的**」。否则可能用一套漂移的代码**覆盖线上**(本仓有过同类事故)。
> - 只看文件名/版本号不算校验(`package.json` 里的版本可能与实际不符;本仓 `lib/index.js` 的 `VERSION` 常量就长期落后于 `package.json`)。
> - ⚠️ **行尾会制造假差异**:`npm pack` 不规范化行尾,手工拼装的 bundle 常是**混用行尾**(实测:线上 `client.js` CR=5222/LF=5547,本地纯 LF),去掉行尾后整文件的「差异」会瞬间收敛到真实改动。
> - 反例(我犯的):**先改后验**。顺序错 = 风险窗口白开一段;改完再验只能证明「没出事」,证明不了「不会出事」。
1. archive 源改版(`/opt/dsh/docs/04-调整方案/poc/<plugin>/lib/client.js` + package.json version/description)
2. `npm pack` → tgz 拷到用户 `ws/`(chown 用户 uid)→ `setpriv --reuid=<uid> --regid=<gid> --init-groups env HOME=... DSH_HOME=... dsh plugin --profile web remove/add <tgz>`
3. kill 实例进程 → 门户自动 respawn 新 pid+**新端口** → `journalctl -u dshs` 抓 `dsh web: http://127.0.0.1:<port>/?token=...`
4. 验证 node_modules 副本 markers(version、关键字符串计数——区分代码 vs 注释行)
### 阶段 4 · 验证(全链路 + 浏览器实测 + 清理)
1. **API 链路**:portal enter(sid)→ url token → 页面 200 → `llm/listProviders ok:true`
- **★ 验证用例必须同时覆盖「有请求体」与「无请求体」两类**(2026-09-11 档案 51 事后修正实证):
只测 `POST` 会整条漏掉 `GET`/`SSE`。本次补丁初版闸门写成 `mayHaveBody && …` →
**POST 全绿、`GET`/SSE 完全不生效**,而 SSE 恰恰是"页面已打开、实例被回收"时最早撞 401 的链路。
凡改动含"按 method / 有无 body 分支",**`GET` 那条必须单独跑一遍**。
- **模拟浏览器语义要测到底**:断言不只是状态码,还要**只带最初那一个旧 cookie、忽略任何新 cookie**
再跑一遍(这才等价于真实浏览器)。修完后新 cookie 是否**回写**(`Set-Cookie`)必须显式断言。
2. **浏览器验证栈(2026-09-11 更正为非侵入式,勿走 agent-browser)**:
### ⛔ 铁律 0:动手前先 `list_tabs()` 确认「你在驱动哪个浏览器」
**browser-harness 会附着到本机**正在运行的**用户日常 Chrome**(CDP **9223**)——2026-09-11 实证:
`BU_CDP_URL=9223` **不被尊重**(daemon 常驻共享,`BH_RUNTIME_DIR_SHARED=1`),换 `BU_NAME` + 非共享 runtime 重建 daemon 后
`list_tabs()` **仍返回用户的 16 个标签页**(`admin.alotbuy.com` / `guest.alotbuy.com` / `portal.html#/plugins/manual`);
`curl --noproxy '*' http://127.0.0.1:9223/json/list` 直接证实 **9223 就是用户浏览器的调试端口**。
**所以:脚本第一步永远是 `print([t["url"] for t in list_tabs()])`。**
- 只要看到的**不是**你自己新开的空浏览器 → **立刻停手**,只做只读检查。
- **禁止**在未确认隔离前做任何有副作用的动作(登录 / 输入 / 提交 / 关闭标签)。
- 已造成的真实事故:在用户浏览器里 `fetch('/api/auth/login')` → **覆盖其 `.alotbuy.com` 的 `sid`**,用户在该浏览器里变成测试账号身份。
补救 = 删掉测试账号(cookie 失效 → 用户重新登录即可)。**收尾必须 `close_tab` 关掉自己开的标签。**
### 正确调用(Windows,2026-09-11 实测可用)
```bash
R="C:/Users/Administrator/.workbuddy/skills/browser-harness" # 必须 Windows 路径!POSIX 路径 Windows Python 不认
export PYTHONPATH="$R/src"; export BH_HOME="$R/.browser-harness-dev"
export BU_CDP_URL="http://127.0.0.1:9223"
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy NO_PROXY="127.0.0.1,localhost" \
"$R/.venv/Scripts/python.exe" -m browser_harness.run < script.py # 脚本走 stdin,不要 heredoc 引号地狱
```
helper:`js / click_at_xy / type_text / fill_input / press_key / switch_tab / close_tab / capture_screenshot / list_tabs / wait`。
### ⛔ dsh composer(Lexical)输入难点(2026-09-11 未攻克)
- composer 真身:`div[contenteditable="true"][data-lexical-editor="true"][data-composer-input="true"]`(class `uV2eYG_input`)。
- `type_text()` = `Input.insertText` → **绕过框架监听**,Lexical 模型不更新 = 没输入。
- `fill_input()`(逐字符 `press_key` + `input/change`)实测**也未生效**。
- 真因:**目标标签不是前台标签** → `el.focus()` 后 `document.activeElement` 仍是 `BODY`,CDP 鼠标/键盘到不了该标签;
`switch_tab` + `ensure_real_tab` + `Page.bringToFront` 都没解决。
- **对照**:Playwright 的 `page.click()` + `page.keyboard.type()` **成功过**(能真实发出消息并拿到回复)。
→ 结论:**要在独占浏览器里跑**(不与用户标签争前台);**传输层 bug 一律用 curl 验证**,浏览器只用于视觉/交互确认。
⚠️ **工具选择口径(2026-09-13 用户明令 —— 这条优先于下面那段旧实测)**:
**只允许两件工具** —— `agent-browser`(**默认**,独立浏览器)/`browser-harness`(附 CDP 9223)。
⛔ **Playwright / `playwright-core` 全面禁止**(用户 2026-09-13 明令)。
本机现状:`agent-browser` 的 daemon **起不来**(2026-09-13 实测:`open` 挂住零输出,而 `--version` / `node -e` 正常;Chrome 153 已装)⇒
退路走 `browser-harness`,但**必须用下面「独立 headless 实例」那套,绝不附着用户日常 Chrome**。
拿不到浏览器时,**静态页与 API 取数一律用 `curl`/`WebFetch`**,UI 结构用「无浏览器 harness」验收(见阶段 4 第 8 条三层验收)。
⛔ **`agent-browser` 2026-09-11 实测记录(保留作背景,勿据此否定上面的用户口径)**:
① 自带 Chromium 要从 `storage.googleapis.com` 下 196MB → **必然超时失败**;
② 改用本机 Chrome + `--cdp` 时,环境里有 `HTTP_PROXY=http://127.0.0.1:2349`(WorkBuddy 服务代理),
CLI 连 `127.0.0.1` 的 CDP **也走代理** → `Timeout connecting to CDP`
(要先 `env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy NO_PROXY="127.0.0.1,localhost"` 才通);
③ 即使通了,**标签/会话状态不一致**:`tab list` 显示 `[t1] about:blank`,而 `get url` 报 `login.html`;
`open <实例子域>` 不生效。→ **要浏览器就用下面这套 browser-harness**。
**核心教训:不要用用户日常 Chrome。** 直接 `new_tab` 到用户正在用的浏览器会反复弹「允许远程调试?」——用户会烦。根因:**绕过 wrapper 脚本**(`python -m browser_harness.run`)就不会设 `BH_RUNTIME_DIR_SHARED=1`,daemon 无法常驻复用 → 每次调用新建连接 → **每次弹一次授权**。
**定型做法 = 起独立 headless 实例(零弹窗、不碰用户浏览器、cookie 隔离)**:
```bash
# ① 一次性启动(后台常驻;PATH 必须先修好,否则 rm/node 会失败导致 Chrome 根本没起)
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
"/c/Program Files/Google/Chrome/Application/chrome.exe" \
--headless=new --remote-debugging-port=9223 --remote-allow-origins='*' \
--user-data-dir='C:\Users\Administrator\AppData\Local\Temp\chrome-bh-dsh' \
--no-first-run --no-default-browser-check --no-proxy-server --disable-gpu about:blank
# ② 探活:curl -s --noproxy '*' http://127.0.0.1:9223/json/version
```
```bash
# ③ 每次验证(把 python 脚本写成文件后重定向进 stdin,避免 heredoc 引号地狱)
R="C:/Users/Administrator/.workbuddy/skills/browser-harness"
export PYTHONPATH="$R/src"; export BH_HOME="$R/.browser-harness-dev"
export BU_CDP_URL="http://127.0.0.1:9223"
"$R/.venv/Scripts/python.exe" -m browser_harness.run < "C:/Users/.../shot.py"
```
**登录态**:DB 直插临时 session(`user_agent=poc-ui`,用后即删),取 token 后
`cdp("Network.setCookie", name="sid", value=TOKEN, domain=".alotbuy.com", path="/", secure=True)` → `Page.reload`。
用户侧真实浏览器完全不受影响(不覆盖其 sid)。
**三个必踩坑**:
- **`Emulation.setDeviceMetricsOverride` 按 target 生效** → 必须**先 `new_tab` 再设覆盖**;设在旧标签上再开新标签 = 覆盖丢失(截图只有 758×482)。
- **hash 残留会骗人**:页面停在上次的 `#/plugins/manual`,重载后目标元素不存在 → `js()` 返回 null / 元素测量得 `0/0`。**验证脚本必须显式导航到目标 tab**。
- **Chrome 必须先 `--headless=new` 前台跑通再加后台**;后台启动失败时先看 task 输出(常见是 PATH 未设导致前置 `rm` 失败,`&&` 短路,Chrome 从未执行)。
- 收尾:临时 session 删除;独立 Chrome 可留着复用(内存小),要停就 kill **9223** 端口对应进程。
3. **静态文件 vs 后端改动**:改 `web/*.html` **无需重启**(静态直出,实测服务启动时间不变);改 `src/**` 才 `npm run build` + `systemctl restart`。**别为前端改动重启服务**(会打断所有用户实例)。
4. **安全面复测**:降权 uid 探测敏感目录(/root、/etc/shadow、DB)应 denied;中间目录 711(去 r 留 x)不破坏子进程访问。
5. **清理临时物**:`poc-*` session 用完即删(DB DELETE);测试 tab 关闭;临时 tgz/脚本归档或清。
6. **截图留证**:每功能验收存 PNG 到本地 tmp + present_files 展示。
7. **🔴 交付前必问一句:这份改动「出厂」了吗**(2026-09-13 实证 —— 用户第 2 次反馈「样式还是没变」的**真因之一**):
改完 `lib/*.js` **≠** 用户会看到变化。出厂链固定为
**改文件 → bump `package.json` 版本 → `npm pack` → 上传 `/opt/dsh/artifacts/<包>-<版本>.tgz` → `node /opt/dshs/scripts/ensure-<包>.cjs --all --restart`**。
- **一秒自检**:`ls -la` 对比「源文件 mtime」与「最新 tgz mtime」—— **源文件更新 ⇒ 还没出厂**。
- ⚠️ 打包脚本里带断言时**先把锚点核对存在再跑 `npm pack`**:断言若在写文件前抛错,版本号没改但 tgz 已被覆盖 ⇒
得到「内容是新版、版本号仍写旧版」的**假产物**(本次已踩,靠手工删该 tgz 兜住)。
- 缓存不是借口:`src/supervisor/proxy.ts` 已对 `/plugins/`、`/assets/` 下发 `Cache-Control: no-cache`,且模块 URL 自带内容 `rev=<sha1>`
⇒ **bundle 一变浏览器下次请求就拿到新的**(「看不到变化」几乎都是没出厂,不是缓存)。
8. **视觉/UI 交付必须三层验收,且要拿到「真正下发到浏览器的那份」**(2026-09-13 定型):
| 层 | 做什么 | 能发现什么 |
|---|---|---|
| ① 单测 | `scripts/verify-*.mjs` 断言 **class 名与结构**(不只断言文本),关键结构逐项写死(例:**5 个弹窗都必须是 `.table-wrap + table.tbl`**) | 结构走样、分支没改到 |
| ② 实装 | `md5sum` 对齐 **本地产物 / 各 profile 的 `node_modules` 实链**,并断言**旧标记为 0** | 没铺发、铺了旧版 |
| ③ 端到端 | `mksess.cjs` 临时会话 → 取实例首页 → 解出 `/plugins/??…&rev=<sha1>` → `curl` 回 bundle → grep 新/旧标记(**用完即删临时会话**) | bundle 没下发、下发的是旧内容 |
> 只做 ① ⇒「脚本全绿但用户说没变」;只做 ①② ⇒ 漏掉「用户拿到的那份」。
9. **🔴 用户说「参考门户/某个现成页面的样式」时:抄那个页面的源码,不要抄规范的抽象条目**(2026-09-13 第 3 次踩):
正确动作 = 打开 `web/portal.html` / `web/admin.html` 的 `<style>`(或它 link 的 `design.css`),
把 `.nav-card` / `table.tbl` / `.badge` / `.btn-sm` / `.card-h` / `.page-title` 的**实际取值逐条搬过来**,
并在注释里写明「本类名 ← 门户某类名」的对应表(便于复核)。
⛔ 反面:只引 `06-工作台UI规范 §4.5/§4.7` 的抽象条目再自行演绎 —— 用户会说「还是没变」。
唯一允许的差异:**颜色走 dsh token `--dsw-*`**(嵌在 dsh 面板内须随主题);字号/间距/圆角/动效与门户**逐值一致**。
### 阶段 5 · 归档清理(文档同步 + 三层沉淀)
0. ★★ **交付门禁:本机改完 ≠ 交付**(2026-09-15 加 —— 同类已发生 **3 次**:09-13「只做到本地打包、没部署」→ 用户「点开看还是和之前一样」;09-14「应用更改是哪里…还是和之前一样」;09-15 会话原话「**平台那半我只改了本机,从没部署到服务器 —— 那你看的当然还是旧页面**」)
**收尾前逐项自问三句:① 这次改的是哪一层?② 这一层的生效链路是什么?③ 最后一步走了吗、在「用户可见面」验了吗?**
| 改动所在层 | 生效链路(**缺一步都不算交付**) | 用户可见面复验 |
|---|---|---|
| **平台静态页**(`web/*.html`、`web/i18n.js`、`portal.html`…) | `scp` 到服务器对应路径(如 `/opt/dshs/web/`)→ 注意 **CDN/CF 缓存**(2026-09-14 实测静态资源被 CF 缓存 **31 天**) | 用 `curl` 取**线上**页面(带 `?lang=` 等参数)确认新内容;必要时 cache-busting |
| **平台 TS**(`src/**`) | `npm run build` → **`systemctl restart dshs`** | 线上端点 / 页面实测(**不是**本地 build 通过) |
| **自研插件**(`poc/*`、client bundle / host 面) | 打 tgz → **admin 导入候选池 → 实例「功能管理」启用** → **重启实例**(client bundle 在**实例启动时**加载,见 `06-工作台UI规范.md §7`) | 取实例页 HTML 里真实 bundle URL **逐串验证**(`rev` 已变) |
| **文档库 / 技能** | `docs-sync-check.sh` 对账 → `scp`(同相对目录)→ **复跑对账** | 对账「一致」计数回升、且**残留项全是别人的 lane** |
| **配置**(env / nginx / nft / 配额) | 改 → reload / restart 对应服务 | 生效值实测(`systemctl show` / `curl` / `nft list`) |
⛔ **四条"自我安慰",一条都不算交付**:**本机改完了** / **build 通过了** / **本地打包完成** / **已 commit 了**。
⛔ **也别回头问用户「要不要部署」**:不打断在线用户的上线属**我的 lane 内执行细节 ⇒ 直接做完**(`CODEBUDDY.md §3 R7-边界` + 素材库 **U20 / X9**);只有**不可逆破坏性操作**与**边界外六类**才先问。
📌 与**阶段 4** 的分工:阶段 4 验"**改对了没有**"(三层验收);本门禁验"**东西到用户那儿了没有**"(链路完整性)。
1. **文档落盘(2026-09-13 现状:本地工作树 = 源,服务器 = 只读镜像)**:
- **源** = 本地 `D:\github\dsh_shenxian\dsh-server-docs`(**目录即 git 工作树**),推 Gitea `dsh_shenxian_doc.git` 的 **main**。
- **四件套(顺序固定)**:`docs-audit.py` → `docs-index-stats.py --write` → `docs-manifest.py` → `docs-sync-check.sh`;另有 `docs-consistency.py`。
- **传服务器**:用**单个 `tar` 管道**(逐个 `scp` 会因每条一个 SSH 连接而超时):
`tar -cf - <相对路径…> | ssh bt-server 'cd /opt/dsh/docs && tar -xf -'`,随后 `chmod`(目录 700 / 文件 600)。
- **对账**:`bash scripts/docs-sync-check.sh`(**服务器镜像无 `.git`**,只能靠 md5 对账;要点:`core.autocrlf=false` + `.gitattributes` 的 `* -text` 必须保持)。
- ⛔ **`docs-status-sync.sh` 已不存在**(旧流程残留,**勿再引用/调用**);状态摘要仍在 `/opt/dsh/docs-status/文档库状态备注.md`。
- ⚠️ **本库多会话共用** ⇒ 提交前先 `git status`,**只 `git add` 自己改的文件**;别人在途的改动(2026-09-13 实例:档案 76 md、`skills/dsh-opensource-release/SKILL.md`、`docs-manifest.json`)**一律不提交、不 scp**。
`docs-manifest.json` 是**派生文件**:它描述的是整库状态,若别人有未提交改动,**跟着一起提交会把别人的半成品「顺手上锁」**⇒ 此时宁可让它保持 dirty。
2. **档案更新**:`04-调整方案/<NN>-*.md` 写"需求→改动文件→commit→验证记录";README.md 档案清单行同步;版本号 bump。
- ⚠️ **档案号必须『原子预留』,不能靠"读一眼 ls 再写"**(2026-09-11 **两次**撞号实证):**"取号 + 写档案 + 改 `INDEX.md` + 改 `docs-status` 同一串行事务"这条规则本身不够** —— 两条并行通道同时 `ls` 时都读不到对方的文件,必然撞。**第二次撞号(37/38 各两条,4 个文件挤 2 个号)就是这么发生的**。
- **正确做法(原子锁)**:写档案前先原子占号,例如
```bash
cd /opt/dsh/docs/04-调整方案
n=$(ls | grep -oE '^[0-9]+' | sort -n | tail -1); n=$((n+1))
mkdir ".lock-$n" 2>/dev/null || { echo "占用,重取"; } # mkdir 是原子的
```
或等价地用 `flock /opt/dsh/docs/.numbering.lock -c '...'` 包住"取号+落文件"整段。**占号与落盘之间的窗口必须极短,且不要在中途放开去做别的任务。**
- **撞号后处置**:保留**已 commit** 的那个号不动(commit message 已引用它),把**自己未提交**的那个顺延改号(`mv` 文件 + 改 INDEX 行 + 改自己的 docs-status 变更行 + 在自己档案内修正交叉引用 + 加一条撞号说明)。**改号要在提交前做**,否则又制造一次引用错位。
- ⚠️ **`docs/README.md` 的档案清单已落后(止于 28)** → **不要只改 README**;全量清单在 `docs/INDEX.md`,以它为准。
3. **三层沉淀**(用户习惯,必做):
- 治本层:机制/流程固化进 skill/档案(本次即本 skill)
- 失误层:教训 append 到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only,不改写历史)
- 记忆层:长期约定/红线写 `.workbuddy/memory/MEMORY.md`(≤3000 字符/会话)
4. **汇总表**:带状态/结果列交付,列明未完成项与副作用。**排版按 `dsh-feature-first §5.4`**(首屏给判定 / 层级≤3 / 每节≤7 行 / 表格≤5 列 / 一条信息只说一次;细节进附录)。
5. **技能自身归档(本 skill 有改动时必做)**:工作副本在本机 `.workbuddy/skills/<name>/SKILL.md`(技能必须本地加载),服务器归档位在 **`/opt/dsh/docs/skills/<name>/SKILL.md`**(root 600)。改完本 skill 后**单向推**:
```bash
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
S="/c/Users/Administrator/AppData/Local/Temp/wb-scratch"; mkdir -p "$S"
cp "<本机 skill>/SKILL.md" "$S/SKILL.md" # 中文路径先转纯 ASCII
scp -i ~/.ssh/id_ed25519_dsh -P 22 "$S/SKILL.md" [email protected]:/tmp/SKILL.md
ssh -i ~/.ssh/id_ed25519_dsh -p 22 [email protected] \
'D=/opt/dsh/docs/skills/<name>; mkdir -p /opt/dsh/backups/docs-skills; \
cp $D/SKILL.md /opt/dsh/backups/docs-skills/SKILL.md.bak-$(date +%Y%m%d_%H%M%S); \
cp /tmp/SKILL.md $D/SKILL.md && chmod 600 $D/SKILL.md && chown root:root $D/SKILL.md && rm -f /tmp/SKILL.md'
```
**md5 必须双端一致**再算完成;同时更新引用它的元数据:`README.md` 模块表行、`INDEX.md` 场景速查行 + 全量清单行。**方向固定「本机 → 服务器」,勿反向覆盖**(服务器副本是归档,不是工作副本)。
⚠️ **改「跨载体规则」时必做全载体扫描**(2026-09-15 实证):一条交互 / 流程规则往往**同时躺在多个载体里**,
只改一处 ⇒ 其余载体立刻自相矛盾,**比不改更糟**。动手前先 `grep -rn` 关键短语,把命中处**全部**列出:
| 载体 | 典型位置 |
|---|---|
| 技能正文 | `SKILL.md` 的 §/铁律/硬约束/反模式/自检 + `references/**` |
| 项目根常驻层 | `CODEBUDDY.md §1`(**连带同节的指针行**,如「排版按 …」) |
| 用户级记忆(跨项目) | `~/.workbuddy/MEMORY.md` |
| **钩子文案** | `scripts/stop-dialog-guard.py` 的 `REASON` / `CONTEXT` |
| **生成物(勿手改)** | `dsh-env-bootstrap/references/常驻规则-快照.md` ⇒ 改完源后跑 `python resident-rules.py --snapshot` **重生成**,再 `--check` 应 RC=0 |
| 登记行 | `README.md` 模块表 · `INDEX.md` |
判据:改完再 grep 一遍,命中应**只在已改处**。**钩子文案最关键** —— 它每轮都在"教训 AI 该怎么做",
与规则打架时危害最大(本轮实证:钩子写「不要在结尾甩问题」,用户要「待确认内容放到最后」,两句话直接对撞)。
收口 = 上表**逐格确认** + 两副本 md5 一致 + 镜像 md5 一致 + 七件套全绿。
## 档案模板(04-调整方案/<NN>-<标题>.md)
```markdown
# <NN>-<标题>(<日期> 落地 / 调研)
## 背景与动机 # 需求来源、触发场景
## 用户决策 # 关键分叉 + 选择 + 理由(含日期)
## 实现 # 改动文件清单 + commit hash + 关键代码/配置片段
### A. ... # 分模块
## 验证记录 # 实测命令 + 输出 + 结论(含失败尝试)
## 事故/踩坑记录 # 坑现象→根因→规避(若适用)
## 回滚 / 注意 # 回滚步骤、副作用、后续待办
```
## 红线 R1-R8 原文速查
1. **禁止启动 dsh 时自动获取最新版本**;版本升级独立流程(档案 07;/usr/local/etc/npmrc `update-notifier=false` 已全局生效)。
2. **不改官方 dsh 主程序与缓存**(`/usr/local/lib/node_modules/@deepseek-ai/dsh` 及依赖);扩展只走 profile 层官方插件机制。
3. **client bundle 严禁 `exports.default = apply`**(或任何 default 函数导出)——详见上文 R3 解释(@dsh-local/portal-entry v0.4.0→v0.4.1 实证教训)。
4. **禁止用真实账号(`admin`/`guest`)做 API 登录测试**:登录 last-wins 会删该账号全部旧会话,直接把用户踢下线。改用 `mksess.cjs` **PG 直插**临时 session 或专用测试账号(档案 15 实证教训)。
5. **权限可见面只准收窄,扩大必须先确认**(2026-09-11 用户新增,R5)——见下节「R5 权限扩大门禁」。
6. **R7|禁止未经确认的批量 / 全仓写入**(2026-09-12 用户新增红线)—— 见下节「R7 批量写入门禁」。
7. **R8|中断在线用户的生产变更须先确认**(2026-09-12 用户新增红线)—— 见下节「R8 生产变更知会」。
### R7 批量写入门禁(2026-09-12 用户新增红线)
> **用户原话**:「为什么会犯这种错误,容易把服务器搞崩,必须记录在红线中。」
**由来(真实事故)**:为了让 scp 出去的文件行尾干净,写脚本遍历整个代码库把 **147 个文本文件** CRLF→LF。当期被要求的只是一句「把某个 UI 字符串改个名」,**操作半径放大了两个数量级**。
**已经造成的实际损害**(不是理论风险):
- 147 个文件被标记为 M —— 若继续 scp / 提交 / 推送,会覆盖服务器上正确的版本、产生巨型 diff 掩盖真实改动、并与并行会话冲突;
- 随后用 `cp -r` 同步插件目录时,把**当期并没有改过的** `cordis.patch.yml`、`lib/index.js` 也用 CRLF 覆盖到了**服务器**(已发现并恢复)。
**规则(硬性)**:
1. **只做被明确要求的事**。执行中发现的额外问题(哪怕看起来"很小、很好修")一律**先报告、后动手**,不得顺手改。用户说"按建议处理"只授权**那条建议本身**,不是授权一切顺带优化。
2. **禁止**对仓库或生产目录做:全库遍历改写(`os.walk` / `find -exec` / `grep -rl | xargs`)、通配符重写、批量 `chmod`/`chown`、**批量换行符转换**、`cp -r` 整目录覆盖、`git add -A`。
3. **阈值**:一次操作若**可能影响 >10 个文件**,或表述里出现「所有 / 整个 / 全库 / 全部」→ **停下来先问**,先产出**受影响清单**再决定。
4. **本机不是沙箱**:本机镜像与服务器是两份独立副本;本机的批量改动即便不带任何"部署"动作,也会在下一次 scp 时传导到生产。
5. **先用单点验证**:任何批量手段先对 **1 个对象**试,确认后果(`git status`、`file`、`md5`)符合预期再考虑推广。
6. **传播前比对待传清单**:scp / 同步前必须 `git status` 确认**待传清单只含本次真实改动**,不含被工具顺手改动的文件。
7. **行尾类问题一律先报告**:仓库 blobs 是 LF 而本机工作树因 system 级 `core.autocrlf=true` 呈现 CRLF(`D:/Program Files/Git/etc/gitconfig`)—— 这是**既有环境事实**,不构成"需要当场修复"的缺陷;要动必须先与用户确认范围。
### R8 生产变更知会(2026-09-12 用户新增红线)
**由来**:档案 58(内存优化)、59(重连反馈)两次改动都需要重启 `dshs`,**连续两次把在线用户踢下线**,直接引发用户"会话连接异常"报障。
**规则**:
1. 以下动作**都会中断在线用户**,执行前必须先说明「**影响谁、断多久、为什么必须现在做**」并取得确认:
- `systemctl restart dshs`(drain 全部实例)
- `systemctl stop dsh-*.scope`(停某个用户实例)
- 批量铺插件 / 改 `MemoryMax` / 改实例 env(都要实例重启才生效)
- 重建 profile、改 profile patch
2. **能选低峰期就不要在用户活跃时做**;无法避免时明确告知"会断一次"。
3. **改完即验证**:重启后必须确认服务 active + 门户 200 + 实例能被拉起,再向用户交代。
4. 附带:本机 `D:\github` 下的镜像**任何批量改动都视为"可能影响生产"**(见 R7 第 4 条)。
### R5 权限扩大门禁(2026-09-11 用户新增红线)
**先判方向:这次改动是「扩大」还是「收窄」?**
| 方向 | 例子 | 处置 |
|---|---|---|
| **收窄** | 减少挂载、去掉白名单项、收紧 nft、收窄 env | 可直接做,但**仍须验证**(遮蔽类可能让实例起不来 → 档案 42) |
| **扩大** ⚠️ | 新增 bwrap 挂载 / `--bind`、放开被遮蔽的路径、把平台目录或文件暴露给实例、给实例注入新 env、放宽 `ALLOWED_ENV`、放松 nft(出网或宿主访问)、提高权限档位或放宽 `approval`、新增用户可读/可写路径、把 root 执行链路(解压 / chown / pnpm)的对象变成用户可控 | **一律先出「权限影响评估」并等用户明确同意**,禁止"顺手做了" |
**「权限影响评估」四问(方案里必须逐条写,档案留痕)**:
1. **扩了什么** —— 逐条列具体路径 / 端口 / env / 权限位(不要写"优化了访问"这种含糊话);
2. **谁受影响** —— 全部租户 / 单租户 / 仅 admin;
3. **有没有不扩大也能实现的方案** —— 若有,必须先提;若确实没有,说明为什么;
4. **回滚方式 + 验收方式** —— 回滚命令写清楚;验收**必须 diff 技能里的「实例可访问路径清单(权威版)」**,
逐项确认"新增项都是用户已同意的"。
**🔒 安装类操作 = 扩大,必须先出「安装确认清单」并经用户确认**(2026-09-11 用户明确要求:
「需要安装哪些依赖和工具,需要确认后才能安装,避免安装有风险的内容」)。
凡是要往**平台共享位**(`/usr/local/dsh-runtime`、`/usr/local/bin`、系统包)装东西,**一律不得自动执行**,
先列七项等用户点头:① 名称+版本 ② **为什么需要**(哪个技能/插件在用)③ **来源与校验方式**(官方 URL + sha256)
④ 体积 ⑤ 影响面(**全部租户**)⑥ 风险点 ⑦ 卸载方式。
技能/插件**导入时的依赖体检只做「报告 + 拦截」(缺失即 409),绝不代装**。
**触发文件(改这些就必须走 R5,无一例外)**:
`src/supervisor/orchestrator.ts`(bwrap args / `baseEnv`)、`src/supervisor/spawn.ts`(`ALLOWED_ENV`)、
`/etc/nftables-dsh-egress.nft`、`src/web/routes/skills.ts`、`src/web/routes/business-plugins.ts`、
`ensure-role-profile-patch.cjs`(角色 profile patch)。
> **历史依据**:档案 39(`/etc` 白名单化)、40(bundled-skills 挂载)、41(技能上传/启停)、42(Python 运行时)
> 里每一次"扩大"都是在用户明确要求下才做的;反例是档案 42 的遮蔽尝试 —— 属"收窄"但因触碰挂载结构
> 直接让实例起不来,**收窄也必须跑真启动验证**。
### Profile 层 cordis patch 机制(角色化 UI 裁剪,档案 09)
- client 插件行 id = **短 id**(`ui-settings-models`,非包名),见 `dsh --profile web --dump-config`。
- **禁用官方 client 插件**(如设置面板「模型」分区对普通用户):profile `cordis.patch.yml` 写 `- id: <短id>\n name: "@deepseek-ai/<包名>"\n disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入 overrides)。`--dump-config` 验证:目标行出现 `disabled: true` + `# == ... patched by <path>` 注释。
- **生效必须重启实例**(client bundle 启动时打包);`patchReload: live` 对 client 插件增减**不生效**(实测 5 轮)。
- 当前生产 spawn 不带 `--patch` overlay(enablePatch=false),disable 只能写 profile 层 `cordis.patch.yml`。
- 幂等工具:`/opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>`(全量=非 admin 用户;含管理标记头则跳过;非默认空内容不覆盖)。admin profile 不动 = admin 保留该分区。
### Skill 装载机制(全员共享只读技能 = bundledSkillDir,档案 10)
- 技能由 agent preset 注册:`@deepseek-ai/dsh-agent-presets/presets/standard/agent.cordis.yml` L83-88(`skill-filesystem` + `tool-skill`),preset 无 config 块 → provider 配置走 **env**。
- 技能分层 rank(`dsh-skill-filesystem/lib/index.js` roots()):project-dsh 100(`<项目>/.dsh/skills`)/ project-agents 200 / custom 300 / user-dsh 400(`$DSH_HOME/skills`,每用户独立)/ user-agents 500 / **bundled 600(`$DSH_BUNDLED_SKILL_DIR`,`trustedHost: true` 只读共享)**。
- 扫描粒度:`discoverRoot` **只扫 1 层**(root 下每目录须含 `SKILL.md`;或直接 .md 文件);`root/主技能/subskills/子技能/SKILL.md` **不会被发现** → subskill 要独立可调需**平铺**到技能根。
- **编排器 env 白名单(关键卡点)**:`/opt/dshs/lib/supervisor/spawn.js` `ALLOWED_ENV` 仅 PATH/HOME/USER/TMP/LANG 等基础变量;`baseEnv()` = `{...scrubEnv(process.env), HOME, DSH_HOME, DEEPSEEK_API_KEY}` → **新 env 变量必须同时加白名单 + baseEnv 显式注入**(src/*.ts 同步),否则被 scrub 丢弃。这是改编排器(自研,非红线 2 对象)。
- MCN V1.0 技能跨平台部署注意:`subskills/browser-harness/envs/` 为 **Windows venv**(`Scripts/*.exe`),Linux 服务器不可用需重建;海外节点直连抖音风控风险高,数据类优先 RedFox API。
### Skill 管理面(编排器 API + 静态页,档案 11)
- **dsh 技能名硬规则**:`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`(`@deepseek-ai/dsh-skill/lib/index.js` SKILL_NAME)—— **中文名技能 dsh 静默丢弃**。MCN 等含中文名技能需先改 `SKILL.md` frontmatter `name` 为 kebab(如 `mcn-workstation`)。
- **API 形态**:`/api/skills/shared`(admin 三件套 GET/POST/DELETE)+ `/api/skills/mine`(任何登录用户);上传 base64 body `{ file, filename, force? }`;后端走系统 `tar/unzip` 解压、路径穿越校验、合法名校验。包结构:单顶层目录 + 含 SKILL.md。
- **落盘属主**:shared → root:root 0755(OS 权限兜底只读);mine → `home` 属主用户 uid/gid(确保用户可改自己的技能)。踩坑:`chownTree` 须 chown 顶层目录自身(首次实现只 chown 子项,目录保留 tar 包内 owner)。
- **watch 即时生效**:`skill-filesystem` 对共享根 watchManager 监听 addDir/unlinkDir/SKILL.md → 自动 invalidate registry(**无需重启实例**,源码已核实)。
- **编排器 env 注入 DSH_BUNDLED_SKILL_DIR**:spawn.ts ALLOWED_ENV 加变量 + orchestrator.ts baseEnv 显式注入(`config.bundledSkillDir`,默认 `<dataRoot>/bundled-skills`);这是上一节「全员共享只读技能层」的实例侧落地。
## 通用运维锚点(重启用)
- 门户重启后实例需重新 launch:`curl -b "sid=..." -X POST http://127.0.0.1:3080/api/dsh/enter` → 返回实例 port + token url
- 实例检测:`pgrep -af "^node /usr/local/bin/dsh --profile web"`;监听:`ss -tlnp | grep :<port>`
- 测试 session 生成:`node /opt/dshs/mksess.cjs`(**PG 直插**,连接串取自 env / `dshs.env` / `dshs.service.d`;10 分钟;`user_agent=poc-curl2`);清理:DELETE WHERE user_agent='poc-curl2'
- **存量会话档位体检(档案 36 P0-1,只读,建议定期跑)**:权限档位**会话级播种**(建会话时写 `permission/preset` + `sandbox/mode` + `approval/policy`,**resume 不重播**)→ 改默认档位只惠及新会话。查全平台还有多少会话是旧档位:
```bash
for f in /var/lib/dshs/users/*/home/sessions/*/*/session.jsonl.zstd; do
[ -f "$f" ] || continue
printf "%-12s %s\n" \
"$(zstd -dc "$f" 2>/dev/null | head -c 4000 | grep -o '"preset":"[a-z-]*"' | head -1)" \
"$(echo $f | sed 's#.*/users/##')"
done
```
2026-09-11 实测:**总会话 10,`workspace-write` 10,`danger-full-access` 0** —— 档案 33 之后**一条都没迁移**。
- **技能投放体检**:`for u in /var/lib/dshs/users/*/; do echo "$(basename $u): $(ls -A $u/home/skills 2>/dev/null|wc -l)"; done; ls -A /var/lib/dshs/bundled-skills | wc -l` —— 2026-09-11 实测**全为 0**(机制就绪但零投放,档案 36 P0-2)。
- **文档备份目录要先建**:`mkdir -p /opt/dsh/backups/docs` 否则 `cp ... .bak-<ts>` 报 `No such file or directory`(本次踩坑)。
- 服务器 git:`git -C /opt/dshs`(user.name/email = [email protected])
- **⚠️ 服务器 git 提交前先 `git status --short`**:工作树常滞留未提交文件(曾有一次 `git add -A` 把 `/logout` 路由、`ensure-role-profile-patch.cjs`、`mksess.cjs`、`*.bak` 一并卷进 `fa718d2`);应**定向 `git add` 只暂存本次改动文件**。
- **登录直达冷启动竞态(档案 13)**:`enter` 返回打开 URL 前必须等 launch token(`waitForLaunchTokenForUser`);实例 spawn 后 `status` 即 running 但 HTTP 路由未就绪,浏览器撞启动窗口会 404(`proxy.ts` 的 404 只对应 unknown_user/not_running,此 404 来自实例自身透传)。验证用「enter 并发 + `--resolve` 回源 curl」:URL 必须含 `?token=`,子域应 303→200。
## 本机 Git Bash 环境坑(2026-09-11 实证,几乎每次都踩)
- **PATH 常坏**:`Bash` 工具里 `dirname`/`head`/`ls` 报 `command not found`,且 `~/.ssh/id_ed25519_dsh` 也找不到(PATH 里没有 ssh)。**每次先前置**:
```bash
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
```
- **node 是 Windows 程序,不认 `/c/...` msys 路径**(会拼成 `d:\c\Users\...`)。脚本里写路径要用 `C:/Users/...`;而 `ssh`/`scp`/`cp` 这些 msys 程序要用 `/c/...`。
- **中文路径下发文件不可靠** → 中转文件先 `cp` 到纯 ASCII 路径(`C:/Users/Administrator/AppData/Local/Temp/wb-scratch/`)再 `scp`。
- **heredoc 通道 UTF-8 安全**:`ssh HOST 'cat > /tmp/x' << 'EOF'`(引号定界符)可原样传输中文,已多次验证;但**别在 heredoc 定界符后再接 `' 2>/dev/null`** 之类的引号拼接,会 EOF 报错 —— 复杂脚本改成「本地写 → scp → 远端 node 执行」。
- **PowerShell 工具在本会话输出被吞**(命令成功但无 stdout)→ 本地操作优先用 Bash(修好 PATH 后)。
- **`.bak-*` 可能已被 git 跟踪**(历史提交过):不要批量挪动/删除它们,否则产生意外的 `D` 变更;只做定向 `git add`。
## 外部数据源缓存必须带结构版本守卫(2026-09-11 实证)
换数据源或在缓存条目里增删字段时,**必须同时 bump 一个 `CACHE_VERSION`**,并在读缓存时校验版本/结构(不符即视为未命中重拉)。
踩坑实录(档案 29):白名单路由从「git clone + 解析 yml」改为「拉 `plugins.json`」后,`whitelist-cache/index.json` 仍是**旧格式**且 `fetchedAt` 在 TTL(6h)内 → 被直接复用 → 条目缺 `owner`/`importKind` 字段 → 按字段过滤时 `e.owner.toLowerCase()` 抛异常 → **接口 500**。加 `CACHE_VERSION` 守卫后自动重拉修复。
配套好习惯:① 缓存读失败/不可用时**降级用旧缓存并返回 `stale: true`**,前端提示;② 排序按「热度」字段降序,让无效条目自然沉底,默认首页就是最有价值的那些。
## 官方白名单插件来源(档案 29 定型口径)
- **数据源**:`https://awesome-dsh-plugin.com/plugins.json`(官方规范地址,3.1MB / 3408 条 / 23 分类;npm 镜像包 `dsh-plugin-catalog`)。字段:`name/owner/url/page/category/description{zh,en}/npm/version/stars/downloads/install/added`。**不要**再去 git clone 仓库解析 3431 个 yml —— 其 `tarball` 是可选字段,覆盖率仅 6%(211/3431)。
- **导入口径 = 仅预构建**(平台侧绝不执行第三方构建脚本):有 `npm` → `registry.npmjs.org/<name>` → `dist-tags.latest` → `dist.tarball`(scoped 包地址 `@scope%2Fname`,即 `encodeURIComponent(name).replace('%40','@')`);有 `tarball` → release 资产;两者皆无(`install` 为 `github:owner/repo`)→ 拒绝并回显原因。**可导入 1792/3408 = 52.6%**,官方下载量 TOP10 全覆盖。
- **npm 官方 tarball 可直接喂现有 `stageTgzArchive`**(`package/` 包装层已被 `findPackageJson` 兼容)→ 无需改 DB schema。
- **筛选排序技巧**:给列表加 `onlyImportable=1`,并在前端把「只看可导入」默认打开;按 `downloads` 降序使未发 npm 的条目自然沉底。
- **安全兜底不外包**:收录 ≠ 安全审计(上游 README 明确)。所有导入包仍走平台 `stageTgzArchive` 扫描,P0 命中即阻断,并把原因**在页面内回显**给 admin 判断(例:热门插件 `FuRongJun-1999/dsh-memory` 因 `docs/…example.yml` 被判 P0,疑似误报)。
## dsh 沙箱与权限预设机制(2026-09-11 源码核实,改默认值必看)
**两层隔离,别混淆**:
| 层 | 谁提供 | 说明 |
|---|---|---|
| **内层** | dsh 自带沙箱 `read-only` / `workspace-write` / `danger-full-access` | 靠 Landlock 或 bubblewrap 后端。**本机不可用**:内核 5.10(Landlock 需 5.13+,LSM 无 landlock),bwrap 嵌套探测失败 → **fail-closed 拒绝执行任何 shell**(`@deepseek-ai/dsh-sandbox/lib/index.js:185`)|
| **外层** | 平台 `systemd-run --scope` + `bwrap` + `setpriv` | **真正的边界**:mount 路径隔离 + uid 隔离 + cgroup(**基础 448M → 上界 1024M**:`MemoryHigh=MIN` 软限 + `MemoryMax=MAX` 硬限;**与插件开关无关**(档案 96)/150%/128)。内层失效不影响它 |
**权限预设插件**(row `@deepseek-ai/dsh-permission-presets`,短 id 一般即 `permission-presets`):
- 内置预设表(**sandbox 与 approval 成对绑定**,这是关键坑):
```js
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' } // ← 选它=同时摘掉审批
```
- `defaultPreset = config.defaultPreset ?? inferredDefault`;默认落 `workspace-write`(→ 本机即「shell 全废」)。
- 设置持久化命名空间 = **`permission`**(settings key `permission.defaultPreset`,值必须是 `presets` 里的键名)。
**改默认值的官方做法(不碰官方主程序,R2 合规)**:
> ⭐ **首选:`DSH_PERMISSION_MODE` 环境变量**(2026-09-11 定案,档案 33)。
> 官方 `@deepseek-ai/dsh-base/cordis.patch.yml:217/233` 直接读它:
> ```yaml
> mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
> policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
> ```
> 在编排器 spawn 时注入 `DSH_PERMISSION_MODE=danger-full-access` 即可 **全局默认「完全权限」+ 免审批**,且:
> ① 不必铺 profile(env 随 spawn,**新用户自动生效**);② 完全复用官方逻辑(预设表 / 设置 UI 都不动)。
> 落地两处:`spawn.ts` 的 `ALLOWED_ENV` 加该键 + `orchestrator.ts` 的 `baseEnv()` 注入(值取 `process.env.X ?? 'danger-full-access'`,运维可覆盖)。
> ⚠️ **副作用**:权限档位是**会话级播种**(会话创建时写 `permission/preset` / `sandbox/mode` / `approval/policy` 三条种子事件)→ **旧会话不跟随新默认**,必须**新建会话**才生效。给用户的话术是「新开一个会话」,不是「刷新页面」。
其余做法(需要更细粒度时才用):
1. **profile 层 `cordis.patch.yml` 改该行 `config`** —— 已核实 `applyEntryPatches` 里
`const { id, insert, name, ...overrides } = patch` → **patch 的其它键会作为 overrides 合入目标行**,所以 `config:` 可覆盖。
注意 **`config` 是整体替换不是深合并** → 必须把内置的两个预设一并写全。
2. 或改设置:`$DSH_HOME/settings.yaml` 加
```yaml
permission:
defaultPreset: <presets 里的键名>
```
3. **profile 是每用户一份** → 要像 `ensure-role-profile-patch.cjs` 那样给每个用户(含新用户)铺;改完**必须重启该用户实例**才生效。
4. 用户仍可在实例「设置 → 权限」自助切换(`ui-permission`),这是设计内的。
**推荐形态**:不要直接用 `danger-full-access`(会连审批一起摘掉)。在 patch 里**新增一个自定义预设**:
`sandbox: danger-full-access` + `approval: ask`,语义 = 「隔离由平台容器提供,高风险操作仍需确认」。
## 实例可见面 · 软件共享 · 网络与安全边界(档案 38a,2026-09-11 实测)
### 铁律:实例能看到什么,完全由 bwrap 挂载面决定
`orchestrator.ts` `spawnAsUser()` 的 `bwrapArgs` **只挂这些**(照抄,勿凭记忆):
```
--ro-bind /usr /usr ← 含 /usr/local(宿主装一次 = 全员共享的现成通道)
--ro-bind /lib64 /lib64
--symlink usr/bin /bin ; usr/sbin /sbin ; usr/lib /lib ← 合成根(档案 23)
--tmpfs /etc + 14 项文件白名单 ← 见下「/etc 白名单」;**不要用 --ro-bind /etc /etc**
--dev /dev --proc /proc
--bind <userRoot>/tmp /tmp ← 每用户独立,1777
--bind <userRoot> <userRoot>
--ro-bind-try <userRoot>/home/profiles/<web|headless>/{cordis.patch.yml,package.json,pnpm-lock.yaml} ← 只读,防绕过投放管控
--unshare-pid ← 注意:**没有 --unshare-net**
--chdir <userRoot>/ws -- setpriv --reuid <uid> --regid <uid> --clear-groups <cmd>
```
> ⚠️ **最常见的漏**:**平台侧任何新增的共享目录,都必须同步补一条 `--ro-bind`,否则实例内根本不存在**。
> 实证(档案 38a P0):`bundledSkillDir` = `<dataRoot>/bundled-skills`(=`/var/lib/dshs/bundled-skills`)**没被挂载** → 以平台原样参数进实例 `ls` = `No such file or directory`;而 `dsh-skill-filesystem/lib/index.js:84,181` 是 **`resolve()` + 实例内直接读盘**(不是编排器推数据)→ **档案 10/11 的共享技能层实际失效:技能投进去也发现不了**。修法 = `'--ro-bind-try', bundledSkillDir, bundledSkillDir`(放在 `--bind root root` **之后**)。
#### `/etc` 白名单(档案 39,2026-09-11 落地)
**`--ro-bind /etc /etc` 不要用** —— 实例会读走 `/etc` 下 **576 个 others-readable 文件**,含**平台情报**:
`/etc/systemd/system/dsh-*.{service,path}`、`/etc/nftables-dsh-egress.nft`(**出网护栏规则全文**)、
`/etc/cron.d/dsh-*`、`/etc/letsencrypt/renewal/*.conf`。
正确写法 = **空 `--tmpfs /etc` + 逐文件 `--ro-bind-try`**,白名单 15 项(运行时实测必需最小集):
`ld.so.cache` `ld.so.conf` `passwd` `group` `nsswitch.conf` `hosts` `resolv.conf` `host.conf`
`services` `localtime` `os-release` `machine-id` `pki/tls/certs` `pki/ca-trust` **`alternatives`**
- ⚠️ **symlink 必须 `realpathSync()` 后绑「真实目标 → symlink 原路径」**,否则实例内是断链
(`nsswitch.conf` → `/etc/authselect/`;`localtime` → `/usr/share/zoneinfo/`)。
- 🔴 **`alternatives` 是「软链枢纽目录」,必须整体挂 —— 这是最容易漏、且事故最隐蔽的一条**
(2026-09-11 实测踩到):`/usr/bin/python3` 是**两跳软链**
`/usr/bin/python3 → /etc/alternatives/python3 → /usr/bin/python3.6`,
中间一跳在 `/etc` → 沙箱内 `python3` **静默 `command not found`**(不是报错,是"命令不存在")。
一次就打断 **21 个命令**:`python3` `python` `pip3` `pip-3` `pydoc3` `python3-config` `pyvenv-3`
`easy_install-3` `unversioned-python` `ld`(→`ld.bfd`,node-gyp 编译要用) `pax`
`print-*` `ifup` `ifdown` `lpc`。
→ **判定准则:只绑"直接指向 /etc 的软链"是不够的,必须枚举"整条链条会穿过 /etc"的条目**:
```bash
find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read -r f; do
[ -L "$f" ] || continue; c="$f"; n=0
while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c")
case "$t" in /*) c="$t";; *) c="$(dirname "$c")/$t";; esac
case "$c" in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done
done | sort -u
```
安全性:`/etc/alternatives` 33 项**全部指向 `/usr` 或 `/lib64`**(已只读挂载),不含凭据/平台情报。
- ⚠️ **这类回归的唯一验收手段是"工具清单前后对比"**(`for c in …; do command -v $c; done`)或**会话取证**
—— 它不报错、不崩溃,只是能力静默消失,所以 `/etc` 白名单类改动**必须跑一次全量工具对比**。
- 实测效果:可见项 **222→13**、可读文件 **576→27**、平台情报 **4/1/2/8 → 0/0/0/0**;
同时 `node` / `os.userInfo` / DNS / node-TLS / `curl` / `dsh --dump-config`(549 行/176 插件)**全通**。
- 凭据类本来就读不到(`dshs.env` 600、`shadow`/`gshadow` 0000、`sudoers` 440)——
**别把"没泄露"当成本次收益**。
- 判据:**`/etc` 里凡是"平台的秘密"就白名单化**;`/usr` 不必动(审计证实零凭据,且是运行时宿主;
`/usr/bin` 的 1151 个 CLI 是 agent 唯一手脚,去掉=平台核心能力归零)。
#### ⛔ H5 红线:OUTPUT 链按「目的地址」封本机服务 = 自伤(档案 39 实测踩到)
实例是「先收后回」的服务端:nginx(root) → 实例端口的 SYN 合法,但**实例回的 SYN-ACK 与后续数据包
`daddr` 同样是 `127.0.0.1`**。若写 `meta skuid <uid段> ip daddr {127.0.0.0/8,…} reject` 一刀切,
**回包会被一起拒掉** → nginx 无法回源、**实例整体不可用**。
- **症状指纹**:root 连实例端口 **timeout(丢包)而不是 refused**。refused = 规则只打在客户端方向(正常);
**timeout 往往意味着双向都被打到**。
- **正确写法**:只匹配**主动发起**的连接 —— TCP 用纯 SYN、UDP 用 `ct state new`:
```
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, <eth0>, <公网EIP> } \
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn counter reject with tcp reset
meta skuid 100000-199999 ip daddr { …同上… } meta l4proto udp ct state new counter reject
```
- 与平台既有 `src/supervisor/firewall.ts` 的 **portGuard**(iptables `-m owner ! --uid-owner 0 -j REJECT`)
**语义一致、范围更全**,且不依赖 `config.portGuard` 开关;portGuard 的存在**反证实例进程自身不需要任何
loopback 连接**。
- **验证闭环(缺一不可)**:① root 回源必须拿到 `303`/`200`(**不能只看 `ss` 有 LISTEN**);
② 实例内自连宿主端口必须 `ECONNREFUSED`;③ `nft list table ip dsh_egress` 看 counter 是否命中。
- **改前先查有没有实例在跑**:`systemctl list-units "dsh-*scope" --no-pager --all`。
- 一键复现:`bash scripts/install-egress-guard.sh`(含 nft + service,已纳入版本控制)。
#### ⛔ 无法遮蔽 `/usr` 内的文件(档案 42 实测踩到 → 实例起不来)
想「屏蔽 `/usr` 里某个文件」(例如藏掉旧解释器 `python3.6`)时**不能用叠加挂载**:
`--ro-bind /dev/null /usr/libexec/platform-python3.6` 会让 bwrap 报
`Can't create file at …: Permission denied` → **实例直接起不来**。
根因:bwrap 需要**在 DEST 创建挂载点**,而 `/usr` 是 **ro-bind(只读)** → 创建失败。
`/etc` 之所以能自由白名单,是因为它先 `--tmpfs`(可写)再逐项 `--ro-bind-try`。
- **判据**:**只有「先 tmpfs 再白名单」的目录才可自由增删**;ro-bind 的目录只能整目录决策。
- **可行变体(未采用,收益低风险中)**:`--tmpfs /usr/libexec` + rebind 必需子项
(`git-core` `getconf` `gawk`/`awk` `coreutils` …)→ 与 /etc 同构的白名单,但要有同类的"静默回归"预案。
- ⚠️ **改 bwrap 参数后必须真启动一次实例验证** —— 本类错误会让**所有实例无法启动**(不是静默降级);
改前先确认无实例在跑:`systemctl list-units "dsh-*scope" --no-pager --all`。
#### 实例共享 Python 运行时(档案 42)
- 系统 `python3` = **3.6.8**,本体 `/usr/libexec/platform-python3.6` 是 **dnf/yum 的兄弟解释器**
(shebang 用绝对路径 `/usr/libexec/platform-python`)→ **不能移除**;而 `/usr/bin/python3`
只是两跳软链(经 `/etc/alternatives`),**动它安全**。
- 平台已装**可移植 Python 3.12**:`bash scripts/install-python-runtime.sh`(幂等 / 可离线)
→ 解压 python-build-standalone 到 **`/usr/local/dsh-runtime/python-3.12.14/`**,
`/usr/local/bin/{python3,python,pip3}` 指过去。
`/usr` 已 ro-bind + `/usr/local/bin` 在实例 PATH 首位 → **实例内 `python3` 即 3.12,零代码、无需重启**。
- **迁移打包单元 = `/usr/local/dsh-runtime/` 一个目录**(自包含,不依赖系统 rpm):
`tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime` → 目标机解压回原位 + 重跑脚本(只重建软链)。
- 📌 **对 AI/用户的引导(这是"3.6 不开放"的正解,不是遮蔽)**:明确写「Python 用 `python3`(= 3.12);
`python3.6` / `python2` 是系统遗留、**不受支持**」。实例内 `/usr/local/bin` 在 PATH 首位,
`python3` 已指向 3.12 —— 引导的成本为零、风险为零;而遮蔽属"改挂载结构",会让实例起不来(见上)。
### 🔒 基础运行时版本冻结(档案 44)
**要求:禁止用户以任何方式升级 Python / pip / node 等基础包**,避免版本差异导致插件功能不可用。
**已经天然成立的护栏**(都不需要额外代码):`/usr` 是 ro-bind →
实例内**改不了** `/usr/local/dsh-runtime/**`;`pip install`(默认)被 Read-only 挡;
`npm/pnpm -g` 失败(`/usr/local/lib/node_modules` 只读);`$HOME/.local/bin` **不在**实例 PATH(固定
`/usr/local/bin:/usr/bin:/bin`);dsh 主进程由 orchestrator spawn,PATH 取自 root 环境,不受用户影响。
**唯一的缝 = Python 的 user-site**(2026-09-11 实测):
`pip install --user` 一旦创建 `$HOME/.local/lib/python3.12/site-packages`,它就会进入 `sys.path`
且**排在平台 `site-packages` 之前** → 用户装的同名包**盖住平台包**(实测 `import packaging` 拿到用户版 26.3)。
**修法(两条限制性 env,官方机制、互不冲突,已注入 `baseEnv` + 放行 `ALLOWED_ENV`)**:
```
PYTHONNOUSERSITE=1 # user-site 不进 sys.path
PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install # pip --user 写只读位 → 明确报错
```
- 实测:`sys.path` 中无 user-site;`pip --user` 报
`Can not perform a '--user' install. User site-packages are disabled for this Python.`;
历史污染失效(平台包不再被盖);**`--target` + `PYTHONPATH` 仍可用**(这是给用户的推荐路径)。
- ⚠️ 这两条虽是"注入 env"(R5 触发项),但**方向是收窄**(限制用户侧安装)→ 可直接做,档案留痕即可。
**版本漂移巡检**:`scripts/runtime-baseline.cjs`(`--accept` 写基线;默认比对,漂移则退出码 1),
cron **每天 05:10** 跑;基线存 `/opt/dsh/state/runtime-baseline.json`(当前 = python3 3.12.14 /
pip 26.2.1 / node 22.23.2 / npm 10.9.8)。**升级运行时后必须 `--accept` 更新基线**,否则会一直告警。
### 📦 实例共享工具(jq / ripgrep / ffmpeg,档案 46)
**核心答案:用户要工具/程序包时,`不需要`每人装一份** —— 宿主装一次全员共享。依据:
实例内 `/usr` 是 **ro-bind** 且 `/usr/local/bin` 在实例 PATH **首位** → 宿主安装**全部实例(含新用户)
立即可见**,零代码、无需重启;而用户侧本来就装不上(`/usr` 只读 + 无 sudo)。
- **一键安装**:`bash scripts/install-shared-tools.sh`(幂等 / 固定版本 / **官方 sha256 校验** / `--force`)
当前已装:`jq 1.8.2`、`ripgrep 15.2.0`(musl 静态)、`ffmpeg`+`ffprobe`(BtbN 静态构建,自带全部编解码库)。
- **新增共享工具的标准动作**:优先找**静态单文件**发行版 → 放 `/usr/local/dsh-runtime/bin/` +
软链到 `/usr/local/bin/` → 更新 `/usr/local/dsh-runtime/SHARED-TOOLS.md` →
跑 `node scripts/runtime-baseline.cjs --accept` 刷新版本基线。
- **迁移单元**:整个 **`/usr/local/dsh-runtime/`** 一个目录(Python + 这些工具都在里面)→
`tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime`,目标机解压回原位 + 重跑两个安装脚本。
- ⚠️ **版本号提取别用通用规则**:ffmpeg 的版本形如 `N-126492-gefb0a7e5e7`(**不含 `x.y`**),
基线脚本对它单独用 `ffmpeg version (\S+)`。
- ⚠️ **Playwright 仍未做**:它不是单文件,除浏览器二进制外缺 **11 个系统 `.so`**(`libnss3`/`libgbm`/
`libatk`/`libcups`/`libdrm`/`libxkbcommon`…)。系统库可 `dnf install`(宿主装一次同样全员可见);
浏览器二进制建议共享位 + `PLAYWRIGHT_BROWSERS_PATH`,但**注入该 env 属 R5「给实例注入新 env」= 扩大**,
须先出「权限影响评估」并取得确认。
- 🖥️ **admin 可视化**:门户 **`#/runtime`「运行环境」**页(档案 47)+ `GET /api/admin/runtime`
(admin 只读)—— 列名称/版本/**来源**/**安装脚本**/可否卸载 + **版本漂移告警**(对比基线)+
目录/体积/最近变更 + 折叠的「升级 / 卸载 / 迁移怎么做」。
**术语澄清**:「宿主」= **服务器本身**(不是某个用户);安装由**平台以 root** 执行,用户侧装不进 `/usr`。
- pip 落点:默认装 runtime 的 site-packages(**只读被挡**);**推荐 `--target <ws>/.pylibs` + `PYTHONPATH`**(实测可用)。
- ⚠️ 宿主 `/usr/local/bin/python3` 现在是 3.12 → 已核实宿主**无任何东西依赖裸 `python3`**
(dnf/yum 绝对路径;BT-Panel 用自带 pyenv 绝对路径)。
- ⚠️ **平台自身 bug(待治本)**:`business-plugins.ts` / `ensure-biz-plugins.cjs` 以 **root** 跑 `pnpm`
且 `HOME=<userRoot>/ws` → 在用户工作区留下 **root 属主的 `.local/`** → 用户 `pip install --user`
报 `Permission denied`(自己的目录里装不了包)。**不能简单改 HOME**(要与实例共用 pnpm store,
否则 `ERR_PNPM_UNEXPECTED_STORE`)→ 应在 pnpm 流程后把 `ws` 下的 root 属主项 chown 回用户,
或放进 `ws-cleanup.cjs` 做自愈。
- 🔐 **本环境出网走 TLS 中间人代理 → Python 脚本必须显式指定 CA**(2026-09-11 guest 会话实证)。
服务器上有**两份 CA bundle**:
- `/etc/pki/tls/certs/ca-bundle.crt` —— **含代理 CA**(**curl 默认读这份**,所以 curl 一直正常)
- `/etc/ssl/certs/ca-bundle.crt` —— 系统原始,**不含代理 CA**(**Python 默认读这份**)
→ 在实例里用 Python 抓 HTTPS 会报 `URLError(SSLCertVerificationError: …)` 或
`unable to get local issuer certificate`。**正解(零平台改动,就是 A 方案)**:
```bash
SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt python3 your_script.py
```
```python
# 或写进脚本(推荐,免每次手敲)
import os; os.environ.setdefault("SSL_CERT_FILE", "/etc/pki/tls/certs/ca-bundle.crt")
# requests 亦可用 REQUESTS_CA_BUNDLE 指向同一文件
```
**写技能/脚本时把这一句固化进去** —— 这是**环境特性,不是用户错误**;不固化就会反复踩
"某个工具莫名连不上网"。**不要**改成平台注入 env:那属 **R5「给实例注入新 env」= 扩大**,
而一行代码就能解决。
(相关:`/etc/ssl` 已加入实例 `/etc` 白名单,否则连系统 CA 都读不到 —— 档案 49 事后修正 2。)
**跨用户共享本地安装 → 一律走宿主**:
| 方式 | 改动 | 评价 |
|---|---|---|
| **宿主 `/usr/local` 或 `/usr/bin`**(推荐)| **零代码**,`/usr` 已 ro-bind | 装一次全员生效、新用户自动。全局单版本 |
| `<dataRoot>/shared-*` + `--ro-bind` + env 白名单 | 改 `orchestrator.ts` + `spawn.ts` ALLOWED_ENV | 与 bundled-skills 同构,**必须先修 bind** |
| 让用户自己 `pip/npm install` | — | ❌ 用户侧**装不上**(`/usr` 只读 + 无 sudo),装上了也是 N 份重复且污染工作区 |
### 资源与网络边界(改默认值/做容量规划前必看)
- **宿主 1870MB / 2 vCPU / 40G 单分区,`quotaon /` 未启用 = 无磁盘配额** → 单租户可写满打瘫全平台。
- 每实例内存 **`MemoryHigh=448M` → `MemoryMax=1024M`**(**与插件集合无关**;见档案 96)+ `CPUQuota=150%` + `TasksMax=128` → **并发实例上限约 2–3 个**(宿主 1870MB)。
- **bwrap 只 `--unshare-pid`,不 `--unshare-net`** → 实例与宿主**共享网络命名空间**。**已在档案 39 缓解**:nft **H5** 封锁实例**主动**访问 `127.0.0.0/8` + eth0 + docker0 + 公网 EIP(写法与坑见上「H5 红线」)。⚠️ 残留:实例仍可 `bind 0.0.0.0`(入站方向未限制)。
- 出网护栏 `table ip dsh_egress`(`/etc/nftables-dsh-egress.nft`,一键复现 `scripts/install-egress-guard.sh`):**H1** `reject` 云元数据 `100.100.100.200`;**H5** 封锁实例主动访问宿主自身;其余新建外联只 `log prefix "dsh-egress" level info` **不拦截**。DNS = 阿里云内网 `100.100.2.136/138`(**绝不可封 `100.100.0.0/16`**)。
- `HOME=<userRoot>/ws`(不是 `home/`)→ pip/npm 缓存全落在**用户工作区**里(`ws/.npm`、`ws/.cache/pip`),会被门户 `#/files` 展示、也可能被 ws-cleanup 触碰。
### 只读诊断配方(不改用户目录、不起实例)
```bash
B=/usr/bin/bwrap; R=/var/lib/dshs/users/<UID>
# 把 diag 脚本 ro-bind 进命名空间,避免往用户 tmp 写文件
$B --ro-bind /usr /usr --ro-bind /lib64 /lib64 \
--symlink usr/bin /bin --symlink usr/sbin /sbin --symlink usr/lib /lib \
--tmpfs /etc --dev /dev --proc /proc \
--bind $R/tmp /tmp --bind $R $R --ro-bind /tmp/diag.sh /tmp/diag.sh \
--unshare-pid --chdir $R/ws \
-- setpriv --reuid <uid> --regid <uid> --clear-groups /bin/sh /tmp/diag.sh
# 权威 bwrap 参数不要读源码猜:直接看失败/运行中的 scope
systemctl list-units "dsh-*" --no-pager --all | grep scope
```
> ⚠️ **`pgrep -f "dsh --profile web"` 会匹配到你自己的命令行**(命令串里含该模式)→ 拿到假 PID。改用 `ps -eo pid,user,cmd | grep "^ *[0-9]\+ .*node /usr/local/bin/dsh"` 或直接读 `systemctl list-units "dsh-*"`。
### 📋 实例可访问路径清单(权威版 · 2026-09-11 档案 39 收窄后)
**只有一栏是"该用户的数据",其余都是"公共软件"或"平台自己"——写文档/答用户时按此表口径。**
| 能读到 | 权限 | 说明 |
|---|---|---|
| `<dataRoot>/users/<该用户id>/` | **读写** | **唯一属于该用户的数据**:`home/`(= `DSH_HOME`:profiles/sessions/settings/storages/`skills/`(**已启用**)/ `skills-library/`(**已禁用的自建技能**,档案 41)/ `.credentials.yaml`)、`ws/`(= `HOME`,默认工作区,pip/npm 缓存也在这)、`tmp/`(该用户的 `/tmp`,1777)、`patches/*.yml`、`handoff.json` |
| `/usr` | 只读 | 公共软件层(node/dsh/npm/pnpm + `/usr/bin` 1151 个 CLI + `/usr/lib64`)。**审计证实零凭据、零用户数据** |
| `/lib64` | 只读 | 系统共享库(node 动态依赖 7 个 `.so`) |
| `/etc`(**白名单 15 项**) | 只读 | `ld.so.cache` `ld.so.conf` `passwd` `group` `nsswitch.conf` `hosts` `resolv.conf` `host.conf` `services` `localtime` `os-release` `machine-id` `pki/tls/certs` `pki/ca-trust` **`alternatives`**;**其余 `/etc` = 空 tmpfs(可写、临时、退出即消)** |
| `/dev`、`/proc` | — | 运行必需;`--unshare-pid` → `/proc` 只见本命名空间进程 |
| `/bin` `/sbin` `/lib` | 符号链接 | → `usr/bin` `usr/sbin` `usr/lib`(补齐标准布局,避免沙箱探针 execvp 失败) |
| `<dataRoot>/bundled-skills/` | **只读** | 平台共享技能层(档案 40 起已挂载)。**内容对每个登录用户可读** → 禁止放内部文档/私有提示词/凭据 |
| 读不到 | 依据 |
|---|---|
| `<dataRoot>/users/<其他用户id>/` | bwrap 未挂载 + uid 隔离(实测只列自己那 1 项) |
| `<dataRoot>/dshs.db` | 未挂载(实测 No such file) |
| `/etc` 白名单外**全部**(systemd 单元 / nft 护栏 / cron / letsencrypt / ssh / firewalld / selinux / 576 个文件) | 档案 39 `/etc` 白名单 |
| `/root`、`/home/*`、`/opt`、`/var`(自己路径除外)、宿主 `/tmp` | bwrap 未挂载 |
| 宿主 `127.0.0.1:*`、eth0、docker0、公网 EIP | nft **H5**(实测 ECONNREFUSED) |
| 云元数据 `100.100.100.200` | nft **H1** |
**仍然可达(已知残留)**:公网(pip/npm/API/LLM)、阿里云内网 DNS `100.100.2.136/138`、
`bind 0.0.0.0`(**入站方向未限制**,跨租户互通靠 H5 的发送侧拦截)、同实例命名空间内 `setpriv` 后的自有进程。
**技能的投放通道(2026-09-11 实测)**:
| 通道 | 路径 | 谁可改 | 备注 |
|---|---|---|---|
| preset 自带 | `<dsh>/node_modules/@deepseek-ai/dsh-agent-presets/presets/<preset>/skills/` | **不可** | dsh 只内置 **2 个**(`cordis-plugin-development`、`editing-cordis-compositions`,都在 `cordis` preset 里)。**改它 = 改官方包(R2 红线)** |
| bundled 共享层 | `$DSH_BUNDLED_SKILL_DIR`(rank **600**,全员只读) | 平台 | ⚠️ **当前断的**:见下 |
| 用户层 | `$DSH_HOME/skills`(rank **400**,每用户独立,watch 即时生效) | 用户 | 平台"用户级启停"应走这层 |
> ✅ **bundled 层已修复(档案 40,commit `37e016f`)**:bwrap args 在 **`--bind root root` 之后**追加
> `'--ro-bind-try', bundledSkillDir, bundledSkillDir`(仅当 `config.bundledSkillDir !== ''`)。
> 实测:实例内 `ls $DSH_BUNDLED_SKILL_DIR` 可见、`readFileSync(SKILL.md)` = OK(= 发现条件成立)、
> `mountinfo` 为精确叶子 `ro,nosuid,nodev`、`touch` = Read-only;**可见面未扩大**(`ls users/` 仍 1 项、
> DB 不可读、`/etc` 仍 13)。⚠️ 此前"只注入 env 就以为配好了"是错的 —— **skill-filesystem 在实例内读盘,
> env 只决定"去哪儿找",目录不挂载就是找不到**(通用判据:插件在实例内 `resolve()` + 读盘的东西,
> 必须真的出现在命名空间里)。
> ⚠️ **宿主 `<dataRoot>/bundled-skills` 目前仍为空** —— 机制通了,但**尚未投放任何技能**。
> 🔬 **修法安全性已实测(2026-09-11,guest uid 100002 实跑)**:加该行后 `mountinfo` = `…/bundled-skills → 同名 ro,nosuid,nodev`(**精确叶子路径 + 只读**);`ls …/users/` **只列用户自己那一个**(看不到其他用户);读 DB = **No such file**;`touch` 挂载点 = **Read-only file system**。**对照组(不加)= `/var/lib/dshs/` 里只有 `users`** → 支持面零变化,且**父目录本来就是 bwrap 为用户 root 合成的**(今天线上已可见该路径名,`users/` 是只含自己的空壳),**不是这一行新暴露的**。
> ⛔ **红线:只能挂最内层路径**。写成 `--ro-bind /var/lib/dshs /var/lib/dshs`(父目录)= **一次性把全部用户 home + DB + 凭据放进每个实例**。这是本改动唯一的真实风险,且属手滑型错误 —— 改完必须**逐字复核 bwrap args**,并跑一次上面的可见面实测。
> ⚠️ **副作用(设计意图,但要说清)**:挂上后 `bundled-skills/` 里的内容**对每个登录用户可读** → 该目录**禁止放内部文档 / 私有提示词 / 凭据**;投放清单必须按"全员可见"审一遍。
> ⚠️ **dsh 技能没有"禁用"机制**(`skill-filesystem` 无 disable/deny)→ ranked 600 的共享层**无法 per-user 关闭**,只适合"人人必须有的基线技能";要让用户可启停,必须用 rank 400 的用户层(复制进/删掉)。
**平台侧 API ↔ 技能层 的映射(2026-09-11 核实 `src/web/routes/skills.ts`;档案 41 后已扩充)**:
| API | 鉴权 | 落点 | 层 | 说明 |
|---|---|---|---|---|
| `GET/POST/DELETE /api/skills/shared`(+`/apply`) | **`requireAdmin`** | `<dataRoot>/bundled-skills` | bundled **600** 共享只读 | ✅ 档案 40 起已挂载可发现;对用户 **`locked:true`(不可禁用/删除)** |
| `GET/POST /api/skills/mine`(+`/apply`) | `requireAuth`(任意登录用户) | `<userRoot>/home/skills` | 用户 **400** 独立 | ✅ 用户自建技能;`GET` 每项带 `source`/`enabled`/`locked` |
| `POST /api/skills/mine/:name/enable` \| `/disable` | `requireAuth` | 启用 ↔ `<userRoot>/home/skills-library/` | 用户 **400** | **dsh 无 disable 机制** → 「禁用」= `rename` 出扫描根(**保留文件**,可再启用)。watch 即时生效,**无需重启** |
| `DELETE /api/skills/mine/:name` | `requireAuth` | 两处同删 | — | 彻底移除用户自建技能 |
| 同名守卫 | — | — | — | 与共享技能同名 → 上传/启用/禁用/删除**一律 409**(否则用户白传一份永远被 rank 600 压住、又关不掉的技能) |
> ⚠️ **投放前必须平铺 —— `discoverRoot` 只扫 1 层**:带 `subskills/` 的技能(实证 `短视频工作台/subskills/{mcn-data-insight,mcn-dou-analysis,…}`)**子技能不会被发现**。投放时把 `subskills/*` 提升为顶层,或每层各投一个技能。`references/`、`scripts/` 是技能内部相对路径引用,**不受影响、别动**。
> ⚠️ **`scripts/` 的可用性由"宿主基线"决定**(技能脚本经 bash 执行 → 受只读 `/usr` 约束):宿主缺 `python3.9+`/`ffmpeg`/`rg`/`jq`/浏览器时,脚本写出来也跑不动。**投放前先核对宿主基线**,别在 SKILL.md 里写"请先装 X"(用户侧装不上)。
> ⚠️ **安全:分清"谁在执行"**(档案 41 定稿)——
> · **运行**技能 `scripts/`:走 agent 的 bash 管线,困在用户沙箱里(uid+bwrap+cgroup+`/etc` 白名单+H5)→ **自伤,可接受**,**不该以"用户能跑任意代码"为由禁止**;
> · **上传/解压**技能包:**平台以 root 执行** → **这才是平台级风险点**(见下「技能上传安全」)。
> 注意档案 33 后默认 `danger-full-access` + `approval: never` → 技能脚本**不再弹确认**。
### 技能上传安全(档案 41 · 该类漏洞的通用判据)
**P0 实测复现的根因:`zip` 的成员可以是符号链接,而 `unzip` 默认原样恢复它。**
symlink 的**目标写在 zip 元数据里、不在任何文件内容中** → `scanDir`(只读文件内容,且遇 `!st.isFile()` 直接 `continue`)**天然看不到**;成员名校验(拒绝对路径 / `..`)也**放行**。
随后 `applyStaged` 以 **root** 执行 `chownTree`/`chmodTree`,二者**跟随**符号链接 → 改写**链接目标**(宿主任意文件)的属主与权限。实测 `-rw------- root:root` → **`-rw-r--r-- <租户uid>`**;指向 `/etc/shadow` = 密码哈希 chmod 644 全机可读 + chown 给该租户。
攻击面 = **传了 `owner` 的那条路由**(`mine`;`shared` 不传 owner,不受影响)。
**必须同时覆盖两个维度**:① **文件内容**(现有 `BLOCK_PATTERNS` P0 阻断 / `WARN_RULES` P1 告警 —— `scanDir` 内部**会 throw 400**,不是只告警);② **归档元数据与文件类型**(symlink / 硬链 / 设备 / FIFO + 解压规模)。
**三项加固(已落地)**:
1. `findSpecialEntry()` —— 解压后**立刻**拒绝任何非普通文件/目录条目(在任何 `chown/chmod` 之前);
2. `sumUncompressedSize()`(解析 `unzip -l`)+ `MAX_SKILL_FILES=2000` + `MAX_SKILL_UNCOMPRESSED_BYTES=200MB`
—— `UPLOAD_BODY_LIMIT=180MB` 只限**压缩体**,而宿主 `quotaon /` 未启用 = **无磁盘配额** → zip bomb 可跨租户 DoS;
3. 纵深防御:`chownTree` 用 **`lchownSync`** 且跳过 symlink;`chmodTree` 跳过 symlink(**Linux 无 `fs.lchmod`**,ENOSYS)。
> 更彻底(未做):以目标 uid **降权解压**(`setpriv --reuid <uid> ... unzip`),让整条链路都不在 root 下。
**技能的依赖:没有机制(最大缺口)**。`dsh-skill-filesystem` **不处理任何 install/deps/package.json** —— 技能包只能靠 `SKILL.md` 写"请先装 X",由 agent 运行时手敲。对比插件:`package.json.dependencies` + `pnpm add` 自动解析(store 硬链复用)。约束:实例内 `/usr` **只读** → 系统级装不上;只能 `--user`/本地;无共享、每用户各装一份。
→ **平台若要多用户投放带依赖的技能,必须在启用时"代装"**:Node 依赖装到该技能目录(或用户 ws 下统一 `node_modules` + `NODE_PATH`);Python 依赖用**用户级 venv**(`$DSH_HOME/.venvs/<skill>`)并让脚本用该解释器。**绝不让 agent 临时 pip/npm install**(只读 `/usr` + 供应链 + 不可复现)。
## 技能 vs 插件:怎么区分(2026-09-11 更正,别用"有没有代码/界面"判)
**先纠正一个常见误解**:技能**可以带 `scripts/`**(实证:`短视频工作台/scripts/MCN_CYLG_API.py`、`mcp-config.json`)。所以「技能=纯内容无代码」「不带界面的插件就是技能」**都不成立**。
**正确判据 = 「代码何时、被谁执行」**:
| | 技能(skills) | dsh 插件(plugins) |
|---|---|---|
| 包形态 | 目录 + `SKILL.md`(+ `scripts/`、`references/`) | npm 包 + **`dsh.bundle.patch`**(+ 可选 `client.js`) |
| **代码执行者** | **agent 通过 bash 工具按需调用**(SKILL.md 指示 → 模型决定) | **cordis 在实例启动时加载执行**(无人决定) |
| 是否经审批/沙箱 | ✅ 走统一工具管线(approval + 沙箱 + uid/cgroup) | ❌ **不经**,它自己就是实例进程的一部分 |
| 参与框架生命周期 | 否(纯文件) | 是(注册服务/工具/UI,进 `profile.bundles`) |
| 装载时机 | 运行时按需发现,**watch 即时生效** | **启动时**,改后必须重启 |
| 失败后果 | 脚本报错 / agent 表现不佳 | **实例起不来(崩溃循环)** |
| 透明度 | 命令进会话记录,可追溯 | 静默运行 |
**可编程判据(我们代码里已在用)**:看包里有没有 **`dsh.bundle`**(`isBundle()` 判 `dsh.bundle.patch`)→ 有=插件,无=技能。**有无 client 面(UI)只是插件的可选属性**,与"是不是技能"无关。
**风险分层(理由要准)**:技能低风险靠三条——**惰性**(不调用不执行)+ **可见**(命令在 transcript)+ **失败不致命**;插件高风险的核心理由是 **开机即执行、无人介入、失败致命**。
> ⚠️ 注意:档案 33 把默认档改成 `danger-full-access` + `approval: never` 后,**技能脚本也不再弹确认**(仍受沙箱/uid/cgroup 约束),两条路线的"人工拦截面"差距因此缩小——分层的主要依据从"审批"转为"是否被框架主动加载 + 失败是否致命"。
## 术语与环境约定(2026-09-11 档案 38b)
- **术语:统一叫「功能插件」**(2026-09-11 起由「业务插件」改名)。**只改显示文案/注释,代码标识符保持不变**:表名 `business_plugins`、包名 `@dsh-local/business-plugins`、section id `business-plugins`、API 路径 `/api/plugins/{business,mine}`、文件名 `business-plugins.ts` / `ensure-biz-plugins.cjs`、目录 `poc/business-plugins/`。
- **`ensure-biz-plugins.cjs` 已是「版本感知 + 自动取最新产物」**:升级流程 = 把新包丢进 `/opt/dsh/artifacts/` → 跑一次脚本(落后会自动 `pnpm add`)。**升级时不要先删旧包**,否则依赖表里的旧 `file:` 指向失效文件会让 `pnpm` 整体 ENOENT 失败(正确顺序:先放新包 + 改依赖指向 → `pnpm add` → 再删旧包)。
- **`.gitignore` 已忽略 `*.bak-*`**:`git add <dir>` 会把同目录的未跟踪备份一并暂存(已踩一次)。提交前用 `git status --short` 核对,或定向 `git add <具体文件>`。
- **服务器 Python 是 3.6.8,不要动系统 `python3`**:`/usr/libexec/platform-python` 是 `dnf`/`yum` 的 shebang,替换/升级会直接搞坏包管理器。**运维脚本一律用 Node**(平台技术栈就是 Node);必须用 Python 时写 3.6 兼容代码(无 `subprocess.run(capture_output=)`、无 f-string `=` 调试等 3.7+ 特性)。确需现代 Python 就**并行装 `python3.11`**(仓库有),别切 alternatives。
## 功能插件启用:探活 · 快照回滚 · 逐插件隔离(档案 34/35,2026-09-11)
**原缺陷**:`/api/plugins/mine/apply` 在 `pnpm add → reconcile → restartMain` 之后**直接标 success,全程无探活** → 插件搞崩实例(崩溃循环)时任务仍报「已应用」。
**正确流程**:快照 → 禁用项直接 remove(不引入新代码,永远安全)→ 启用项**先整批试一次** → 探活 → 失败才回滚 + **逐插件隔离**(能起来的保留、起不来的单独摘掉并记 `plugin_incident` 审计)。
**探活的两个必踩坑(都靠实测发现)**:
1. **不能只判 `restartMain` 的返回值** —— 编排器里没有该用户实例时(portal 重启清了内存态、或实例被空闲回收)它返回 `undefined`,会把**无辜插件**误判为「不兼容」。→ 无实例时先按 `/api/dsh/enter` 同路径 `launch` 一个再探。
2. **固定短等待会把好插件判死** —— 初版「固定 2.5s 稳定窗口」实测好插件也报「未产出 launch token」。→ 改为**轮询**(500ms 间隔 / 总预算 60s):拿到 `launchToken` 且 `status==='running'` 后,再过 2.5s 确认没闪崩才算通过。
3. 探活失败要**回传 dsh 的真实错误**(如 `duplicate loader entry id` / `ERR_MODULE_NOT_FOUND`),不要泛化 —— 这是 admin 判断"这个插件为什么坏"的唯一线索。
**坑:平台 bundle 包不能被 ws 清理删掉**(档案 35 P0)。`ws-cleanup` 的 T1 会无条件删 ws 顶层 `.tgz`,而三个平台 bundle 是以 `file:<ws>/*.tgz` 安装的 → 删掉后 profile 的 `dependencies` 指向不存在文件 → **任何 pnpm 操作 ENOENT 失败**(启用功能插件必然失败)。**且极隐蔽:`node_modules` 已装好,实例照常运行,只在下次 pnpm 操作时爆发。** 修法:清理前读每个用户**所有 profile 的 `file:` 依赖**,被引用的文件一律豁免(别硬编码文件名)。平台 bundle 的可重建源码在 `/opt/dsh/docs/04-调整方案/poc/`,产物存 `/opt/dsh/artifacts/`。
**造测试 fixture 的坑**:bundle 的 `package.json.name` **必须与 `cordis.patch.yml` 里 `insert` 的 `name:` 一致**,否则 `ERR_MODULE_NOT_FOUND`;且打包要用 `package/` 前缀的 npm-pack 布局(`tar czf x.tgz package`),平铺布局 pnpm 可能不接受。
**平台 bundle 的铺装(档案 36)**:`scripts/ensure-biz-plugins.cjs` —— 幂等,判定**看 `dsh.profile.bundles` 不看 `dependencies`**(包内 `cordis.patch.yml` 只对 bundles 成员生效;有 dep 没 bundles = 等于没装)。流程:复制产物 tgz 到用户 ws(chown)→ `pnpm add file:` → 对齐 dsh plugin add 的 reconcile。**新用户自动铺 = 审批时 detached `spawn`(fire-and-forget)+ cron 巡检兜底。**
**坑:`pnpm-workspace.yaml` 会让 pnpm 拒绝 `add`**。dsh 会给某些 profile 生成 `pnpm-workspace.yaml`(`packages: [.]`)→ pnpm 视为 workspace root → `ERR_PNPM_ADDING_TO_ROOT`。→ **所有 `pnpm add` / `pnpm remove` 都要带 `--ignore-workspace-root-check`**(有无该文件都工作)。
**坑:判断「装没装」**永远看 `dsh.profile.bundles`,不要看 `dependencies` —— 两者可以不一致,而不一致时组件是**没生效**的。
**坑:Node 22 的 `execFile` 类型**:`execFile(file, args, { stdio:'ignore' }, cb)` 在本项目 TS 版本报「'stdio' does not exist in type ExecFileOptions」。要做 fire-and-forget 用 `spawn(..., { stdio:'ignore', detached:true }).unref()`。
## 会话记录取证(分析某工作区/用户的真实对话,2026-09-11 定型)
用户常提「看看 XX 工作空间下的对话记录,能发现哪些问题」。**这是只读任务,可与其他只读任务并行**,且**适合丢给子代理**(大文件长分析,别吃主上下文)。
**路径规律**(`<UID>` = 用户 id,admin = `cce6d1cd-b376-4304-80f0-0e1c58c9ffde`):
```
U=/var/lib/dshs/users/<UID>
$U/home/sessions/--<cwd 路径把 / 换成 ->--/session-<uuid>/session.jsonl.zstd
$U/home/storages/workspace.json # 工作区注册表:id → { path, title, sessionIds }
```
例:`--var-lib-dshs-users-<UID>-ws-mcntimo--` 对应工作区 `$U/ws/mcntimo`。
**坑:文件是「追加式多帧 zstd」,不是普通 zstd。**
- `zlib.zstdDecompressSync` / `createZstdDecompress` 只解第一帧 → 只得 240 字节的 session 头,然后报 `Unknown frame descriptor`。
- **必须用 zstd CLI**(服务器默认没装):`yum install -y zstd`(al8 官方源 `zstd-1.5.1-2.0.2.al8`,安全,已获用户授权)。然后 `zstd -dc <src> > /tmp/s.jsonl` —— 1.6 MB 明文正常解出。
- 用户点过 `/export`(`command/done` → `Session log download requested.`)拿到的是**这种多帧 zstd**,**用户根本打不开** —— 属平台缺陷(档案 36 P1-6)。
**分析脚本写法(2026-09-11 踩坑后定型)**
```bash
# ❌ 别用:ssh 'node -e "..."' —— 内层引号转义在单引号 ssh 命令里必坏
# ✅ 一律:本地写 → scp → 远端执行
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
S="C:/Users/Administrator/AppData/Local/Temp/wb-scratch"
scp -i ~/.ssh/id_ed25519_dsh -P 22 "$S/an.cjs" [email protected]:/tmp/an.cjs
ssh -i ~/.ssh/id_ed25519_dsh -p 22 [email protected] 'node /tmp/an.cjs /tmp/g.jsonl users'
```
- ⚠️ **`.cjs` 后缀是必须的**:服务器 `/tmp/package.json` 含 `"type":"module"` → `/tmp/*.js` 被当 ESM,`require is not defined in ES module scope`(本次踩坑)。
- 脚本按 `MODE` 分支输出(`users` / `errors` / `tools` / `assistant` / `seq`),**一次写好复用**,避免反复 scp。
- `type` 分布里 `request/header` 每条含完整 system prompt + 工具表(占明文相当大比例)→ **务必按 `type` 过滤**,别全量 dump。
**JSONL 结构**:每行一条 JSON。第 1 行 `{"type":"session", cwd, agentPreset, ...}`;其余 `type` ∈ `request/header`(含完整 system prompt + 工具表)、`assistant/chunk`、`assistant/message`(含 `reasoning` + `tool-call`)、`tool/result`、`turn/end` 等。工具结果里能直接看到真报错原文。
**分析要点**(产出「问题清单」,P0/P1/P2 + 行号 + ≤60 字原文片段):
① 工具报错/重试/超时/权限被拒(**沙箱限制是高频根因**)② 用户挫败信号(反复纠正、重复要求、放弃)③ **平台侧缺陷**(菜单无反应、报错不可读、路径写死、工作区为空、会话中断)④ 卡点与无谓的工具调用 ⑤ **安全**:凭据明文(只报「第 N 行疑似凭据,类型 X」,**绝不抄值**)、越权尝试。
**额外可用信号**:报错在会话中的**位置分布**(`grep -n`)能区分「已修」还是「一直在发生」。
**必须先查「是不是已知问题」再报(2026-09-11 定型)**:动手前先 `ls /opt/dsh/docs/04-调整方案/` + 读同类旧档案(如 21 卡顿 / 23 环境限制 / 32 上次取证),**新发现要明确标注「新增」还是「修正/补充旧档案」**,否则会把已归档的结论当新问题重复报。
**产出后的闭环(别只输出清单)**:① 落 `04-调整方案/<NN>-<主题>.md`(含「修正旧档案的哪条结论」小节);② `INDEX.md` §二 追加 `04-<NN>` 行并更新状态摘要(**README 的档案清单已定格为历史对照,别只改 README**);③ `INDEX.md §六.1` 的「下一号」+1、`03-路线图与待办.md` 补登记;④ 收尾四件套:`python scripts/docs-audit.py`(**退出码 0**)→ `python scripts/docs-manifest.py`(刷新机读清单)→ `bash scripts/docs-sync-check.sh`(全绿)→ `MINE="<我改的文件>" PUSH=1 bash scripts/handoff-guard.sh`(幽灵文件硬判定)。
## 多任务并行调度协议(2026-09-11 用户提出,长期沿用)
**核心结论**:**可以并行,但并行度由「冲突域」决定,不由任务数量决定。** 只读/调研类可自由并行;改码 → 构建 → 重启 → 提交 → 写文档是**单通道**,必须串行。
### 冲突域清单(同域必串行,跨域才可并行)
| 冲突域 | 涉及资源 | 并行性 |
|---|---|---|
| 门户后端代码 | `/opt/dshs/src/**` → `npm run build` 全量重编译 `lib/` | ❌ 独占:一批只允许 1 个改码任务 |
| 服务重启 | `systemctl restart dshs` | ❌ 全局;重启会打断所有用户实例 |
| 前端页面 | `web/*.html`(同文件后写覆盖前写;marker-splice 会互相清块) | ❌ 同文件串行 |
| 代码仓库 | `git add` / `git commit`(index.lock) | ❌ 串行 |
| DB schema | `dshs.db` 建表/迁移 | ❌ 串行(普通读写事务短,可并行) |
| 文档 | `/opt/dsh/docs/**`、`docs-status/**`(服务器唯一源,单点文件) | ❌ 串行(后写覆盖) |
| 本地记忆 | `.workbuddy/memory/*.md`、`MEMORY.md` | ❌ 串行(并发 append 会丢更新) |
| 实例运行态 | 每用户 profile / 实例进程 | ⚠️ **跨用户可并行**;同用户串行 |
| 临时测试会话 | `sessions` 表 | ✅ 可并行,但 UA 必须带 worker 标识(`poc-<worker>`),清理只删自己的前缀 |
| 只读调研 | 读源码、看日志、`--dump-config`、外网抓取、兼容性测试 | ✅ 完全可并行 |
### 批次模型与依赖处理
1. **一批 = 1 个「改码通道」+ N 个「只读通道」**;改码任务之间永远排队串行。
2. 待办先建成 **DAG**,逐条标注「依赖」+「冲突域」;**无依赖且冲突域不同**才允许同批。
3. **收口在主会话**:并行通道产出汇总回主会话,统一写档案 → commit → `docs-status-sync.sh --pull`(这三步本身也是串行单通道)。
4. **用户侧多会话按「领域」切分**(A=门户后端 / B=文档梳理 / C=兼容性测试),**不要按「同一领域的不同切片」切** —— 后者必然同域冲突。
5. **开跑前先出「并行批次表」**(任务 / 冲突域 / 依赖 / 可否并行)给用户确认。
6. 我侧可用并行子代理(`Agent` + `run_in_background`)跑**只读通道**;**写操作必须回到主会话串行执行**。
### 反例(同域并发的具体破坏形态)
- 两会话同时 `npm run build` → `lib/` 产物交叉污染,`systemctl restart` 互相打断实例
- 两会话同时改 `portal.html` → 后写覆盖前写(marker 区块被对方整段替换)
- 并发 `git add/commit` → `index.lock` 冲突
- 同时写 `/opt/dsh/docs/**` 或 `MEMORY.md` → 后写覆盖 / 丢更新
- 用同一 UA 前缀批量删测试会话 → 误删另一个 worker 的会话
### 落地机制(2026-09-12 建立):三把锁 —— 别靠记忆,靠判据
冲突域判断是「设计」,还需要「执行时的强制判据」。当日 3 次实证事故后补上三把锁:
| 锁 | 位置 | 管什么 | 拿法 |
|---|---|---|---|
| **全局执行锁**(粗) | `交接单/.exec-lock` | 同一时刻只允许**一个执行会话**动「文档 / 代码 / 服务器」 | `bash scripts/handoff-guard.sh --claim-exec "<会话名>"` |
| **单级占用锁**(细) | `交接单/.doing-<单号>` | 这个单归谁做(供台账与接管) | `bash scripts/handoff-guard.sh --claim <单号> "<会话名>"` |
| **服务器侧操作锁** | `/opt/dsh/state/.op-lock/<操作名>`(跨机可见) | 谁正在动**生产**:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书 | `bash scripts/op-lock.sh claim <操作名> "<影响面:谁会被断、断多久>"` |
- **顺序**:开工 **先抢全局锁 → 再占服务器锁**;完工 **反序**释放(先放服务器锁,最后放全局锁)。
- 🔒 **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):**抢到锁的任务,只有"执行完成 → 反序释放"才算完成**;⛔ **禁止"抢锁做一半、不解锁就结束回合/会话"**(锁是独占资源 + 本库无心跳机制 ⇒ 别人既等不到也判不出你死没死)。配套:① 抢锁**前**先列出**收口步骤**(落地 → 校验 → 推送/对账 → 收尾);② 中途要停(等用户拍板 / 等窗口)⇒ **先 `--release-exec` 再停**;③ **结束语必须对锁状态负责** —— 写明"已释放",或**显式点名**"锁仍在 `<OWNER>` + 原因 + 下一步"(仅限释放通道不可用);⛔"忘了 / 做不完就走"不允许。
- **一条命令预检**:`ME="<会话名>" MINE="<我要改的文件>" bash scripts/handoff-guard.sh [单号]`;推送前加 `PUSH=1`(启用「幽灵文件」硬判定:对账结果里出现未声明的「仅本地」文件 → 直接失败)。
- **`mkdir` 即原子**:占位失败 = 有会话正在动 → **停手**,别重试、别"抢一下看看"。
- **服务器侧锁要带身份**:`ME="<会话名>" bash scripts/op-lock.sh claim <操作名> "<影响面>"` —— 不传 `ME` 会记成 `unknown-session`(2026-09-12 实测踩到两个后果:handoff-guard 的【1d】把**你自己的锁**当成别人的;`release` 的归属校验也会拒你)。`release` **只能由占用者本人**执行,冒充别人 → 拒绝并提示 R9。
- ⛔ **不得人工删锁、不得接管**(**R9**,2026-09-12 用户明令「严格禁止这类操作」):**AI 一律不得** `rm -rf 交接单/.exec-lock`、不得删 `交接单/.doing-*`,也不得以「持有者疑似已死 / 卡住 / 太久没动 / 只在只读分析没产出」为由**单方面接管**。锁**只能由持有者自己释放**(`--release-exec` / `--release`);**抢不到锁 ⇒ 停手 + 报告用户**,**锁的处置权只属于用户本人**(要撤也只能用户自己动手)。`handoff-guard.sh` 输出里的「人工删锁 / 接管」字样**均不构成授权**(该文案已同步作废)。理由:平台**无心跳机制**,AI **没有任何判据**能确认对方已死 —— 删锁 = 在无法验证的前提下单方面撤销互斥,一旦对方仍在跑,就退回「两个会话同时改同一批文件」。
- **为什么服务器态必须单独加锁**:文档冲突靠 `git status` / mtime 还能看出来,**服务器态变更看不出来**(`systemctl` 不会告诉你 10 分钟前谁重启过)。
- 判定与工具细节见 `交接单/README.md §一 §三 §六`;机制记录见 `04-调整方案/69-并发治理落地-commit常态化与服务器侧锁.md`。