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 一律写「远程服务器」。
114 KiB
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:16760*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-restart69 次(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)。凡"页面内自愈"类注入脚本, 只 hookfetch/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 · 需求识别(先盘点再动手,勿跳步)
-
⛔ 开工前置检查(2026-09-11 用户点名要求,血的教训):这是本技能最重要的一步。 病根 = 不先查「已经有什么」就凭直觉装工具/猜机制;出错后要用户追问,才把早就写在文档里的说明翻出来 (最刺眼的一次:我早在技能里写了「勿走 agent-browser」,自己却忘了,绕最远的路)。
任何「装工具 / 写自动化 / 查机制」之前,按序做完这三条:
- 先看可用技能列表(会话开始就在)→ 有对应的立刻加载技能,禁止另起炉灶;
- 先看本机/项目已有资产:
~/.workbuddy/skills/、项目.workbuddy/skills/(尤其本技能)、MEMORY.md; - 要装任何东西前自问:本机已有的能否满足? 能→不装;不能→先说清楚再装。
→ 固化结论(别再重新发现):浏览器自动化只允许两件 ——
agent-browser(默认,独立浏览器) 与browser-harness(看用户现场用,先list_tabs());⛔ 禁止 Playwright /playwright-core(2026-09-13 用户明令); 静态页与 API 取数用curl/WebFetch,别动浏览器。 -
环境盘点:本机/服务器/工具链/权限/DB/实例状态先行(历史教训:未盘点就默认本机装 Docker 走错路线,被用户批评)。
-
需求边界:区分"方案请求"(只输出方案,P0 硬规则:方案与执行分离,未经明确授权不改文件)与"任务明确直接执行"。
-
用户确认:关键分叉用选项表收敛(AskUserQuestion:推荐项置首标注 "(Recommended)",2-4 选项,不设"其他"占位)。
-
输出格式:一句话结论先行 + 表格化细节(状态/结果列)。
阶段 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 (bodydsh web authentication required…)——改的地方完全不同。
-
只读分析官方包(允许):
/usr/local/lib/node_modules/@deepseek-ai/dsh/...内 bundle 读代码定位机制(slot 声明、inject 契约、fiber 注入)。绝不写官方主程序。 -
服务器实测 > 推断:区分 fence 层 / 应用层错误;浏览器问题抓 console 完整 err 对象(
description/堆栈,勿只看字符串字段——at new apply类堆栈是定位关键)。 -
双端对照:archive 源 ↔ 实例安装副本 md5 对账;官方行为对照第三方插件写法差异逐字段对比(如 register 的
locale/children/label函数形式)。 -
多模型交叉验证:关键结论交叉核对,不轻信单点推断。
阶段 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→ DBUPDATE 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>
- 专用测试账号模板(2026-09-11 实证):
- R5 权限可见面只准收窄,扩大必须先确认(2026-09-11 用户新增)——先判方向:这次改动是"扩大"还是"收窄"?命中「扩大」定义(新增 bwrap 挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽
ALLOWED_ENV/ 放松 nft / 提高权限档位或放宽 approval / 新增用户可读写路径 / 让 root 执行链路的对象变成用户可控)→ 必须在方案里写出「权限影响评估」四问并等用户明确同意,不得顺手做;改了「触发文件」清单里的文件就一律按 R5 走。收窄可直接做,但遮蔽类必须真启动一次实例验证(档案 42:遮蔽/usr内文件让实例起不来)
- 影响评估:实例重启会换端口(authority 变)→ 旧 WS 断连预期;登录 session 删除预期;数据目录权限影响。若命中 R5,另附「权限影响评估」四问 + 改动前后 diff「实例可访问路径清单(权威版)」。
- 文档占位:
04-调整方案/建编号档案(模板见下),先写需求与改动设计。
阶段 3 · 开发(小步 + 备份 + 逐条验证)
服务器 TS/配置改码标准流程(多文件改动):
-
scp服务器源码 → 本地工作区 → 本地 Edit(读一条→改一条→验证一条,禁止批量读全部文件) -
scp回服务器/tmp/→cp覆盖前先备份bak-<功能>-<YYYYMMDD>/ -
npm run build(tsc 零报错)→systemctl restart dshs -
本机直连验证(绕 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.jsCR=5222/LF=5547,本地纯 LF),去掉行尾后整文件的「差异」会瞬间收敛到真实改动。- 反例(我犯的):先改后验。顺序错 = 风险窗口白开一段;改完再验只能证明「没出事」,证明不了「不会出事」。
- archive 源改版(
/opt/dsh/docs/04-调整方案/poc/<plugin>/lib/client.js+ package.json version/description) npm pack→ tgz 拷到用户ws/(chown 用户 uid)→setpriv --reuid=<uid> --regid=<gid> --init-groups env HOME=... DSH_HOME=... dsh plugin --profile web remove/add <tgz>- kill 实例进程 → 门户自动 respawn 新 pid+新端口 →
journalctl -u dshs抓dsh web: http://127.0.0.1:<port>/?token=... - 验证 node_modules 副本 markers(version、关键字符串计数——区分代码 vs 注释行)
阶段 4 · 验证(全链路 + 浏览器实测 + 清理)
-
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)必须显式断言。
- ★ 验证用例必须同时覆盖「有请求体」与「无请求体」两类(2026-09-11 档案 51 事后修正实证):
只测
-
浏览器验证栈(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"](classuV2eYG_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-browser2026-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 端口对应进程。
-
静态文件 vs 后端改动:改
web/*.html无需重启(静态直出,实测服务启动时间不变);改src/**才npm run build+systemctl restart。别为前端改动重启服务(会打断所有用户实例)。 -
安全面复测:降权 uid 探测敏感目录(/root、/etc/shadow、DB)应 denied;中间目录 711(去 r 留 x)不破坏子进程访问。
-
清理临时物:
poc-*session 用完即删(DB DELETE);测试 tab 关闭;临时 tgz/脚本归档或清。 -
截图留证:每功能验收存 PNG 到本地 tmp + present_files 展示。
-
🔴 交付前必问一句:这份改动「出厂」了吗(2026-09-13 实证 —— 用户第 2 次反馈「样式还是没变」的真因之一): 改完
lib/*.js≠ 用户会看到变化。出厂链固定为 改文件 → bumppackage.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 一变浏览器下次请求就拿到新的(「看不到变化」几乎都是没出厂,不是缓存)。
- 一秒自检:
-
视觉/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 没下发、下发的是旧内容 只做 ① ⇒「脚本全绿但用户说没变」;只做 ①② ⇒ 漏掉「用户拿到的那份」。
-
🔴 用户说「参考门户/某个现成页面的样式」时:抄那个页面的源码,不要抄规范的抽象条目(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 · 归档清理(文档同步 + 三层沉淀)
-
★★ 交付门禁:本机改完 ≠ 交付(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 验"改对了没有"(三层验收);本门禁验"东西到用户那儿了没有"(链路完整性)。 -
文档落盘(2026-09-13 现状:本地工作树 = 源,服务器 = 只读镜像):
- 源 = 本地
D:\github\dsh_shenxian\dsh-server-docs(目录即 git 工作树),推 Giteadsh_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。
- 源 = 本地
-
档案更新:
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,以它为准。
- ⚠️ 档案号必须『原子预留』,不能靠"读一眼 ls 再写"(2026-09-11 两次撞号实证):"取号 + 写档案 + 改
-
三层沉淀(用户习惯,必做):
- 治本层:机制/流程固化进 skill/档案(本次即本 skill)
- 失误层:教训 append 到
.workbuddy/memory/YYYY-MM-DD.md(append-only,不改写历史) - 记忆层:长期约定/红线写
.workbuddy/memory/MEMORY.md(≤3000 字符/会话)
-
汇总表:带状态/结果列交付,列明未完成项与副作用。排版按
dsh-feature-first §5.4(首屏给判定 / 层级≤3 / 每节≤7 行 / 表格≤5 列 / 一条信息只说一次;细节进附录)。 -
技能自身归档(本 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 原文速查
- 禁止启动 dsh 时自动获取最新版本;版本升级独立流程(档案 07;/usr/local/etc/npmrc
update-notifier=false已全局生效)。 - 不改官方 dsh 主程序与缓存(
/usr/local/lib/node_modules/@deepseek-ai/dsh及依赖);扩展只走 profile 层官方插件机制。 - client bundle 严禁
exports.default = apply(或任何 default 函数导出)——详见上文 R3 解释(@dsh-local/portal-entry v0.4.0→v0.4.1 实证教训)。 - 禁止用真实账号(
admin/guest)做 API 登录测试:登录 last-wins 会删该账号全部旧会话,直接把用户踢下线。改用mksess.cjs直插临时 session 或专用测试账号(档案 15 实证教训)。 - 权限可见面只准收窄,扩大必须先确认(2026-09-11 用户新增,R5)——见下节「R5 权限扩大门禁」。
- R7|禁止未经确认的批量 / 全仓写入(2026-09-12 用户新增红线)—— 见下节「R7 批量写入门禁」。
- 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 覆盖到了服务器(已发现并恢复)。
规则(硬性):
- 只做被明确要求的事。执行中发现的额外问题(哪怕看起来"很小、很好修")一律先报告、后动手,不得顺手改。用户说"按建议处理"只授权那条建议本身,不是授权一切顺带优化。
- 禁止对仓库或生产目录做:全库遍历改写(
os.walk/find -exec/grep -rl | xargs)、通配符重写、批量chmod/chown、批量换行符转换、cp -r整目录覆盖、git add -A。 - 阈值:一次操作若可能影响 >10 个文件,或表述里出现「所有 / 整个 / 全库 / 全部」→ 停下来先问,先产出受影响清单再决定。
- 本机不是沙箱:本机镜像与服务器是两份独立副本;本机的批量改动即便不带任何"部署"动作,也会在下一次 scp 时传导到生产。
- 先用单点验证:任何批量手段先对 1 个对象试,确认后果(
git status、file、md5)符合预期再考虑推广。 - 传播前比对待传清单:scp / 同步前必须
git status确认待传清单只含本次真实改动,不含被工具顺手改动的文件。 - 行尾类问题一律先报告:仓库 blobs 是 LF 而本机工作树因 system 级
core.autocrlf=true呈现 CRLF(D:/Program Files/Git/etc/gitconfig)—— 这是既有环境事实,不构成"需要当场修复"的缺陷;要动必须先与用户确认范围。
R8 生产变更知会(2026-09-12 用户新增红线)
由来:档案 58(内存优化)、59(重连反馈)两次改动都需要重启 dshs,连续两次把在线用户踢下线,直接引发用户"会话连接异常"报障。
规则:
- 以下动作都会中断在线用户,执行前必须先说明「影响谁、断多久、为什么必须现在做」并取得确认:
systemctl restart dshs(drain 全部实例)systemctl stop dsh-*.scope(停某个用户实例)- 批量铺插件 / 改
MemoryMax/ 改实例 env(都要实例重启才生效) - 重建 profile、改 profile patch
- 能选低峰期就不要在用户活跃时做;无法避免时明确告知"会断一次"。
- 改完即验证:重启后必须确认服务 active + 门户 200 + 实例能被拉起,再向用户交代。
- 附带:本机
D:\github下的镜像任何批量改动都视为"可能影响生产"(见 R7 第 4 条)。
R5 权限扩大门禁(2026-09-11 用户新增红线)
先判方向:这次改动是「扩大」还是「收窄」?
| 方向 | 例子 | 处置 |
|---|---|---|
| 收窄 | 减少挂载、去掉白名单项、收紧 nft、收窄 env | 可直接做,但仍须验证(遮蔽类可能让实例起不来 → 档案 42) |
| 扩大 ⚠️ | 新增 bwrap 挂载 / --bind、放开被遮蔽的路径、把平台目录或文件暴露给实例、给实例注入新 env、放宽 ALLOWED_ENV、放松 nft(出网或宿主访问)、提高权限档位或放宽 approval、新增用户可读/可写路径、把 root 执行链路(解压 / chown / pnpm)的对象变成用户可控 |
一律先出「权限影响评估」并等用户明确同意,禁止"顺手做了" |
「权限影响评估」四问(方案里必须逐条写,档案留痕):
- 扩了什么 —— 逐条列具体路径 / 端口 / env / 权限位(不要写"优化了访问"这种含糊话);
- 谁受影响 —— 全部租户 / 单租户 / 仅 admin;
- 有没有不扩大也能实现的方案 —— 若有,必须先提;若确实没有,说明为什么;
- 回滚方式 + 验收方式 —— 回滚命令写清楚;验收必须 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 不带
--patchoverlay(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.ymlL83-88(skill-filesystem+tool-skill),preset 无 config 块 → provider 配置走 env。 - 技能分层 rank(
dsh-skill-filesystem/lib/index.jsroots()):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.jsALLOWED_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.jsSKILL_NAME)—— 中文名技能 dsh 静默丢弃。MCN 等含中文名技能需先改SKILL.mdfrontmattername为 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 不重播)→ 改默认档位只惠及新会话。查全平台还有多少会话是旧档位:2026-09-11 实测:总会话 10,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/##')" doneworkspace-write10,danger-full-access0 —— 档案 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 keypermission.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三条种子事件)→ 旧会话不跟随新默认,必须新建会话才生效。给用户的话术是「新开一个会话」,不是「刷新页面」。
其余做法(需要更细粒度时才用):
- profile 层
cordis.patch.yml改该行config—— 已核实applyEntryPatches里const { id, insert, name, ...overrides } = patch→ patch 的其它键会作为 overrides 合入目标行,所以config:可覆盖。 注意config是整体替换不是深合并 → 必须把内置的两个预设一并写全。 - 或改设置:
$DSH_HOME/settings.yaml加permission: defaultPreset: <presets 里的键名> - profile 是每用户一份 → 要像
ensure-role-profile-patch.cjs那样给每个用户(含新用户)铺;改完必须重启该用户实例才生效。 - 用户仍可在实例「设置 → 权限」自助切换(
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 个命令:python3pythonpip3pip-3pydoc3python3-configpyvenv-3easy_install-3unversioned-pythonld(→ld.bfd,node-gyp 编译要用)paxprint-*ifupifdownlpc。 → 判定准则:只绑"直接指向 /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/alternatives33 项全部指向/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.env600、shadow/gshadow0000、sudoers440)—— 别把"没泄露"当成本次收益。 - 判据:
/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-coregetconfgawk/awkcoreutils…)→ 与 /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):H1reject云元数据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 + 解压规模)。
三项加固(已落地):
findSpecialEntry()—— 解压后立刻拒绝任何非普通文件/目录条目(在任何chown/chmod之前);sumUncompressedSize()(解析unzip -l)+MAX_SKILL_FILES=2000+MAX_SKILL_UNCOMPRESSED_BYTES=200MB——UPLOAD_BODY_LIMIT=180MB只限压缩体,而宿主quotaon /未启用 = 无磁盘配额 → zip bomb 可跨租户 DoS;- 纵深防御:
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 idbusiness-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 审计)。
探活的两个必踩坑(都靠实测发现):
- 不能只判
restartMain的返回值 —— 编排器里没有该用户实例时(portal 重启清了内存态、或实例被空闲回收)它返回undefined,会把无辜插件误判为「不兼容」。→ 无实例时先按/api/dsh/enter同路径launch一个再探。 - 固定短等待会把好插件判死 —— 初版「固定 2.5s 稳定窗口」实测好插件也报「未产出 launch token」。→ 改为轮询(500ms 间隔 / 总预算 60s):拿到
launchToken且status==='running'后,再过 2.5s 确认没闪崩才算通过。 - 探活失败要回传 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 个「改码通道」+ N 个「只读通道」;改码任务之间永远排队串行。
- 待办先建成 DAG,逐条标注「依赖」+「冲突域」;无依赖且冲突域不同才允许同批。
- 收口在主会话:并行通道产出汇总回主会话,统一写档案 → commit →
docs-status-sync.sh --pull(这三步本身也是串行单通道)。 - 用户侧多会话按「领域」切分(A=门户后端 / B=文档梳理 / C=兼容性测试),不要按「同一领域的不同切片」切 —— 后者必然同域冲突。
- 开跑前先出「并行批次表」(任务 / 冲突域 / 依赖 / 可否并行)给用户确认。
- 我侧可用并行子代理(
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。