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