Files
dsh_shenxian/dsh-server-docs/skills/dsh-change-workflow/SKILL.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

114 KiB
Raw Blame History


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 / ${ / 反引号会在模板求值时先被处理一次。   写 .join('\n')(单反斜杠)⇒ 求值后变裸换行 ⇒ 注入的 JS 直接 SyntaxError ⇒   整段脚本静默不执行(浮层 / 自愈 / 助手面板全废,页面只剩 dsh 自己的「连接异常」)。   → 用 String.fromCharCode(10);校验必须"先模板求值、再 node --check":   node scripts/verify-inject.cjs lib/supervisor/proxy.js(已接入 npm test,勿绕过)。   ⚠️ new Function(原文) 与「grep 页面 HTML 有没有标记」都是假绿 —— 前者跳过求值,后者验不出"跑不跑得起来"。 ② 触发面必须覆盖"页面开着不动":用户盯着页面时无任何事件;现已有 可见时 25 s 心跳   + EventSource / WebSocket 断流包装,且两者都连续两次失败才恢复(恢复=原地 replace,会丢未保存输入)。

🔴 浏览器自动化:只允许两件工具;Playwright 全面禁止(用户 2026-09-13 明令:「playwright 禁止使用,加到规则中」): ① agent-browser(WorkBuddy 内置技能,独立浏览器,不碰用户环境)—— 本项目默认走这条(2026-09-13 选定); ② browser-harness(CDP 9223,附着用户正在用的 Chrome)—— 只在需要看用户现场时用,   ⛔ 动手前必须先 list_tabs();旧事故:在其浏览器里登录测试号 → 覆盖了用户的 .alotbuy.com 的 sid。 ⛔ 禁止 Playwright / playwright-core(含「独立无头」用法)—— 本行更正此前把它写成「只读验收默认路径」的错   (那是 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-调整方案/

六阶段流程

阶段 0 · 需求识别(先盘点再动手,勿跳步)

  1. ⛔ 开工前置检查(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,别动浏览器。

  2. 环境盘点:本机/服务器/工具链/权限/DB/实例状态先行(历史教训:未盘点就默认本机装 Docker 走错路线,被用户批评)。

  3. 需求边界:区分"方案请求"(只输出方案,P0 硬规则:方案与执行分离,未经明确授权不改文件)与"任务明确直接执行"。

  4. 用户确认:关键分叉用选项表收敛(AskUserQuestion:推荐项置首标注 "(Recommended)",2-4 选项,不设"其他"占位)。

  5. 输出格式:一句话结论先行 + 表格化细节(状态/结果列)。

阶段 1 · 调研(源码级实证优先,禁止只靠文档推断)

  1. ★ 用户报障第一步:先拿「真实失败请求」,别猜(2026-09-11 档案 51 实证,省掉全部试错)。 用户只会说"报错/没反应/发消息失败",而平台 journal 里有精确的 URL + method + status:

    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…)——改的地方完全不同。
  2. 只读分析官方包(允许):/usr/local/lib/node_modules/@deepseek-ai/dsh/... 内 bundle 读代码定位机制(slot 声明、inject 契约、fiber 注入)。绝不写官方主程序。

  3. 服务器实测 > 推断:区分 fence 层 / 应用层错误;浏览器问题抓 console 完整 err 对象(description/堆栈,勿只看字符串字段——at new apply 类堆栈是定位关键)。

  4. 双端对照:archive 源 ↔ 实例安装副本 md5 对账;官方行为对照第三方插件写法差异逐字段对比(如 register 的 locale/children/label 函数形式)。

  5. 多模型交叉验证:关键结论交叉核对,不轻信单点推断。

阶段 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 直插临时 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/代理):

    # 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 实测可用)

    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 隔离):

    # ① 一次性启动(后台常驻;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
    
    # ③ 每次验证(把 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 · 归档清理(文档同步 + 三层沉淀)

  1. ★★ 交付门禁:本机改完 ≠ 交付(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 验"改对了没有"(三层验收);本门禁验"东西到用户那儿了没有"(链路完整性)。
  2. 文档落盘(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。
  3. 档案更新:04-调整方案/<NN>-*.md 写"需求→改动文件→commit→验证记录";README.md 档案清单行同步;版本号 bump。

    • ⚠️ 档案号必须『原子预留』,不能靠"读一眼 ls 再写"(2026-09-11 两次撞号实证):"取号 + 写档案 + 改 INDEX.md + 改 docs-status 同一串行事务"这条规则本身不够 —— 两条并行通道同时 ls 时都读不到对方的文件,必然撞。第二次撞号(37/38 各两条,4 个文件挤 2 个号)就是这么发生的。
      • 正确做法(原子锁):写档案前先原子占号,例如
        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,以它为准。
  4. 三层沉淀(用户习惯,必做):

    • 治本层:机制/流程固化进 skill/档案(本次即本 skill)
    • 失误层:教训 append 到 .workbuddy/memory/YYYY-MM-DD.md(append-only,不改写历史)
    • 记忆层:长期约定/红线写 .workbuddy/memory/MEMORY.md(≤3000 字符/会话)
  5. 汇总表:带状态/结果列交付,列明未完成项与副作用。排版按 dsh-feature-first §5.4(首屏给判定 / 层级≤3 / 每节≤7 行 / 表格≤5 列 / 一条信息只说一次;细节进附录)。

  6. 技能自身归档(本 skill 有改动时必做):工作副本在本机 .workbuddy/skills/<name>/SKILL.md(技能必须本地加载),服务器归档位在 /opt/dsh/docs/skills/<name>/SKILL.md(root 600)。改完本 skill 后单向推:

    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-调整方案/-<标题>.md)

# <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 直插临时 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(DB 直插,10 分钟,user_agent=poc-curl2);清理:DELETE WHERE user_agent='poc-curl2'
  • 存量会话档位体检(档案 36 P0-1,只读,建议定期跑):权限档位会话级播种(建会话时写 permission/preset + sandbox/mode + approval/policy,resume 不重播)→ 改默认档位只惠及新会话。查全平台还有多少会话是旧档位:
    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)。每次先前置:
    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 成对绑定,这是关键坑):
    '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 直接读它:

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 加
    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"的条目:
    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 方案):

    SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt python3 your_script.py
    
    # 或写进脚本(推荐,免每次手敲)
    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 触碰。

只读诊断配方(不改用户目录、不起实例)

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 踩坑后定型)

# ❌ 别用: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。