Files
workbuddy_skills/dsh-local-env/references/00-本机跑起来与取证.md
T
admin e03465c398 按用户令提交:把此前未纳管的 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/`)。
2026-10-08 22:29:08 +08:00

612 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 在 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
```