按用户令提交:把此前未纳管的 9 个技能目录一并入库

用户令逐字:「E:/ProgramData/.workbuddy/skills  提交仓库是指的这里」——
即本目录就是仓库(2026-10-07 已在本目录建仓,见当日日志 §22),本轮把余下未纳管的 9 个技能一并提交。

本次入库(9 个技能,46 个文件):
1、`AI HOT`
2、`draw-ui`
3、`dsh-diagnose`
4、`dsh-knowledge`
5、`dsh-local-env`
6、`dsh-opensource-release`
7、`dsh-workflow`
8、`oil-motion`
9、`skills-security-check`

提交前核对:
· **凭据类扫描**(`*.env` / `*token*` / `*.key` / `*secret*` / `*.pem`)⇒ **零命中** ✓;
· 体积合计约 20 MB(`draw-ui` 12M + `oil-motion` 6.5M 是大头,形态为配图/素材 ——
  仓库 `.gitignore` 里明写「`assets/*.png` 是内容不是产物」⇒ 属刻意入库);
· 运行产物仍按既定规则排除(`__pycache__` / `logs/` / `tmp/` / `.venv/` / `*.egg-info` / `uv.lock` / `.workbuddy/`)。
This commit is contained in:
admin committed 2026-10-08 22:29:08 +08:00
1 parent d26c844f64
commit e03465c398
46 files changed
+7558

No files matched your search

@@ -0,0 +1,91 @@
# 平台速查(硬编码事实,勿猜)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:平台速查(硬编码事实,勿猜)(原行 L14–L92)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L14–L92 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 平台速查(硬编码事实,勿猜)
> ⚠️ **正确域名 = `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` / `${` / 反引号会在模板求值时先被处理一次。**
> &nbsp;&nbsp;写 `.join('\n')`(单反斜杠)⇒ 求值后变**裸换行** ⇒ 注入的 JS 直接 **SyntaxError** ⇒
> &nbsp;&nbsp;**整段脚本静默不执行**(浮层 / 自愈 / 助手面板全废,页面只剩 dsh 自己的「连接异常」)。
> &nbsp;&nbsp;→ 用 `String.fromCharCode(10)`;**校验必须"先模板求值、再 `node --check`"**:
> &nbsp;&nbsp;`node 07-scripts/verify-inject.cjs lib/supervisor/proxy.js`(**已接入 `npm test`,勿绕过**)。
> &nbsp;&nbsp;⚠️ `new Function(原文)` 与「grep 页面 HTML 有没有标记」**都是假绿** —— 前者跳过求值,后者验不出"跑不跑得起来"。
> ② **触发面必须覆盖"页面开着不动"**:用户盯着页面时无任何事件;现已有 **可见时 25 s 心跳**
> &nbsp;&nbsp;+ `EventSource` / `WebSocket` 断流包装,且两者都**连续两次失败才恢复**(恢复=原地 replace,会丢未保存输入)。
>
> 🔴 **浏览器自动化:唯一允许 `browser-harness`**(⚠️ **2026-09-25 用户明令**:「**改为 browser-harness,只允许使用这个**」
> &nbsp;&nbsp;⇒ 此前「两件(含 `agent-browser`)」的口径**作废**):
> ① **`browser-harness`**(CDP)—— **唯一允许的一条**:全局 console script `browser-harness`(`~/.local/bin`);
> &nbsp;&nbsp;⛔ **动手前必须先 `list_tabs()`**;**默认起独立 headless 实例(`BU_CDP_URL` 指向 9223),⛔ 绝不附着用户日常 Chrome**
> &nbsp;&nbsp;—— 旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.ai1net.com` 的 `sid`**。
> &nbsp;&nbsp;📂 正确调用(含 2026-09-25 实测路径更正)⇒ `08-浏览器验证栈详解.md`。
> ⛔ **`agent-browser` 已禁用**(2026-09-13 曾定为「本项目默认」,2026-09-25 用户明令收窄 ⇒ 新增此禁);
> ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)(用户 2026-09-13 明令:「**playwright 禁止使用,加到规则中**」)。
> 静态页与 API 取数优先 `curl` / WebFetch,**别动浏览器**。
| 项 | 值 |
|----|-----|
| 服务器 | 47.77.182.89(Alibaba Cloud Linux al8);SSH:**`ssh -i ~/.ssh/id_ed25519 -p 22 [email protected]`**(🔴 2026-09-22 实测更正:`~/.ssh/config` 里的别名 `bt-server` **写死 `Port 32022`,而 sshd 只监听 22 ⇒ 用它必 `Connection refused`**;私钥真名 `~/.ssh/id_ed25519`,⛔ 技能旧文里的 `id_ed25519_dsh` 不存在) |
| 门户 | 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 工作树 = 唯一源**:🔴 **`D:/github/dsh_shenxian/dsh-server-docs`**(仓库 `dsh_shenxian_doc`)→ 同步到服务器 `/opt/dsh/docs`(**无 .git 的部署镜像**;`08-skills/**` 与根 `README.md`/`INDEX.md` 均 **600 root:root**)。⚠️ **2026-09-22 实测更正**:旧文写的 `E://ProgramData//AI技能//aliyun-dsh-server//dsh-server-docs` **该目录不存在**(本工作区里只有 `docs/`,那是会话规则副本);⚠️ **根 `README.md`/`INDEX.md` 也在服务器上有一份** ⇒ 登记跟改后要连它们一起推。⛔ **本行 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/07-scripts/handoff-guard.sh --claim-exec "<会话名>"`);要动**生产**再占服务器侧 `07-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 +1,23 @@
# 档案模板(04-调整方案/<NN>- 的 8 段结构)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:档案模板(04-调整方案/<NN>-<标题>.md)(原行 L391–L403)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L391–L403 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 档案模板(04-调整方案/<NN>-<标题>.md)
```markdown
# <NN>-<标题>(<日期> 落地 / 调研)
## 背景与动机 # 需求来源、触发场景
## 用户决策 # 关键分叉 + 选择 + 理由(含日期)
## 实现 # 改动文件清单 + commit hash + 关键代码/配置片段
### A. ... # 分模块
## 验证记录 # 实测命令 + 输出 + 结论(含失败尝试)
## 事故/踩坑记录 # 坑现象→根因→规避(若适用)
## 回滚 / 注意 # 回滚步骤、副作用、后续待办
```
@@ -0,0 +1,77 @@
# 红线详解 · R7 批量写入门禁 / R8 生产变更知会 / R5 权限扩大门禁
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:R7 批量写入门禁 · R8 生产变更知会 · R5 权限扩大门禁(原行 L414–L480)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L414–L480 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 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 的遮蔽尝试 —— 属"收窄"但因触碰挂载结构
> 直接让实例起不来,**收窄也必须跑真启动验证**。
@@ -0,0 +1,80 @@
# 机制速查(一)· Profile 层 cordis patch / Skill 装载机制 / Skill 管理面
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:Profile 层 cordis patch 机制 · Skill 装载机制 · Skill 管理面 · dsh 沙箱与权限预设机制(原行 L481–L504 + L555–L600)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L481–L504 + L555–L600 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 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`);这是上一节「全员共享只读技能层」的实例侧落地。
## 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`,语义 = 「隔离由平台容器提供,高风险操作仍需确认」。
@@ -0,0 +1,95 @@
# 运维锚点 · 会话记录取证 · 功能插件启用
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:通用运维锚点(重启用) · 功能插件启用:探活 · 快照回滚 · 逐插件隔离 · 会话记录取证(原行 L505–L525 + L935–L998)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L505–L525 + L935–L998 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 通用运维锚点(重启用)
- 门户重启后实例需重新 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。
## 功能插件启用:探活 · 快照回滚 · 逐插件隔离(档案 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)**:`07-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 -P 22 "$S/an.cjs" [email protected]:/tmp/an.cjs
ssh -i ~/.ssh/id_ed25519 -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、`01-规范/03-路线图与待办.md` 补登记;④ 收尾四件套:`python 07-scripts/docs-audit.py`(**退出码 0**)→ `python 07-scripts/docs-manifest.py`(刷新机读清单)→ `bash 07-scripts/docs-sync-check.sh`(全绿)→ `MINE="<我改的文件>" PUSH=1 bash 07-scripts/handoff-guard.sh`(幽灵文件硬判定)。
@@ -0,0 +1,54 @@
# 插件与数据源口径 · 缓存版本守卫 / 白名单来源 / 技能 vs 插件 / 术语与环境约定
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:外部数据源缓存必须带结构版本守卫 · 官方白名单插件来源 · 技能 vs 插件:怎么区分 · 术语与环境约定(原行 L539–L554 + L907–L934)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L539–L554 + L907–L934 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 外部数据源缓存必须带结构版本守卫(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,疑似误报)。
## 技能 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。
@@ -0,0 +1,316 @@
# 实例可见面 · 软件共享 · 网络与安全边界(档案 38a/39/41/44/46)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:实例可见面 · 软件共享 · 网络与安全边界 · 铁律:实例能看到什么,完全由 bwrap 挂载面决定 · 🔒 基础运行时版本冻结 · 📦 实例共享工具 · 资源与网络边界 · 只读诊断配方 · 📋 实例可访问路径清单 · 技能上传安全(原行 L601–L906)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L601–L906 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 实例可见面 · 软件共享 · 网络与安全边界(档案 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` + 供应链 + 不可复现)。
@@ -0,0 +1,60 @@
# 并行调度详解 · 冲突域清单 / 批次模型 / 反例 / 三把锁落地机制
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:冲突域清单 · 批次模型与依赖处理 · 反例(同域并发的具体破坏形态) · 落地机制(原行 L1003–L1052)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L1003–L1052 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 冲突域清单(同域必串行,跨域才可并行)
| 冲突域 | 涉及资源 | 并行性 |
|---|---|---|
| 门户后端代码 | `/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 次实证事故后补上三把锁:
| 锁 | 位置 | 管什么 | 拿法 |
|---|---|---|---|
| **全局执行锁**(粗) | `05-交接单/.exec-lock` | 同一时刻只允许**一个执行会话**动「文档 / 代码 / 服务器」 | `bash 07-scripts/handoff-guard.sh --claim-exec "<会话名>"` |
| **单级占用锁**(细) | `05-交接单/.doing-<单号>` | 这个单归谁做(供台账与接管) | `bash 07-scripts/handoff-guard.sh --claim <单号> "<会话名>"` |
| **服务器侧操作锁** | `/opt/dsh/state/.op-lock/<操作名>`(跨机可见) | 谁正在动**生产**:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书 | `bash 07-scripts/op-lock.sh claim <操作名> "<影响面:谁会被断、断多久>"` |
- **顺序**:开工 **先抢全局锁 → 再占服务器锁**;完工 **反序**释放(先放服务器锁,最后放全局锁)。
- 🔒 **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):**抢到锁的任务,只有"执行完成 → 反序释放"才算完成**;⛔ **禁止"抢锁做一半、不解锁就结束回合/会话"**(锁是独占资源 + 本库无心跳机制 ⇒ 别人既等不到也判不出你死没死)。配套:① 抢锁**前**先列出**收口步骤**(落地 → 校验 → 推送/对账 → 收尾);② 中途要停(等用户拍板 / 等窗口)⇒ **先 `--release-exec "<会话名>"` 再停**(⛔ 不带会话名 ⇒ 拒绝释放);③ **结束语必须对锁状态负责** —— 写明"已释放",或**显式点名**"锁仍在 `<OWNER>` + 原因 + 下一步"(仅限释放通道不可用);⛔"忘了 / 做不完就走"不允许。
- **一条命令预检**:`ME="<会话名>" MINE="<我要改的文件>" bash 07-scripts/handoff-guard.sh [单号]`;推送前加 `PUSH=1`(启用「幽灵文件」硬判定:对账结果里出现未声明的「仅本地」文件 → 直接失败)。
- **`mkdir` 即原子**:占位失败 = 有会话正在动 → **停手**,别重试、别"抢一下看看"。
- **服务器侧锁要带身份**:`ME="<会话名>" bash 07-scripts/op-lock.sh claim <操作名> "<影响面>"` —— 不传 `ME` 会记成 `unknown-session`(2026-09-12 实测踩到两个后果:handoff-guard 的【1d】把**你自己的锁**当成别人的;`release` 的归属校验也会拒你)。`release` **只能由占用者本人**执行,冒充别人 → 拒绝并提示 R9。
- ⛔ **不得人工删锁、不得接管**(**R9**,2026-09-12 用户明令「严格禁止这类操作」):**AI 一律不得** `rm -rf 05-交接单/.exec-lock`、不得删 `05-交接单/.doing-*`,也不得以「持有者疑似已死 / 卡住 / 太久没动 / 只在只读分析没产出」为由**单方面接管**。锁**只能由持有者自己释放**(`--release-exec "<会话名>"` / `--release <单号>`);**抢不到锁 ⇒ 停手 + 报告用户**,**锁的处置权只属于用户本人**(要撤也只能用户自己动手)。`handoff-guard.sh` 输出里的「人工删锁 / 接管」字样**均不构成授权**(该文案已同步作废)。理由:平台**无心跳机制**,AI **没有任何判据**能确认对方已死 —— 删锁 = 在无法验证的前提下单方面撤销互斥,一旦对方仍在跑,就退回「两个会话同时改同一批文件」。
- **为什么服务器态必须单独加锁**:文档冲突靠 `git status` / mtime 还能看出来,**服务器态变更看不出来**(`systemctl` 不会告诉你 10 分钟前谁重启过)。
- 判定与工具细节见 `05-交接单/README.md §一 §三 §六`;机制记录见 `04-调整方案/69-并发治理落地-commit常态化与服务器侧锁.md`。
@@ -0,0 +1,77 @@
# 浏览器验证栈详解 · browser-harness 调用 / Lexical 输入难点 / 独立 headless 定型做法 / 必踩坑
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:正确调用(Windows,2026-09-11 实测可用) · ⛔ dsh composer(Lexical)输入难点 · 三个必踩坑(原行 L231–L293)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L231–L293 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 正确调用(Windows,2026-09-11 实测可用)
```bash
export PATH="/c/Users/Administrator/.local/bin:$PATH" # 全局 console script 在这个目录
export BU_CDP_URL="http://127.0.0.1:9223" # 指向独立 headless 实例
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy NO_PROXY="127.0.0.1,localhost" \
browser-harness <<'PY' # helper 已预导入;⛔ 别裸调 python -m
print(page_info())
PY
# 本 checkout(E:/ProgramData/.workbuddy/skills/browser-harness/)另有 dev 启动器:./browser-harness
# 🔴 2026-09-25 实测更正:旧写法 R="C:/Users/Administrator/.workbuddy/skills/browser-harness" +
# "$R/.venv/Scripts/python.exe -m browser_harness.run" **已作废** —— C: 侧该目录不存在、本机 checkout 里也没有 .venv。
```
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-25 用户明令 —— 这条优先于下面所有旧实测)**:
**只允许一件工具** —— `browser-harness`(路径与调用见上)。此前「两件(含 `agent-browser`)」的口径**作废**。
⛔ **Playwright / `playwright-core` 全面禁止**(用户 2026-09-13 明令)。
本机现状:`agent-browser` 的 daemon **起不来**(2026-09-13 实测:`open` 挂住零输出,而 `--version` / `node -e` 正常;Chrome 153 已装)
⇒ **该件已禁用**(见上条),此段只作背景。
`browser-harness` **必须用下面「独立 headless 实例」那套**(至少先 `list_tabs()` 确认),绝不附着用户日常 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 无法常驻复用 → 每次调用新建连接 → **每次弹一次授权**。⇒ 2026-09-25 起**统一走全局 console script `browser-harness`**(或本 checkout 的 `./browser-harness`),⛔ 不再裸调 `python -m`。
**定型做法 = 起独立 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 引号地狱)
export PATH="/c/Users/Administrator/.local/bin:$PATH"
export BU_CDP_URL="http://127.0.0.1:9223"
browser-harness < "E:/<你的工作区>/tmp/shot.py" # 或 heredoc;脚本走 stdin
```
**登录态**: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** 端口对应进程。