chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Canceled after 0s

文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

@@ -0,0 +1,321 @@
---
name: dsh-desktop-dev-shell
description: 在 Windows 本机把**官方 dsh 跑起来**并完成端到端取证 —— 覆盖两个形态:① **web 实例**(`apps/cli` 的 `dsh` CLI,起实例 / 查回环监听 / token+cookie jar 取 200 / 探测 Windows 沙箱后端)② **桌面壳**(`apps/desktop`,需源码构建,且能挂自研 client 插件做真跑验证,含**离线可用**验证:黑洞服务造"挂起"、有界超时、如实状态)。⚠️ **作用域 = 本机源码/开发态**(与 `dsh-instance-diagnose` 的边界:**服务器上的用户实例**属对方)。当出现「**本机** dsh 实例起不来」「壳起不来」「plugin tree failed to load」「atomic-write: timed out waiting for the writer lock」「curl 实例页 404 / 401 / 303」「怀疑实例内工具被整体拒绝 / refusing to run the command unconfined」「Electron 一出窗口就 FATAL」「pnpm 卡在 added 一百多不动」「pnpm run 每次都重装」「dev:desktop 起不来」「装了插件但界面没变化」「启动器日志停在 `[devkit] release=` 不动」、**或「跑起来不是登录注册页 / 跟服务器版本不像一套」**时使用;也用于判断"壳能不能离线用 / 启动链有没有网络依赖",在有 safe-delete 护栏的机器上删除 `node_modules` 这类巨型树,或需要绕开 pnpm 直接构建这个 monorepo 的场景。
version: 1.0.0
updated_at: 2026-09-22
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v2.0.1);正文与历史中的版本号为当时记录,未改动。此前 v2.0.1(2026-09-22):触发词加「**本机**」限定并写明与 `dsh-instance-diagnose` 的作用域边界 —— 实测「实例起不来」原本会撞(两技能都像命中,且偏向本技能),用户其实指服务器实例时不该加载这里。此前 v2.0.0(2026-09-21):并入原 `dsh-windows-run-web-instance`(该技能已随之删除,其内容现为 §11)—— 两形态共用一条判据链(Node 22 / 监听≠就绪 / token 栅栏 / 陈旧写锁)。
agent_created: true
---
# dsh-desktop-dev-shell — 在 Windows 上跑起官方 dsh 并做真跑验证
## 0. 为什么存在
`@deepseek-ai/dsh` 的**运行时**可以 `npx` 直跑,但**桌面壳**(`apps/desktop` + `apps/desktop-host`)是 `private: true`、未发布 npm ⇒ **只能克隆源码构建**。这条路在 Windows 上有一串**看起来像"方案不行"、其实是环境**的坑,逐条定位一次要花几小时。本技能把这些坑与处置固化下来(含 **web 实例**形态,见 §10)。
## 1. 两种形态:先选对入口
官方 dsh 在 Windows 本机有**两种跑法**,本技能两条都管。**先判定你要哪一种**,再跳到对应章节:
| 形态 | 入口 | 有没有 HTTP 端口 | 适用章节 |
|---|---|---|---|
| **web 实例** | `apps/cli` 的 `dsh --profile web` | ✅ 有(回环 + token 栅栏) | **§11** |
| **桌面壳** | `apps/desktop`(源码构建) | ❌ **不开监听端口**(Electron 管道 + `dsh-app://`) | §2–§10 |
**两形态共用的判据链**(无论走哪条都先过一遍):
1. **必须 managed Node 22** —— 原生模块按 Node 22 编译,用 24 会 `ERR_DLOPEN_FAILED`。
2. **监听 ≠ 就绪** —— 插件层晚于端口挂载,起听后立刻访问会拿到 404 / 拒连 ⇒ **轮询到成功响应**再判。
3. **陈旧写锁会让实例"永久起不来"** —— 实例被中途掐断留下 `<DSH_HOME>/.credentials.yaml.lock` ⇒ 之后同一 `DSH_HOME` 每次启动都 `exit 1`(报 `plugin tree failed to load` + `atomic-write: timed out waiting for the writer lock`)。
✅ 处置 = **换全新 `DSH_HOME`**(立刻恢复)或启动前清掉该 `.lock`。**别改官方源码。**
⇒ **探针 / 守护脚本一律「每次全新 DSH_HOME + 就绪前不掐断」**。
4. **`ELECTRON_RUN_AS_NODE=1` 可能就在环境里** ⇒ 不删掉的话 Electron 退化成纯 Node、**不出 GUI**(像"壳起不来")。
> ⚠️ 形态选错的典型症状:**去 `curl` 桌面壳的 HTTP 端口找页面** —— 壳压根不开端口(见 §2 第 1 条)。
## 2. 桌面壳:三条必须先知道的判断
1. **壳不开监听端口。** 官方 README 原文:"**It opens no listening port**" ⇒ Electron 主进程与内置 Node 子进程走**带版本的定长帧管道**,渲染层用 **`dsh-app://`** 取资源。
⇒ ⛔ 别去 `curl` 壳的 HTTP 端口找页面(没有);要验渲染层请用 **`--remote-debugging-port`**。
2. **开发态里 `DSH_DESKTOP_DEV_PROJECT_DIR` 直接充当 profile。** `main.ts` 里 `activeProject = development ?? paths.profile` ⇒ **跳过** project-manager 的 staging / healthCheck / activate 事务。
⇒ 想挂插件,**自己造 profile 目录**,别指望走事务。
3. ⛔ **别用 `grep -nE "https?://" <源码>` 判"有没有网络依赖"** —— 它会**结构性漏掉** `electron-updater`:
它的 feed 地址在**打包产物**的 `app-update.yml` 里,**源码里一个 URL 字面量都没有**(本项目因此错下过一次结论)。
✅ 正确口径(本线实测,逐处读码得来):壳的启动链只有**三类**碰网 ——
① `main.ts` 启动后 **10 s** 自动 `checkAndPrompt(false)` → `electron-updater`:**三重可容忍**
(`enabled()` 要求 `app.isPackaged` **且** `resourcesPath/app-update.yml` 存在 ⇒ 开发态/未配 publish 的包**零网络**;
用 `void` 发起**不阻塞**窗口;全程 try/catch + `manual=false` ⇒ **静默失败不弹窗**)
② `project-manager.ts` 的 `DESKTOP_REGISTRY='https://registry.npmjs.org/'` ⇒ 只在**装/更新插件**时(离线装不了插件)
③ `host-process.ts` 的 `async fetch(request)` 是**进程内 fd 转发**(壳↔host),⛔ 不走网络
⇒ 结论:**"启动 → 出窗口 → 实例页"这一段不依赖网络**,可以做成离线可用。
⚠️ 但**装成正式包并配了 `app-update.yml` 之后**,10 s 那次检查会真的发请求 ⇒ 届时应复测。
4. **`dev.ts` 每次启动都重写 profile 的 `package.json`。** 所以手工挂进去的插件**会被抹掉**。
⇒ 要么每次重新挂,要么**别用 `dev.ts`**(见 §4)。
## 3. 依赖安装:pnpm 在本机"卡在 added ~100"的真根因
**症状**:`pnpm run <任意脚本>` 都先跑一遍安装,然后**卡在 `added ~104` 十几分钟不动**(`downloaded 0`,包全在 store 里)。
**根因(三条叠加,缺一不可)**:
1. **pnpm 的 before-run 安装钩子默认开启** ⇒ 每次 `pnpm run` 都先做依赖校验,判定"需装"就真的装。
2. 该次安装要**按虚拟 store 重建已有的 `node_modules`**;若 `.pnpm` 是上一轮**失败安装留下的半成品**,这一步在本机退化成**每包数秒**。
⚠️ **不在网络、不在磁盘、不在 pnpm 版本**(11.7 与 11.21 一样中招)—— 别在这三个方向浪费时间。
3. 若上一轮用过**非默认 `virtual-store-dir`**,顶层符号链接会全指向那棵树,并留下 `.pnpm-workspace-state-v1.json` 记录该配置 ⇒ 任何**不带该配置**的 `pnpm run` 都被判"配置变了要重建" ⇒ **死循环**。
🔴 附带后果:官方 `dev.ts` 的 `dependencyDir` **写死 `node_modules/.pnpm/node_modules`** ⇒ 非默认虚拟 store 会让官方 dev 流程**直接失效**。
**处置(三步)**:
```bash
# ① 把损坏/陈旧的树【改名移走】——⛔ 不是删除
mv node_modules/.pnpm <外部目录>/pnpm-old
mv node_modules/.pnpm-new <外部目录>/pnpm-new
mv node_modules <外部目录>/node_modules-old
# ② 空目录上做默认布局安装
pnpm install --frozen-lockfile --config.minimumReleaseAge=0
# ③ 此后所有 pnpm 调用都带这一条(⭐ 能透传到嵌套 pnpm run,无需 shim)
pnpm --config.verify-deps-before-run=false run <script>
```
- ⚠️ **两处必须解释清楚**:
- `minimumReleaseAge`(供应链校验)在 **npmmirror 上会超时**并报 `… entries failed verification` ⇒ **不是真实版本违规**,必须 `--config.minimumReleaseAge=0`。
- 非标准 `virtual-store-dir` 会让 pnpm 判"要 purge",无人值守下 `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`。⛔ **别用 `CI=true` 绕过** —— 那会真的 purge。
- ⚠️ **`rm -rf node_modules` 会被环境的 safe-delete 护栏拦下**(报 `SAFE_DELETE_BULK_CONFIRM_REQUIRED`)⇒ **"删除完成"是假象**,你还在同一堆半成品上重试。⇒ 这就是为什么要用 **`mv` 改名**而不是删。
**护栏机制全文 ⇒ §9**(阈值、正规批量通道、Windows 上删 pnpm 树**必失败**的坑)。
- ✅ **`build:native-system` 在 Windows 上是空操作**(`native/system/07-scripts/build.ts`:平台非 linux/darwin 且带 `--host-addon-only` ⇒ 直接 `exit(0)`)⇒ **不需要 MSVC / Python / Windows SDK**。别被"打包前置要求"吓住。
- ⚠️ **Electron 二进制不在 npm 包里**:全新安装后 `node_modules/.pnpm/electron@*/node_modules/electron/` 下**没有 `dist/` 与 `path.txt`**(该包未列入 `allowBuilds` ⇒ pnpm 不跑它的脚本)⇒ 会看到 "Downloading Electron binary..." 然后卡死/失败。手动补齐:从 npmmirror 拉 `electron-v<ver>-win32-x64.zip` ⇒ 解压进 `dist/` ⇒ 写 `path.txt`(内容就是 `electron.exe`)。
## 4. 启动:不要用 `pnpm run dev:desktop`
**两个理由**:
1. `dev:desktop` = `pnpm --filter … run dev` ⇒ **两层 `pnpm run` = 两次 before-run 安装钩子**。
2. `dev.ts` 每次重写 profile ⇒ 挂的插件被抹掉。
**做法:自建一个启动器**(放在工作区外或本工作区的 `.workbuddy/` 下,⛔ 不进官方源码树),步骤:
1. 复用官方 `apps/desktop/07-scripts/development-project.ts` 的 **`prepareDevelopmentProject()`**,但把 `projectDir` 指到**自己的**目录(如 `…/.desktop-build/development/project-adapt`),与 `dev.ts` 的 `project/` 互不干扰。
- `release` 需要:`{ schemaVersion:1, version:<apps/desktop 版本>, hostProtocolVersion:<从 src/host-protocol.ts 读>, nodeVersion:process.versions.node, pnpmVersion:<apps/desktop/node_modules/pnpm 的版本> }`
- ⚠️ 该模块只依赖 node 内置 + 本地 ts,**不牵连 electron**,可以安全 import。
2. 改 profile 的 `package.json`,把插件名 push 进 `dsh.profile.bundles`(basis 的两项 `@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app` 必须在前)。
3. 把插件包 **junction** 进 profile 的 `node_modules/<scope>/<name>`。
4. 直接 `spawn(electron, [...])`,参数见 §5。
- `require('electron')` 要用 `createRequire(<apps/desktop/package.json>)` 才解析得到。
### 4.1 🔴 `prepareDevelopmentProject()` 会**偶发卡死**在删目录(⛔ 不报错)
- **症状**:启动器日志停在 `[devkit] release=…` **不动**,下一条 `[devkit] profile=…` **永不出现**,
窗口起不来,**且没有任何错误输出** ⇒ 极易被误判成"壳坏了 / 方案不行"。
- **判定**(30 秒内可确认):`<projectDir>/node_modules` 已**空**,但同层元数据文件
(`package.json` / `pnpm-workspace.yaml` / `desktop-release.json` / `desktop.cordis.yml`)的
**mtime 仍是上一次的** ⇒ 卡在 `removeOwnedPath()` 的 `rmSync(recursive)`
(`development-project.ts` 里 `removeOwnedPath(options.projectDir)` 那一步),**还没走到重建**。
⚠️ 这几个文件的 mtime 是**最好用的判据**:重建会刷新它们。
- **先排除"junction 遍历误删"**(别慌着重装):看 `apps/cli` / `apps/desktop-host` 的 mtime 有没有变新、
`node_modules/.pnpm/node_modules` 条目数有没有掉(正常 ≈ 820)⇒ 都没变 ⇒ **官方树未受损**,只是删不动。
- ✅ **处置 = 移走,不删除**:
```bash
taskkill /F /PID <启动器 PID> # 先停掉卡住的那个
mv "<projectDir>" "<projectDir>.stale-<时间戳>" # mv 在同卷上近乎瞬时
# 重新启动 ⇒ prepareDevelopmentProject 走 ENOENT 快路径,秒级过
```
陈旧树**留给用户处置**(Windows 上删这种树会卡,理由与 §9 同源)。
- **⚠️ 非必现**:同条件下另一次重启(`projectDir` **存在**)**顺利通过**。⇒ 按"偶发环境坑"记,
⛔ **不要**因此去改官方 `development-project.ts`(那是官方源码,本项目红线 R2)。
### 4.2 ⚠️ 改插件后必须重启壳
`readClientBoot()` 之类的读取与**模块级常量**都在**加载时**求值 ⇒ 没有热更新。
⛔ **杀壳只能按端口定位 PID**:`netstat -ano | grep LISTENING | grep 9333` → `taskkill /F /T /PID <pid>`。
⛔⛔ **绝不用 `taskkill /IM electron.exe`** —— WorkBuddy 自己就是 Electron,会把自己杀掉。
## 5. Electron 启动参数(Windows 本机实测)
```js
// ⛔ 必须先从 env 里删掉;本机环境常带着它
delete env.ELECTRON_RUN_AS_NODE
const args = [
`--inspect=127.0.0.1:${mainPort}`, // 主进程调试(默认 9229,本机可能被占)
`--remote-debugging-port=${rendererPort}`, // 渲染层 CDP(默认 9222,⛔ 本机常被别的进程占用)
'--no-sandbox', // 🔴 Windows 上以管理员身份运行时必需
`--user-data-dir=${userDataDir}`,
appRoot,
]
// env 还要给:DSH_HOME(默认 .desktop-build/development/home)
// DSH_DESKTOP_DEV_PROJECT_DIR(= 你的 profile 目录)
// DSH_DESKTOP_HOST_INSPECT_PORT、DSH_DESKTOP_NODE_BINARY
```
**`--no-sandbox` 的判据(别误判成 GPU 问题)**:症状是 GPU 进程连崩 6 次后
`FATAL: … gpu_data_manager_impl_private.cc: GPU process isn't usable. Goodbye.`(退出码 `2147483651`)。
实测:**单独 `--disable-gpu` 无效**;加 `--no-sandbox` 后 **GPU 报错 0 条** ⇒ **GPU 本身没问题,sandbox 是单一根因**。
⛔ 这是**开发态在管理员会话里跑**的权宜,**不得带进正式分发包**。
**定位这类问题的通用手法**:写一个**最小 Electron 探针**(`package.json` + 一个只创建 `BrowserWindow` 并 `setTimeout` 打印存活标志的 `main.js`),用 `timeout` 跑多组参数扫描,按"是否打印存活标志"判定。⛔ 别一上来就跑整个壳 —— 一轮几秒 vs 一轮几十秒。
## 6. 真跑验证(怎么拿到硬证据)
| 想验什么 | 怎么做 |
|---|---|
| 渲染层加载了什么 | `curl http://127.0.0.1:<rendererPort>/json/list` ⇒ 看 `url` / `title` |
| 插件是否真被装载 | CDP `Runtime.evaluate` 读 DOM:插件注入的 `<style id>` / `data-*` 属性是否在;`typeof window.__ModuleLoader__` |
| **窗口能否窄到某个宽度** | 走**主进程调试端口**:`Runtime.evaluate` 执行 `process.mainModule.require('electron').BrowserWindow.getAllWindows()[0].setSize(w,h)` —— 这是**真·窗口缩放**,受 `minWidth` 约束 |
| 断点/响应式是否真生效 | 缩放前后各读一次 `window.innerWidth` + 你的断点属性;再 `Page.captureScreenshot` 抓两张图对比 |
**三个"换路走"的坑**:
- ⛔ **`Browser.getWindowForTarget` 在 Chromium 152 / Electron 44 里不存在**(`-32601`)⇒ CDP 的 Browser 域改窗口走不通。
- ⛔ 主进程调试上下文里 **`require` 未定义 · 动态 `import()` 报 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`** ⇒ **`process.mainModule.require('electron')` 是唯一入口**。
- ⛔ 若本机安全策略拦 `Add-Type`("compiles and loads .NET code at runtime")或拦"从 Bash 调 PowerShell",**Windows API `MoveWindow` 这条路就没了** ⇒ 只能走上面主进程调试器那条。
### 6.1 ⚠️ 选 CDP 目标:**DevTools 自己也是 `type:'page'`**
`/json/list` 里会有多个 `page`。⛔ 别取第一个 —— 按 **`t.url.startsWith('dsh-app:')`** 选,
否则你量的是 DevTools 而不是壳。(同理:壳的 scheme 在你的项目里可能是别的自定义协议。)
### 6.2 🔴 判据写窄 = **假红**(会让你去改本来正确的产品行为)
实测两例:① 假定起始态干净 ⇒ 上一脚本留在打开状态的视图被当成产品缺陷
(修法 = 连接后先**归一化前置态**,并把归一化动作**写进报告**);
② 断言实例页"有 textarea/contenteditable" ⇒ 而实例页用的是 `role="textbox"`
(`buttons=15` 明明说明页面画出来了)。
✅ **规矩**:判据写**语义层**("页面画出来了且不是空壳"),信号**取全再判**
(`body.children.length` / `#root.innerHTML.length` / `#root.textContent` / `textarea` / `[contenteditable]` / `[role=textbox]` / `input` / `button`)。
### 6.3 「离线可用」怎么验(⛔ 别用"没人监听的端口")
**真断网的形态是连接挂起**(黑洞地址 / 丢包),不是 `ECONNREFUSED`。
拿"连不上就秒拒"的端口去验离线是**假验证** —— 它只证明了快速失败,而真正要防的是"永远不返回"。
✅ 做法:起一个**黑洞服务**(接受 TCP 连接、**永不回话**),把客户端指向它:
```js
// blackhole-api.mjs —— 只有 net.createServer,连上来什么都不写
const server = createServer((socket) => { socket.on('error', () => {}) }) // ⛔ 不 write、不 end
server.listen(9414, '127.0.0.1')
```
判据三条(缺一不可):
1. **有界**:每一次对外调用都要在**预算内**给出结论(本线:探针 1 200 ms / 代理 6 000 ms / 客户端兜底 8 000 ms)。
2. **如实**:够不着就显示"离线/单机",⛔ 不许谎称"未登录"(那会诱导用户去点必然失败的入口)。
3. **佐证**:黑洞侧打印**连接数**与 `still_open` 峰值 —— 峰值就是"挂起本身",
之后回落则证明**超时真的在释放 socket**(不是泄漏)。
⭐ 这条比"壳里看到单机模式"更强:它同时证明**壳真的去连了**。
⚠️ 全盘端到端验证之外,**先写一层"宿主半单测"**(造最小 ctx 直接调路由 handler):
秒级、确定、能直接量到毫秒,且能把"离线"拆成几条独立事实分别定位。
### 6.4 ⚠️ 造假的 ctx 有个必踩的语义坑
Cordis 的 **`ctx.effect(fn)` 是「立即执行 `fn` 并把返回的销毁函数登记起来」**。
插件一般把 `register(...)` 放在 effect 回调**里面** ⇒ 把 `effect` 写成 `() => {}`
会导致**路由一条都没挂上**,而插件日志**照报** `mounted=9`(因为它数的是循环次数)。
✅ 正确的最小实现:
```js
effect: (fn) => { const d = fn(); return typeof d === 'function' ? d : () => {} }
```
## 7. 其他本机环境坑(会误导判断)
- **前台 Bash 命令有 120s 上限**,且 **`sleep` 不会被自动转后台** ⇒ `sleep 150` 会被直接杀掉、**无任何输出**(看起来像命令失败了,其实是超时)⇒ 长等待拆段或走后台。
- **node 不认 MSYS 路径**:`node /e/foo` 会解析成 `E:\e\foo` ⇒ 传参一律写 `E:/foo`。
- **中文路径必须整体加引号**。
- **Windows git 不认 MSYS 路径**:`git -C /d/repo` 报 "cannot change to" ⇒ 写 `D:/repo`。
- **`ELECTRON_RUN_AS_NODE=1` 可能就在环境里** ⇒ 不删掉的话 Electron 退化成纯 Node、**不出 GUI**(像"壳起不来")。
- **Bash 命令正文里出现某些敏感关键词会被直接拦掉**(如正文含 "PowerShell" 触发"Invoking PowerShell from Bash")⇒ 想写含这类词的文档时,改用文件编辑工具落盘,别用 `cat <<EOF` 拼。
## 8. 「壳里打开不是登录注册页」——先说清它**不是 bug**
如果你挂的是**官方** desktop 壳 + 官方 bundle(`@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app`),首屏就是官方「内测声明」弹窗 + 空工作区。**这不是装错了**:
- 🔴 **官方 dsh 单机版没有账号体系**:官方 `packages/client/src` 与 `apps/web/src` 对「登录 / 注册」**0 命中**。
- 🔴 **登录 / 注册属于「控制面」那一层**,不是实例层。以本机的 `dshs` 平台为例:它是一个 `dsh:{plugin:true, kind:"server"}` 的**独立 Fastify 服务**(`bin: dshs`),页面在它自己的 `web/login.html` · `register.html` · `portal.html`,路由在 `src/web/routes/auth.ts`;而注入每个用户实例的只是零依赖的 **`dshs/runtime`**(orchestrator 用 `dsh --patch` 注入,见其 `Dockerfile.dsh` / `cordis.patch.yml`)。
- ⇒ **桌面壳对应的是"实例"那一层** ⇒ 只装官方 bundle 时,**壳里天生不会有登录注册页**。
- ✅ **判据**:要确认"壳里装了什么",直接读 profile 的 `package.json` → `dsh.profile.bundles`(开发态 = `DSH_DESKTOP_DEV_PROJECT_DIR` 指向的那个目录)。**bundles 里没有控制面包 ⇒ 就别期待控制面页面。**
- ⚠️ 想让它出现,只有三条路:① 把控制面包当 bundle 装进 profile(壳里跑"单机版平台")② 壳改成加载线上域名的浏览器外壳(**要改官方源码**)③ 接受现状。**这三条都要用户拍板,⛔ 不要代为决定。**
## 9. 环境 `safe-delete` 护栏(删 `node_modules` 前必读)
**A. 机制**
- 实现 = CLI 的 shim 链 `safe-bin/{rm,unlink,rmdir}` → `safe-delete-common.sh` → `safe-delete-bulk-guard.cjs`(bash 里 `rm` 已被函数覆盖)。
- ⭐ **阈值 = 环境变量 `CODEBUDDY_SAFE_DELETE_BULK_THRESHOLD`**(代码默认 20,本机实测 **50**),且**按"轮次"累积**(key = `CODEBUDDY_CONVERSATION_REQUEST_ID`)⇒ **同一轮内多次 `rm` 会累加**,很容易触顶。
- ⭐⭐ **正规批量通道 = 让 host 弹确认**:`rm` → guard 打印 `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {…}` 退出码 2 → **host 拦截并弹确认** → 同意后 host 调 `approveSafeDeleteBulkGuard(toolCallId)` 并**重试同一条命令**。
🔴 **自己调 `…/safe-delete-bulk-guard.cjs approve` = 自我批准 = 绕过护栏 ⇒ 禁止。**
- ✅ **怎么确认"真被批准了"**:读 `$CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR/<sha256(sessionId)>/state.json`,看 `toolApprovals[<toolCallId>].approved === true`;命令行侧还会附 `⚠️ Sandbox bypassed (escalation-approved)` —— **那句提示就是批准本身,不是被绕过**。
- ✅ **豁免**:目标在 **OS 临时目录**(`$TMPDIR/$TMP/$TEMP`、`/tmp`)⇒ **跳过 guard,走真 `rm`**。
- ✅ **去向 = Windows 回收站**(`genie-trash`);可用 `$CODEBUDDY_SAFE_DELETE_REPORT_PATH` 逐条核对。
**B. 🔴 Windows 上「回收站失败 = 不删」,且 pnpm 树**必**失败**
- 症状:`genie-trash` 报 `Error during a 'trash' operation: Unknown { description: "Some operations were aborted" }` → shim 打印 `[SAFE_DELETE_FAIL_CLOSED] {"reason":"trash-failed"}` → **文件原样保留**(退出码 1)。
- ⭐ **规模相关,已实测定位**:**42 文件 / 1 符号链接**的小目录 → **成功进回收站**;**66k 文件 / 3778 符号链接**的 pnpm store → **必中止**(复现 3 次)。**不是** MAX_PATH(最长 227 < 260)、**不是**磁盘满。
- 🔴🔴 **⛔ 不要"绕过"**:直接用真 `rm`(`SAFE_DELETE_REAL_RM`)· `cmd /c rd /s /q` · Shell COM 删 —— 三者都**跳过回收站**,等于手动关掉一个**刻意 fail-closed** 的控制(shim 源码明写:Windows 下 native helper 存在但失败**必须 fail-closed,不得降级**)。
- ⚠️ `mv` 到 `$TEMP` 再 `rm` 只是"把硬删换个地方",一样不可恢复 ⇒ **不构成更安全的替代**。
- ✅ **正确动作 = 停手 + 报告用户**(给对象 / 大小 / 失败原因 / 可用命令),⛔ 不自行强删。
## 10. 纪律(本项目硬约束)
- ⛔ **不改官方源码**:官方源码树 = **只读参照**;自研代码与启动器放**独立目录/独立仓**,两者**永不混树**。
- ⛔ **不 commit / 不 push**,除非用户明确要求。
- ⛔ **不碰用户正在用的库/工作台**:开发态务必用**独立的 `DSH_HOME`**(默认 `.desktop-build/development/home` 已隔离);起第二个 host 时用**新端口 + 隔离 profile**。
- ⛔ **删锁/接管锁的处置权只属于用户**:遇到陈旧锁(如 `dsh-lefthook-install.lock`)**只报告、不删**。
- ⛔ **改名优于删除**:清理 `node_modules` 这类大目录时用 `mv` 而不是 `rm`,既绕开护栏也留后路。
---
## 11. web 实例形态(`apps/cli` 的 `dsh` CLI)
> 与桌面壳的区别:**这一形态有 HTTP 端口**,且实例页受 token 栅栏保护。**共用判据见 §1 的判据链**。
### 11.1 适用
官方源码工作树(如 `E:\github\dsh-desktop-0.1.5rc1`,版本 `0.1.5-rc.1`)内,用 `apps/cli` 的 `dsh` 把 web 档实例跑起来,并给出可被第三方复现的读数。
### 11.2 基线命令
```bash
# 必须用 managed Node(项目原生模块按 Node 22 编译;用 24 会 ERR_DLOPEN_FAILED)
NODE="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe"
cd <开发树根>
DSH_HOME=<每次全新目录> "$NODE" apps/cli/lib/bin.js --profile web \
--host 127.0.0.1 --port <空闲口> --no-open
```
- `web` 是**随包发的 profile**(自动落到 `$DSH_HOME/profiles/web/`,`bundles: [dsh-base, dsh-web-app]`)。
⛔ 别用 `--from-default-profile web` —— 会被拒:`profile "web" is shipped and cannot be a custom profile target`。
- `--port 0` 合法(OS 选空闲口),但端口值得从 stdout 解析。
- 不要用 `pnpm run dev:*`(两层安装钩子 + 每次重写 profile 的 `package.json`,理由见 §4)。
### 11.3 三条必踩的坑(顺序即排错顺序)
1. **监听 ≠ 就绪**:HTTP 端口先 listen,插件层随后才挂载 ⇒ 起听后立刻 curl 会拿到 404 / 连接被拒。
✅ 正确姿势 = **轮询到 200**(`--host 127.0.0.1` 时 3~4 s 起听,路由就绪略晚)。
2. **实例页不是裸 200,受 token 栅栏保护**:
- `GET /` 无 token → **401**,正文 `dsh web authentication required; reopen the URL printed by dsh web.`
- token 在启动 stdout 里:`dsh web: http://127.0.0.1:<port>/?token=<43 字符>`
- `GET /?token=` 不跟随 → **303**(要 cookie jar)⇒ 必须:
```bash
curl -s -L --compressed -c jar -b jar -H "Accept: text/html" \
-o page.html -w "%{http_code}" "http://127.0.0.1:<port>/?token=<TOKEN>"
```
三条(`-L` / `--compressed` / cookie jar)缺一会被挡。
- `/index.html` `/app` `/login` 本就 404(实例侧无账号体系;登录页在平台控制面,不在实例 —— 理由见 §8)。
3. **判据「实例内 bash 工具可用」不能靠启动日志判断**:`confine()` 是**每次工具调用**才判定,不是启动探针;启动期无 `sandbox` 告警 ≠ 运行时可用。
### 11.4 沙箱后端的组件级探针(确定性、零凭据、零外发)
默认组成就挂了受限 bash(`packages/bundle/base/cordis.patch.yml` L205–247:`sandbox`=`dsh-sandbox-local` · `sandbox-policy` · `bash-sandbox` · `pwsh-sandbox` · `tool-bash`,档位 `read-only`(默认)/`workspace-write`/`danger-full-access`)。Windows 上**挂 `sandbox-local` 即自动选 `@deepseek-ai/dsh-sandbox-windows-acl`**(`WRITE_RESTRICTED` 受限令牌,依赖 `koffi` FFI)。
```js
// 探针脚本放在开发树之外时,裸包名按「导入方所在目录」解析会失败 ⇒ 用绝对 file:// URL
const m = await import('file:///E:/github/<dev-tree>/packages/sandbox/sandbox-windows-acl/lib/index.js')
const s = new m.AclSandbox({ writableDirs:[ws], tempDir,
writeSid:m.workspaceWriteSid(ws), tempWriteSid:m.tempWriteSid(tempDir), mode:'workspace-write' })
await s.init() // 抛错 = runner 起不来(最坏分支:工具被整体拒绝)
// 期望:init OK;受限子进程 echo → exit 0;写 ws 内 → exit 0;写 ws 外 → EPERM
```
- ⚠️ 测写权限**别用 `cmd.exe` 拼重定向**(argv 二次解析会假失败,报 GBK「文件名、目录名或卷标语法不正确。」);直接让受限子进程跑 `node -e "require('fs').writeFileSync(process.argv[1],'x')" <path>`。
- 组件级通过只排除"后端起不来";**实例级真跑仍需一次模型调用**,取证时如实标注级别。
### 11.5 安全清理
- 回收实例用**端口占有者 PID**(`netstat -ano | grep ":<port> " | grep -i listening | awk '{print $NF}'` 再 `taskkill //PID <pid> //T //F`);直接记后台 job 的 `$!` 在 `VAR=x cmd &` 形式下拿到的是子 shell PID,会漏杀。
- 探针用的 scratch `DSH_HOME` 是临时产物,收尾删掉(⚠️ 但**先确认它不在 `node_modules` 那类巨型树里** —— 见 §9 护栏)。