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

56 KiB
Raw Blame History

在 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 流程直接失效。

处置(三步):

# ① 把损坏/陈旧的树【改名移走】——⛔ 不是删除
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)⇒ 都没变 ⇒ 官方树未受损,只是删不动。
  • ✅ 处置 = 移走,不删除:
    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 本机实测)

// ⛔ 必须先从 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 连接、永不回话),把客户端指向它:

// 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(因为它数的是循环次数)。 ✅ 正确的最小实现:

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 重铺/任何写盘):
    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 基线命令

# 必须用 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)⇒ 必须:
      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)。

// 探针脚本放在开发树之外时,裸包名按「导入方所在目录」解析会失败 ⇒ 用绝对 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(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:

;(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 · 逐字保留)

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