用户令逐字:「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/`)。
56 KiB
在 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 |
两形态共用的判据链(无论走哪条都先过一遍):
- 必须 managed Node 22 —— 原生模块按 Node 22 编译,用 24 会
ERR_DLOPEN_FAILED。 - 监听 ≠ 就绪 —— 插件层晚于端口挂载,起听后立刻访问会拿到 404 / 拒连 ⇒ 轮询到成功响应再判。
- 陈旧写锁会让实例"永久起不来" —— 实例被中途掐断留下
<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 + 就绪前不掐断」。 ELECTRON_RUN_AS_NODE=1可能就在环境里 ⇒ 不删掉的话 Electron 退化成纯 Node、不出 GUI(像"壳起不来")。
⚠️ 形态选错的典型症状:去
curl桌面壳的 HTTP 端口找页面 —— 壳压根不开端口(见 §2 第 1 条)。
2. 桌面壳:三条必须先知道的判断
- 壳不开监听端口。 官方 README 原文:"It opens no listening port" ⇒ Electron 主进程与内置 Node 子进程走带版本的定长帧管道,渲染层用
dsh-app://取资源。 ⇒ ⛔ 别去curl壳的 HTTP 端口找页面(没有);要验渲染层请用--remote-debugging-port。 - 开发态里
DSH_DESKTOP_DEV_PROJECT_DIR直接充当 profile。main.ts里activeProject = development ?? paths.profile⇒ 跳过 project-manager 的 staging / healthCheck / activate 事务。 ⇒ 想挂插件,自己造 profile 目录,别指望走事务。 - ⛔ 别用
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 那次检查会真的发请求 ⇒ 届时应复测。 dev.ts每次启动都重写 profile 的package.json。 所以手工挂进去的插件会被抹掉。 ⇒ 要么每次重新挂,要么别用dev.ts(见 §4)。
3. 依赖安装:pnpm 在本机"卡在 added ~100"的真根因
症状:pnpm run <任意脚本> 都先跑一遍安装,然后卡在 added ~104 十几分钟不动(downloaded 0,包全在 store 里)。
根因(三条叠加,缺一不可):
- pnpm 的 before-run 安装钩子默认开启 ⇒ 每次
pnpm run都先做依赖校验,判定"需装"就真的装。 - 该次安装要按虚拟 store 重建已有的
node_modules;若.pnpm是上一轮失败安装留下的半成品,这一步在本机退化成每包数秒。 ⚠️ 不在网络、不在磁盘、不在 pnpm 版本(11.7 与 11.21 一样中招)—— 别在这三个方向浪费时间。 - 若上一轮用过非默认
virtual-store-dir,顶层符号链接会全指向那棵树,并留下.pnpm-workspace-state-v1.json记录该配置 ⇒ 任何不带该配置的pnpm run都被判"配置变了要重建" ⇒ 死循环。 🔴 附带后果:官方dev.ts的dependencyDir写死node_modules/.pnpm/node_modules⇒ 非默认虚拟 store 会让官方 dev 流程直接失效。
处置(三步):
# ① 把损坏/陈旧的树【改名移走】——⛔ 不是删除
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
两个理由:
dev:desktop=pnpm --filter … run dev⇒ 两层pnpm run= 两次 before-run 安装钩子。dev.ts每次重写 profile ⇒ 挂的插件被抹掉。
做法:自建一个启动器(放在工作区外或本工作区的 .workbuddy/ 下,⛔ 不进官方源码树),步骤:
- 复用官方
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。
- 改 profile 的
package.json,把插件名 push 进dsh.profile.bundles(basis 的两项@deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app必须在前)。 - 把插件包 junction 进 profile 的
node_modules/<scope>/<name>。 - 直接
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)⇒ 都没变 ⇒ 官方树未受损,只是删不动。 - ✅ 处置 = 移走,不删除:
陈旧树留给用户处置(Windows 上删这种树会卡,理由与 §9 同源)。
taskkill /F /PID <启动器 PID> # 先停掉卡住的那个 mv "<projectDir>" "<projectDir>.stale-<时间戳>" # mv 在同卷上近乎瞬时 # 重新启动 ⇒ prepareDevelopmentProject 走 ENOENT 快路径,秒级过 - ⚠️ 非必现:同条件下另一次重启(
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\。两个根下各有一份同构目录树(同名子目录大部分重合),抄路径时极易串根。
- 工作区 =
-
两级路径曾被同时写死、各踩一坑:
- 桌面薄壳写成
dsh-ai1net-desktop(词序反)⇒ cmd 直接系统找不到指定的路径。、退出码 1,什么都不发生(静默失败)。 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 本机实测)
// ⛔ 必须先从 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 APIMoveWindow这条路就没了 ⇒ 只能走上面主进程调试器那条。
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 连接、永不回话),把客户端指向它:
// blackhole-api.mjs —— 只有 net.createServer,连上来什么都不写
const server = createServer((socket) => { socket.on('error', () => {}) }) // ⛔ 不 write、不 end
server.listen(9414, '127.0.0.1')
判据三条(缺一不可):
- 有界:每一次对外调用都要在预算内给出结论(本线:探针 1 200 ms / 代理 6 000 ms / 客户端兜底 8 000 ms)。
- 如实:够不着就显示"离线/单机",⛔ 不许谎称"未登录"(那会诱导用户去点必然失败的入口)。
- 佐证:黑洞侧打印连接数与
still_open峰值 —— 峰值就是"挂起本身", 之后回落则证明超时真的在释放 socket(不是泄漏)。 ⭐ 这条比"壳里看到单机模式"更强:它同时证明壳真的去连了。
⚠️ 全盘端到端验证之外,先写一层"宿主半单测"(造最小 ctx 直接调路由 handler): 秒级、确定、能直接量到毫秒,且能把"离线"拆成几条独立事实分别定位。
6.4 ⚠️ 造假的 ctx 有个必踩的语义坑
Cordis 的 ctx.effect(fn) 是「立即执行 fn 并把返回的销毁函数登记起来」。
插件一般把 register(...) 放在 effect 回调里面 ⇒ 把 effect 写成 () => {}
会导致路由一条都没挂上,而插件日志照报 mounted=9(因为它数的是循环次数)。
✅ 正确的最小实现:
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 目录)
// 只在 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 重铺/任何写盘):- 口在听 ⇒ 零副作用早退(验证方法:看
project-adapt.stale-*目录数有没有变多 —— 没变就说明真早退); - 上次拉起在宽限期内(如 90 s)且进程仍在 ⇒ 判"正在启动"、不重复拉;
- 旧客户端活着但口不通 ⇒ 先按命令级甄别停掉它(用本客户端专属
--user-data-dir匹配;⛔ 绝不按进程名杀electron.exe—— WorkBuddy 自己就是 Electron),再拉一份干净的; - 干净 ⇒ 正常拉。
- 判活凭据:启动时写
_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)
证据(不是推断):
@deepseek-ai/dsh-desktop-host的src/与lib/里零处electron引用;其 package.json 的 description 原文就是 "Private Node-mode host process for the Electron desktop application";- 官方壳自己也是把它当独立 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); - 入口守卫是
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…)⇒ 一律经脚本 spawnschtasks(实测--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 基线命令
# 必须用 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 三条必踩的坑(顺序即排错顺序)
- 监听 ≠ 就绪:HTTP 端口先 listen,插件层随后才挂载 ⇒ 起听后立刻 curl 会拿到 404 / 连接被拒。
✅ 正确姿势 = 轮询到 200(
--host 127.0.0.1时 3~4 s 起听,路由就绪略晚)。 - 实例页不是裸 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)⇒ 必须:三条(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)。
- 判据「实例内 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)。
// 探针脚本放在开发树之外时,裸包名按「导入方所在目录」解析会失败 ⇒ 用绝对 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 能答(见下"为什么日志不够")。
// ① 换证: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(cordisReflectService.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:
;(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 / 界面类交付必须有「浏览器级判据」,⛔ 不能靠静态检查替代。
浏览器级判据姿势(照抄可跑,本轮实测有效):
- 起一个自建 profile 的 web 实例(空闲口,⛔ 别碰用户自己的实例)→ 取 token。
fetch('/?token=…', { redirect: 'manual' })⇒303+Set-Cookie拿到 cookie(§11.6)。- 用系统 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。 page.goto(base)→ 断言正文含目标卡片文案,并统计 JS 错误数(page.on('pageerror')+ console error)⇒ 期望 0。- 🔴 首启「内测声明」弹层会挡住导航 ⇒ playwright 的 actionability 检查会让
el.click()超时 (报elementHandle.click: Timeout)。✅ 绕法 = 程序化点击:page.evaluate(...)里document.querySelectorAll(...)找到元素后直接el.click()(不走 hit-testing)。 ⛔ 别把这误判成"入口不存在"。
变更历史(原 frontmatter · 逐字保留)
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 的 文档库 版)
⚠️ 本档正文取自另一侧(超集);此处补上对侧副本的版本史,⛔ 以保证不丢任何事实。
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