用户令逐字:「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/`)。
612 lines
56 KiB
Markdown
612 lines
56 KiB
Markdown
# 在 Windows 本机跑官方 dsh 并做端到端取证
|
||
|
||
> **归属**:技能 `dsh-local-env` · 详情档(主干 `../SKILL.md`)
|
||
> **本档覆盖**:原技能 `dsh-desktop-dev-shell` **全文**(正文 349 行 + frontmatter 变更历史)
|
||
> **provenance**:本机版(WorkBuddy 实况)
|
||
> **搬运方式**:**逐行未改**;内部附件链接已改写为 `dsh-desktop-dev-shell/<附件名>`(附件在本目录下)
|
||
> ⚠️ 原技能 `dsh-desktop-dev-shell` **已合并退役** ⇒ 见到该名按本档读。
|
||
|
||
---
|
||
|
||
|
||
# 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/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/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,会把自己杀掉。
|
||
|
||
### 4.3 桌面快捷方式链路(`DSH 客户端.cmd`)—— 2026-09-26 实修
|
||
|
||
交给用户的桌面入口是**三段接力**。报"双击没反应 / 一闪而过"时,按这个顺序查:
|
||
|
||
%USERPROFILE%\Desktop\DSH 客户端.cmd ← 薄壳,只负责 call
|
||
└─ <工作区>\.workbuddy\_devkit\launch-client-017.cmd ← 设环境变量 + 三条存在性守卫
|
||
└─ .workbuddy\_devkit\launch-desktop-dev-017.mts ← 真启动器(由 tsx 跑)
|
||
|
||
- **本机规范路径**(⛔ 名字反了/根抄错就一定起不来):
|
||
- 工作区 = `E:\ProgramData\AIProject\ai1net-dsh-desktop` —— 是 **ai1net-dsh**,不是 `dsh-ai1net`
|
||
- 根是 `E:\ProgramDSH\`,**不是** `E:\ProgramData\`。**两个根下各有一份同构目录树**(同名子目录大部分重合),抄路径时极易串根。
|
||
- **两级路径曾被同时写死、各踩一坑**:
|
||
1. 桌面薄壳写成 `dsh-ai1net-desktop`(词序反)⇒ cmd 直接 `系统找不到指定的路径。`、退出码 1,**什么都不发生**(静默失败)。
|
||
2. `launch-client-017.cmd` 的 `DEVKIT` 写成 `E:\ProgramData\AIProject\dsh-ai1net-desktop\…`(盘符目录 + 名字**双错**)⇒ 只修第一处的话,第二处必炸。
|
||
- ✅ **已改为自校验 + 自派生**:薄壳先 `if not exist "%CLIENT_LAUNCH%"` 再 `call`(找不到就打印**缺哪个路径**,而不是一句无信息量的"找不到路径");`DEVKIT` 改用 `%~dp0` 从脚本自身位置派生:
|
||
|
||
set "DEVKIT=%~dp0"
|
||
if "%DEVKIT:~-1%"=="\" set "DEVKIT=%DEVKIT:~0,-1%"
|
||
|
||
⇒ 以后工作区再改名/搬家,第二处不会再断。
|
||
- 🔴 **`.cmd` 必须 CRLF**:桌面薄壳原本是 LF-only。只有 10 行短句时"侥幸能跑";一旦加长注释,cmd 的按行定位漂移 ⇒ 把 **REM 行的尾巴当命令执行**,开头吐一屏碎片报错(`'p' 不是内部或外部命令`、`'used' 不是…`、`'rtcut' 不是…`)。**功能仍能起来**,所以极易被误判成"改坏了"。
|
||
- 判据:数 `\n` 与 `\r\n` 个数是否相等;不等即 LF-only ⇒ 转 CRLF 后碎片报错消失。
|
||
- **验证姿势(不弹窗、不真起 Electron)**:复制一份 `launch-client-017.cmd`,把最后一行 `"%NODE%" "%TSX%" "%LAUNCHER%"` 换成 `echo`、`pause` 换成 `rem pause`,跑它 ⇒ 三条守卫全过并打印解析后的 node/tsx/launcher 全路径,即证明链路通。验完删掉探针(在**同目录**建探针,`%~dp0` 才等价)。
|
||
- **无害噪音(⛔ 别当故障去改)**:
|
||
- `Error occurred in handler for 'dsh-desktop:mandatory-status': No handler registered …` —— 官方态未挂自研插件时的正常现象。
|
||
- `Electron Security Warning (Insecure Content-Security-Policy)` —— dev 态有,打包后自动消失。
|
||
- ⚠️ 停壳仍按 §4.2 用**端口定位 PID**(`netstat -ano | grep 9333`)。本机 `WorkBuddy.exe` 与 dev 壳不同 image 名,`taskkill /IM electron.exe` 实测只命中 dev 壳 —— 但**别把这条当可依赖前提**,按端口杀最稳。
|
||
|
||
## 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 : () => {} }
|
||
```
|
||
|
||
## 6b. 🔴 自研插件的 **opt-in 回环口**:装上了 ≠ 口会开(2026-09-29 实测)
|
||
|
||
> 场景原型:`@dsh-local/ai1net`(模块⑥「设备接入」)要在 `127.0.0.1:20090` 起一台回环反代,**默认不启**,开关由 profile 的 `cordis.patch.yml` 定向覆盖该插件 id:`deviceAccess: { enabled: true, port: 20090, upstream: workbuddy }`。
|
||
|
||
### 6b.1 先记住三条"假象"
|
||
|
||
| 你以为 | 实际 |
|
||
|---|---|
|
||
| profile 里 `bundles` 有它 + junction 指对了 ⇒ 装好了 | ✅ 只是"**装**上了",**口会不会开**是另一回事(opt-in 默认不启) |
|
||
| `mount-into-profile.mjs verify` 返回 **`rc=0`** ⇒ 装好了 | 🔴 **假绿** —— 它的绿只判 `bundles + junction`,**opt-in 开关只在输出里打印**、不进退出码 |
|
||
| 重启一下客户端就好了 | ⚠️ 若故障机制是"开机一次性发现失败",**重启可能好、也可能不好**,且**日志里看不到原因** |
|
||
|
||
🔴 **唯一可靠判据 = 那个口有没有 `LISTENING`**:`netstat -ano | grep ":20090 " | grep LISTENING`。
|
||
|
||
### 6b.2 分诊三步(⛔ 顺序别颠倒,前两步都免重启)
|
||
|
||
**第 1 步 · 配置真的落到插件了吗?**(只读,⛔ **不要**用 `loadProfile()` —— 它会 `initProfile` / `removeLinkProjections` / `normalizeShippedProfile`,**会写 profile 目录**)
|
||
|
||
```js
|
||
// 只在 runtimeDir 下跑(否则解析不到官方包)
|
||
import { loadProfileDirectory, composeEntries }
|
||
from 'file:///<runtimeDir>/node_modules/@deepseek-ai/dsh-app-boot/lib/index.js'
|
||
const p = loadProfileDirectory('dsh', <profileDir>, <desktopHost>/package.json) // ⛔ 不是 loadProfile
|
||
const flat = (rows) => rows.flatMap(r => [r, ...(r.group && Array.isArray(r.config) ? flat(r.config) : [])])
|
||
const entries = flat(composeEntries([...p.layers.map(l => l.patches), p.patches], m => console.warn(m)))
|
||
console.log(entries.find(e => e.id === 'ai1net')?.config) // ⇒ 应看到 { deviceAccess: {…} }
|
||
```
|
||
- 打印出来的 `config` **就是**官方装载器实际会喂给 `apply()` 的东西 ⇒ 它对了,就**排除**"配置没送到",别再折腾配置文件。
|
||
- ⚠️ id 定向覆盖要能命中 **bundle 自己 `insert` 时声明的 id**(本例插件自带 `cordis.patch.yml` 里 `- insert: - id: ai1net`)——id 对不上就是静默不生效。
|
||
|
||
**第 2 步 · 插件到底装载没有?**(不用读日志)
|
||
|
||
打**插件自己的**一条精确路由,用 **`404` vs `401`** 区分:
|
||
|
||
| 返回 | 含义 |
|
||
|---|---|
|
||
| `404 not found` | 路由**没注册** ⇒ 插件(host 半)**没装载** |
|
||
| `401 unauthorized` | 路由**注册了**、只是没带会话 ⇒ 插件**已装载** |
|
||
|
||
⚠️ 这一步能免掉大量"瞎猜插件没挂"的时间。
|
||
|
||
**第 3 步 · 才去查运行期**(动态发现 / `listen`)。若模块的日志面**进不了客户端日志**(实测过 `grep` 零命中),就在入口侧**落一行自己的判定**。
|
||
|
||
### 6b.3 🔴 「永远修不好」的组合:开机一次性发现 + Electron 单实例锁
|
||
|
||
- 模块若在**开机那一刻**做上游动态发现、失败即 **fail-closed 且永不重试**(为守"不轮询上游"而有意如此)⇒ 那一次失败 = **该客户端整条生命周期**都不开口。
|
||
- 守护/看护进程的常规补救是"再拉一份"——**但这在 Electron 上无效**:桌面端有**单实例锁**,第二份**秒退**(`exit 0`),还会把启动器的调试口撞掉。日志表现:
|
||
```
|
||
Starting inspector on 127.0.0.1:9329 failed: address already in use
|
||
[devkit] electron exited: 0
|
||
```
|
||
⇒ **看着像"起不来",其实头一份活得好好的**。别照这条路修。
|
||
- ⇒ **启动入口必须自带幂等闸**(放在 `main()` **最前面**,早于 runtimeDir 重铺/任何写盘):
|
||
1. **口在听** ⇒ **零副作用早退**(验证方法:看 `project-adapt.stale-*` 目录数有没有变多 —— 没变就说明真早退);
|
||
2. **上次拉起在宽限期内(如 90 s)且进程仍在** ⇒ 判"正在启动"、不重复拉;
|
||
3. **旧客户端活着但口不通** ⇒ **先按命令级甄别停掉它**(用本客户端专属 `--user-data-dir` 匹配;⛔ **绝不按进程名杀 `electron.exe`** —— WorkBuddy 自己就是 Electron),**再拉一份干净的**;
|
||
4. 干净 ⇒ 正常拉。
|
||
- 判活凭据:启动时写 `_devlogs/.client-launcher.pid` + `.client-spawn-stamp`(PID 复用靠宽限期 + 命令级甄别兜住)。
|
||
|
||
### 6b.4 三项端到端判据(可照抄)
|
||
|
||
| # | 判据 | 命令要点 |
|
||
|---|---|---|
|
||
| ① | 口在听 | `netstat -ano \| grep ":20090 " \| grep LISTENING` |
|
||
| ② | 自检端点 | `curl -s http://127.0.0.1:20090/<selfcheck 路径>` ⇒ `ok:true` + `credential.present:true`,⚠️ **⛔ 不得回显口令** |
|
||
| ③ | **与直连上游逐字节相等** | 从 selfcheck 里取上游口 ⇒ 同一路径**直连上游**再取一次 ⇒ 两侧 `json.loads` 后 `json.dumps(..., sort_keys=True)` **字符串相等**(比"条数相同"强得多) |
|
||
| ④ | 幂等 | 再调一次入口 ⇒ 应**早退**且不新增客户端进程 |
|
||
|
||
⚠️ 口令纪律:直连上游要带凭据头时,**用 shell 变量引用**(`-H "x-access-token: $ENV_NAME"`),⛔ 别把明文写进命令行历史/文件/日志。
|
||
|
||
---
|
||
|
||
## 6c. 🔴 起法:**headless host(普通 node,⛔ 不弹 Electron 窗口)+ 计划任务常驻**(2026-09-30 实测)
|
||
|
||
> 场景原型:让 `:20090` 那台垫片**脱离会话常驻**。要点:**垫片不是一个能单独起的东西** —— 它是 **DSH host 进程里的一个 cordis 插件** ⇒ 「让垫片常驻」≡「让 **DSH host** 常驻」。
|
||
> 配套:`E:/ProgramData/AIProject/ai1net-dsh-desktop/.workbuddy/_devkit/wb-shim-host.mjs`(可用现成件,⛔ 别重写)。
|
||
|
||
### 6c.1 🔴 结论:`apps/desktop-host` 可以用**普通 node** 直接起(无需 Electron)
|
||
|
||
**证据(不是推断)**:
|
||
1. `@deepseek-ai/dsh-desktop-host` 的 `src/` 与 `lib/` 里**零处 `electron` 引用**;其 package.json 的 description 原文就是 **"Private Node-mode host process for the Electron desktop application"**;
|
||
2. 官方壳自己也是把它当**独立 node 子进程** spawn 的 —— `apps/desktop/src/host-process.ts:188-190`:
|
||
`entry = join(runtimeDir,'node_modules','@deepseek-ai','dsh-desktop-host','lib','index.js')`,
|
||
`spawn(this.node, ['--expose-internals', entry, runtimeDir, projectDir, primaryRuntime, pnpm, nodeBinDir])`(`this.node` = 以 `ELECTRON_RUN_AS_NODE=1` 运行的 electron ⇒ 语义上就是 node);
|
||
3. 入口守卫是 `if (import.meta.main)` ⇒ ⚠️ **要确认 node 版本支持它**(Node 22.22.2 / 24.19.0 实测均为 `true`;更老的 node 会**静默不执行 `main()`**、进程立刻退出且零输出)。
|
||
|
||
**因此 headless 起法 = 逐字照搬官方壳的 spawn 形状,只把 executable 从 electron 换成 node**:
|
||
|
||
| argv | 值 |
|
||
|---|---|
|
||
| `[2]` runtimeDir | `<APP_ROOT>/.desktop-build/development/project-adapt`(= `DSH_DESKTOP_DSH_DIR`;host 的包集合根) |
|
||
| `[3]` projectDir | **`$DSH_HOME/profiles/desktop`**(`apps/desktop/src/paths.ts:19` ⇒ `join(resolveDshHome(),'profiles','desktop')`)**⛔ 不是 runtimeDir** |
|
||
| `[4]` primaryRuntime | `<APP_ROOT>/.desktop-build/targets/<target>/runtime/primary-runtime`(未打包启动**必需**,缺 ⇒ 官方直接 throw) |
|
||
| `[5]/[6]` | 内置 pnpm 入口 + `node-bin` 目录(可选;运行时装插件才用) |
|
||
|
||
**必需 env(少一个就"起得来但不出效果")**:`DSH_HOME` · `TEMP`/`TMP`(一并把 dsh 的临时产物挪走)· `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` · 🔴 **`CODEBUDDY_SAFE_DELETE_BULK_THRESHOLD`**(默认阈值是给"AI 会话删用户文件"设计的,而 dsh 启动**正常**就会删 profile 锁等 ⇒ 不调高会被判成危险批量删除,表现为"**进程起了、口永远不开、且零报错**")。
|
||
|
||
### 6c.2 ⚠️ 一条**半对**的常见说法:「没有口令也不妨碍它先起来」
|
||
|
||
**要分两件事**(`device-access.js` 的 `apply()` 起始顺序):
|
||
- ✅ **口令缺失**确实**不阻止 `listen`** —— 它只让每个请求回 `503 gateway-token-unavailable`;
|
||
- 🔴 但 `upstream=workbuddy` 模式是 **「先发现、后监听」且 fail-closed**:**网关发现失败 ⇒ 永不监听**(⛔ 不沿用上次端口、⛔ 不先占口再说)。
|
||
⇒ 所以"起不来的真凶"通常是**动态发现**那一环,而不是口令。判据也据此分两条:`20090 有没有 LISTENING`(发现是否成功)/`selfcheck.credential.present`(口令是否就位)。
|
||
|
||
### 6c.3 durable:交给 **Windows 计划任务**(⛔ 不是 `detached`)
|
||
|
||
- 🔴 **`detached:true` 在本机无效**:会话进程落在 Job 对象里且 `CREATE_BREAKAWAY_FROM_JOB` 实测 `err=5`(`ERROR_ACCESS_DENIED`)⇒ **逃不出会话**。
|
||
- ✅ 唯一有效机制 = **计划任务**(Task Scheduler 持有 ⇒ 父链 `node ← cmd ← svchost ← services ← wininit`、`workbuddyAncestor=null`);`LogonTrigger` ⇒ 重启自恢复;`MultipleInstancesPolicy=IgnoreNew` + `ExecutionTimeLimit=PT0S`(常驻监督不被 OS 砍)。
|
||
- **动作形状**:`C:\Windows\System32\cmd.exe /c "<entry>.cmd"`,由 `.cmd`(**纯 ASCII**)定位 node 再转交 `.mjs`。
|
||
- ⚠️ **`schtasks` 在本机"会话命令行直接调"会被拦**(`This block cannot be approved or bypassed…`)⇒ **一律经脚本 spawn `schtasks`**(实测 `--install/--query/--run/--delete` 全 `rc=0`)。
|
||
- ⚠️ **`schtasks /Create /XML` 的编码坑**:XML 声明是 `UTF-16` ⇒ 必须 `Buffer.concat([Buffer.from([0xFF,0xFE]), Buffer.from(xml,'utf16le')])`;`writeFileSync(..., 'utf16le')` **不写 BOM** ⇒ 报「XML 格式错误 (1,2) 一个无效元素」。
|
||
- ✅ **幂等闸照样要有**(口在听 ⇒ 零副作用早退),因为下次登录/`--run` 会再跑一次。
|
||
|
||
### 6c.4 判据(四枪,⛔ 不许只看"进程在")
|
||
|
||
| # | 判据 | 要点 |
|
||
|---|---|---|
|
||
| ① | 口在听 | **⛔ 只认 `netstat`**(本机 Proxifier 会代理回环 ⇒ `curl`/`connect` 会假阳) |
|
||
| ② | 自检 | `GET /<selfcheck>` ⇒ `credential.present=true` 且 `source` 为 `env:…` 或 `inbound` |
|
||
| ③ | 经垫片打上游 | `GET /api/v1/health` ⇒ **非 401**;再打一条**上游同路径**(如 `/api/v1/info`)⇒ 与**直连上游**对照(直连不带凭据应 `401`)⇒ 证"凭据真被带上并转发成功" |
|
||
| ④ | 起法是否 durable | 说清挂在**哪个持久机制**上(计划任务/启动文件夹)+ 给出**父链**(应无 `WorkBuddy` 祖先) |
|
||
|
||
### 6c.5 两条新坑(会误导判断)
|
||
|
||
- 🔴 **`electron_run_as_node` / 任何"同组键"在子进程 env 里要"枚举删"**(用点号读写会**假阴性**)。headless 起法本身不需要它,但**不删干净**会让别的分支误判。
|
||
- ⚠️ **用 `Get-CimInstance … -match '<关键串>'` 数进程会被"探针自己的命令行"污染**(探针脚本文本里含该关键串 ⇒ 计数虚高/把 powershell 自己算进来)⇒ 判**唯一性**要看**父链**,⛔ 不能只看计数。
|
||
|
||
## 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 护栏)。
|
||
- ⚠️ Windows 上 `taskkill /F` 会被 MSYS **路径改写**成 `taskkill 'F:/'` ⇒ 报"无效参数/选项 - 'F:/'"且**进程没被杀**。加 `MSYS_NO_PATHCONV=1` 前缀(或写 `//F`)。取 PID 别用 `awk '{print $5}'`(netstat 列数不稳),用 `tr -s ' ' | cut -d' ' -f6`。
|
||
|
||
### 11.6 真 host 的 RPC 调用(token→cookie + **信封口径**)
|
||
|
||
实例起来后,很多"运行时到底挂没挂"的问题只有 RPC 能答(见下"为什么日志不够")。
|
||
|
||
```js
|
||
// ① 换证:token URL 拿 cookie(照官方 welcome-backend.ts)
|
||
const exchange = await fetch(`${HOST}/?token=${TOKEN}`, { redirect: 'manual' }) // 303
|
||
const cookie = (exchange.headers.get('set-cookie') ?? '').split(';')[0]
|
||
// ② 调用:POST /api/<ns>/<method>
|
||
body: JSON.stringify({ type: 'client-request', rpcId, method: '<ns>/<method>', payload: { args } })
|
||
```
|
||
|
||
🔴 **`payload.args` 必须是「一个普通对象」(命名参数),传数组会被拒**:
|
||
`{"ok":false,"error":{"code":"gateway/internal","message":"Remote payload must contain exactly one plain-object args field"}}`
|
||
(校验在 `packages/api/gateway/src/index.ts:1131`)。
|
||
⚠️ 命名键名**不总是** TS 形参名 ⇒ 拿不准就**按候选形状逐个探**,命中的打印出来复用。已实测两条:
|
||
- `settings/describe` → `args = {}`
|
||
- `settings/mutate(ns, ops, expectedRevision)` → `args = { ns, ops, expectedRevision }`
|
||
|
||
🔴 **为什么日志不够**:`$DSH_HOME/logs/startup-*.log` 与启动 stdout **只记录失败条目**(`dsh: warning: N entries did not activate` + 失败原因),
|
||
插件自己 `ctx.logger.info` 打的"已挂载 X"**不会出现在里面** ⇒ ⛔ 别把"日志里没有"当成"没挂上"的判据。
|
||
✅ 要证运行时挂载,用 ① RPC 读配置 / 工具面 ② 启动 stdout 有无**新增**失败条目 ③ 宿主级假 ctx 单测(见 §6.4)三条之一。
|
||
|
||
🔴 **写路径的"真"证明**:对 `settings/mutate` 真写一次(如 `{op:'set',path:['agents','1'],value:…}`)——官方在
|
||
`configEditor.edit()` 后会 `reconcileProfilePatches(..., requiredIds:[<被写的 ns>])`,**若该插件写后没有重新激活,这次写会直接抛错** ⇒
|
||
「写成功」本身就是"补丁已落盘 + 插件已重启 + 新配置已生效"三件事的合成证据。
|
||
⚠️ mutate 的每个 op 路径都要过 `isVolatilePath`(`settings/src/schema.ts:74`:**只要祖先标了 `.volatile()` 就放行**),否则报 `Config field "…" is not volatile`。
|
||
|
||
### 11.7 自研 host 插件要对外暴露只读路由(client 半要读宿主事实时)
|
||
|
||
场景:插件 host 半拿到了**只有宿主进程才知道**的事实(本机文件是否存在、子进程起不起得来、挂载成没成),要在界面上如实显示。桌面 host **没有 webServer**,唯一可挂服务端逻辑的扩展点就是官方 `connection.fetch.register`(`@deepseek-ai/dsh-client-connection`)。
|
||
|
||
🔴🔴 **第一条:服务只能靠 `inject` 拿(本轮实测踩过,代价 = 路由静默 404)**
|
||
- 未在 `inject` 声明的那条服务,`ctx.connection` **直接抛** `cannot get property "connection" without inject`(cordis `ReflectService.handler.get`)。
|
||
- `ctx.reflect.get('connection', false)`(cordis 自己的注释写着 "Read a service from the store **without the inject requirement**")在本 fiber 上**返回 undefined** —— 那个 store 只装**本 fiber 已注入的**服务。
|
||
- ⇒ **只写防御式 `try { ctx.connection } catch {}` 是错的**:异常被吞、路由没挂上、客户端拿到 404,而 `ctx.logger.warn` 又**不进正常启动的 stdout** ⇒ 全程零提示。
|
||
- ✅ 正解:`export const inject = ['connection']`(官方 `file-upload` / `ui-deliverables` 与自研 `portal-first-screen` 全部如此)。
|
||
- 📌 **诊断姿势**:host 插件里**临时 `console.log`** 打到启动 stdout(`ctx.logger` 的话只在启动**失败**时才落 `$DSH_HOME/logs/startup-*.log`),一次就能看出 `apply` 有没有跑、服务取没取到、`register` 是什么类型。
|
||
|
||
🔴 **第二条:`register` 只支持精确路径**
|
||
Map 按 `url.pathname` 命中,**无前缀匹配**(`rpc-host.ts:130-148`);路径必须以 `/api` 开头,且**同路径重复注册会抛** `already registered`。⇒ 自研包用**专属前缀**(如 `/api/<pkg>/…`),⛔ 不碰实例自己的 `/api/*`。
|
||
|
||
🔴 **第三条:client 侧要用「去前导斜杠的相对写法」**
|
||
官方口径 = `PRESENT_HOST_ROUTE = PRESENT_HOST_PATH.slice(1)`(`presented.ts`,附 `.agents/notes/…/web-document-relative-app-routes.md`)⇒ 渲染层写 `fetch('api/<pkg>/status')`,⛔ 不要拼绝对 origin、⛔ 不自造 `dsh-app://` URL。
|
||
|
||
✅ **验收姿势(可照抄)**:boot 一个自建 profile 的实例(空闲口,⛔ 别碰用户自己的实例)→ token 换 cookie(§11.6)→ `GET /api/<pkg>/status` 带 cookie → 断言**具体取值**而不是"没报错"。⚠️ 路由在 Connection 的鉴权栅栏内 ⇒ **必须带 cookie**,匿名请求拿不到 200。
|
||
⚠️ 只读路由的**存在性**本身也要断言:本轮第一版探针里唯一 FAIL 的就是 `HTTP 404`(不是"值不对")—— 这种失败最容易被误读成"功能没实现"。
|
||
|
||
### 11.8 🔴🔴 自研 client bundle 必须是 **lazy-CJS 封包**(⛔ 手写 ESM 直交必崩)
|
||
|
||
**症状(会在页面顶部直接报)**:
|
||
```
|
||
Failed to load plugins
|
||
@dsh-client/<pkg>: import failed (see console for the import error)
|
||
```
|
||
console 里的真错误是 `SyntaxError: Cannot use import statement outside a module`。⇒ 插件**看起来被加载了、宿主侧一切正常,但界面上那个卡片根本不出现**。
|
||
|
||
**根因**:`lib/client.js` 被浏览器当**经典脚本**加载 ⇒ 顶层 `import` 非法。官方 client bundle 一律是 **lazy-CJS 封包**,⛔ 不是 ESM。
|
||
|
||
**契约(照抄形态)** —— 对照实现:官方 `@deepseek-ai/dsh-client-ui-settings-subagent/lib/client.js` | 同仓 `packages/adapter-mobile/lib/client.js`:
|
||
```js
|
||
;(function () {
|
||
var id = '@dsh-client/<pkg>'
|
||
var factory = function (require) {
|
||
var module = { exports: {} }
|
||
var exports = module.exports
|
||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
||
var react = require('react') // ← 外部依赖一律在 factory 内 require
|
||
var h = react.createElement
|
||
var primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
||
/* …模块体保持原坐标… */
|
||
exports.apply = apply
|
||
exports.inject = inject
|
||
return module.exports
|
||
}
|
||
var host = typeof window !== 'undefined' ? window.__ModuleLoader__ : undefined
|
||
if (host && typeof host.load === 'function') { host.load({ id: id, factory: factory }); return }
|
||
factory(function (name) { throw new Error('[<pkg>] 零内联依赖,却被要求 require:' + name) })
|
||
})()
|
||
```
|
||
**刚性约束**:零顶层 `import` / 零顶层 `export` / 零顶层 `require`。
|
||
转换量通常很小:把顶层 `import { a as b, c } from 'x'` 改成 factory 内的 `var x = require('x')` + 逐字段取;
|
||
把 `export const inject = …` / `export function apply(…)` 改成普通声明 + 末尾 `exports.x = x`。**模块体坐标可原样不动**(不必重排缩进)。
|
||
|
||
🔴🔴 **推论(比这个缺陷本身更重要)**:`node --check` ✅ +「client bundle 可组合」✅ +「boot 本包零报错」✅
|
||
—— **三条全绿也证明不了客户端能加载**,因为它们**都不经过浏览器的脚本解析**。
|
||
⇒ **UI / 界面类交付必须有「浏览器级判据」,⛔ 不能靠静态检查替代。**
|
||
|
||
**浏览器级判据姿势(照抄可跑,本轮实测有效)**:
|
||
1. 起一个**自建 profile** 的 web 实例(空闲口,⛔ 别碰用户自己的实例)→ 取 token。
|
||
2. `fetch('/?token=…', { redirect: 'manual' })` ⇒ `303` + `Set-Cookie` 拿到 cookie(§11.6)。
|
||
3. **用系统 Edge 起 playwright**:`chromium.launch({ channel: 'msedge' })`(退路 `channel:'chrome'`)。
|
||
⛔ **不要 `chromium.launch()` 裸跑** —— playwright 自带浏览器常未下载,而 `npx playwright install`
|
||
会把 ~150 MB 落到 `C:\Users\…\AppData\Local\ms-playwright`(本机纪律:⛔ 不落 C/D 盘)。
|
||
⚠️ playwright 装在 managed node 工作区时,**ESM 不认 `NODE_PATH`** ⇒ 必须用
|
||
`createRequire(import.meta.url).resolve('playwright')` 或绝对 `pathToFileURL(...)` 动态 import。
|
||
4. `page.goto(base)` → 断言正文含目标卡片文案,并**统计 JS 错误数**(`page.on('pageerror')` + console error)⇒ 期望 **0**。
|
||
5. 🔴 **首启「内测声明」弹层会挡住导航** ⇒ playwright 的 actionability 检查会让 `el.click()` **超时**
|
||
(报 `elementHandle.click: Timeout`)。✅ 绕法 = **程序化点击**:
|
||
`page.evaluate(...)` 里 `document.querySelectorAll(...)` 找到元素后直接 `el.click()`(不走 hit-testing)。
|
||
⛔ 别把这误判成"入口不存在"。
|
||
|
||
|
||
---
|
||
|
||
## 变更历史(原 frontmatter · 逐字保留)
|
||
|
||
```text
|
||
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.3.1
|
||
updated_at: 2026-09-26
|
||
last_change: 【2026-09-26 新增 §4.3「桌面快捷方式链路(`DSH 客户端.cmd`)实修」—— 🔴 桌面薄壳与 devkit `DEVKIT` **两级路径同时写错**(`dsh-ai1net-desktop` 词序反 + `E:\ProgramData` 串根;本机规范工作区 = `E:\ProgramDSH\AIProject\ai1net-dsh-desktop`)⇒ 静默失败 `系统找不到指定的路径。`、退码 1、什么都不发生;处置 = 薄壳加 `if not exist` 守卫 + `DEVKIT` 改 `%~dp0` 自派生 ⇒ 工作区改名/搬家不再断;🔴 **`.cmd` 必须 CRLF**(LF-only + 长注释 ⇒ cmd 按行定位漂移,把 REM 行尾巴当命令执行,吐一屏 `'rtcut' 不是内部或外部命令` 类碎片报错,**但功能仍能起来** ⇒ 极易误判);附「不弹窗的链路探针」验证姿势与两条无害噪音】此前 【2026-09-25 新增 §11.8「自研 client bundle 必须是 lazy-CJS 封包」—— 🔴🔴 手写 ESM 直交 ⇒ 浏览器 `SyntaxError: Cannot use import statement outside a module` ⇒ `import failed` ⇒ 页面报 `Failed to load plugins` 且**卡片根本不出现**(UI 类交付的血泪教训)+ 完整封包契约与转换量评估 + **推论:`node --check`/bundle 可组合/boot 零报错三条全绿也证明不了客户端能加载 ⇒ UI 交付必须有浏览器级判据** + 浏览器级判据姿势(系统 Edge `channel:'msedge'` 而非下载自带浏览器/ESM 不认 NODE_PATH/首启弹层挡导航 ⇒ 程序化点击绕 actionability)】此前 【2026-09-25 新增 §11.7「自研 host 插件对外暴露只读路由」—— 🔴 cordis 服务只能靠 `inject` 拿(未声明即抛 `cannot get property … without inject`,而 `ctx.reflect.get(name,false)` 返回 undefined ⇒ 只写 try/catch 会让路由**静默 404**)+ `connection.fetch.register` 只支持精确路径 + client 侧要写「去前导斜杠的相对写法」+ 正常启动时 `ctx.logger` 不进 stdout(要诊断就临时 `console.log`)】此前 【2026-09-25 新增 §11.6「真 host 的 RPC 调用」——信封口径(`payload.args` 必须是普通对象,传数组被拒)+ 命名参数形状实测 + 「启动日志只记失败条目,别据此判没挂上」+ 写路径的合成证据(写成功 = 补丁落盘 + 插件重启 + 生效);§11.5 补 Windows `taskkill` 被 MSYS 改写成 `F:/` 的坑】此前 【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
|
||
```
|
||
|
||
|
||
---
|
||
|
||
## 变更历史(**对侧副本** frontmatter · 逐字保留 · 来自 `dsh-desktop-dev-shell` 的 文档库 版)
|
||
|
||
> ⚠️ 本档正文取自**另一侧**(超集);此处补上对侧副本的版本史,⛔ 以保证不丢任何事实。
|
||
|
||
```text
|
||
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
|
||
```
|