Files
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

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

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

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

28 KiB
Raw Permalink Blame History


name: dsh-desktop-dev-shell description: 在 Windows 本机把官方 dsh 跑起来并完成端到端取证 —— 覆盖两个形态:① web 实例(apps/cli 的 dsh CLI,起实例 / 查回环监听 / token+cookie jar 取 200 / 探测 Windows 沙箱后端)② 桌面壳(apps/desktop,需源码构建,且能挂自研 client 插件做真跑验证,含离线可用验证:黑洞服务造"挂起"、有界超时、如实状态)。⚠️ 作用域 = 本机源码/开发态(与 dsh-instance-diagnose 的边界:服务器上的用户实例属对方)。当出现「本机 dsh 实例起不来」「壳起不来」「plugin tree failed to load」「atomic-write: timed out waiting for the writer lock」「curl 实例页 404 / 401 / 303」「怀疑实例内工具被整体拒绝 / refusing to run the command unconfined」「Electron 一出窗口就 FATAL」「pnpm 卡在 added 一百多不动」「pnpm run 每次都重装」「dev:desktop 起不来」「装了插件但界面没变化」「启动器日志停在 [devkit] release= 不动」、**或「跑起来不是登录注册页 / 跟服务器版本不像一套」**时使用;也用于判断"壳能不能离线用 / 启动链有没有网络依赖",在有 safe-delete 护栏的机器上删除 node_modules 这类巨型树,或需要绕开 pnpm 直接构建这个 monorepo 的场景。 version: 1.0.0 updated_at: 2026-09-22 last_change: 【2026-09-22 按要求统一版本号】frontmatter version → 1.0.0(原 v2.0.1);正文与历史中的版本号为当时记录,未改动。此前 v2.0.1(2026-09-22):触发词加「本机」限定并写明与 dsh-instance-diagnose 的作用域边界 —— 实测「实例起不来」原本会撞(两技能都像命中,且偏向本技能),用户其实指服务器实例时不该加载这里。此前 v2.0.0(2026-09-21):并入原 dsh-windows-run-web-instance(该技能已随之删除,其内容现为 §11)—— 两形态共用一条判据链(Node 22 / 监听≠就绪 / token 栅栏 / 陈旧写锁)。 agent_created: true

dsh-desktop-dev-shell — 在 Windows 上跑起官方 dsh 并做真跑验证

0. 为什么存在

@deepseek-ai/dsh 的运行时可以 npx 直跑,但桌面壳(apps/desktop + apps/desktop-host)是 private: true、未发布 npm ⇒ 只能克隆源码构建。这条路在 Windows 上有一串看起来像"方案不行"、其实是环境的坑,逐条定位一次要花几小时。本技能把这些坑与处置固化下来(含 web 实例形态,见 §10)。

1. 两种形态:先选对入口

官方 dsh 在 Windows 本机有两种跑法,本技能两条都管。先判定你要哪一种,再跳到对应章节:

形态 入口 有没有 HTTP 端口 适用章节
web 实例 apps/cli 的 dsh --profile web ✅ 有(回环 + token 栅栏) §11
桌面壳 apps/desktop(源码构建) ❌ 不开监听端口(Electron 管道 + dsh-app://) §2–§10

两形态共用的判据链(无论走哪条都先过一遍):

  1. 必须 managed Node 22 —— 原生模块按 Node 22 编译,用 24 会 ERR_DLOPEN_FAILED。
  2. 监听 ≠ 就绪 —— 插件层晚于端口挂载,起听后立刻访问会拿到 404 / 拒连 ⇒ 轮询到成功响应再判。
  3. 陈旧写锁会让实例"永久起不来" —— 实例被中途掐断留下 <DSH_HOME>/.credentials.yaml.lock ⇒ 之后同一 DSH_HOME 每次启动都 exit 1(报 plugin tree failed to load + atomic-write: timed out waiting for the writer lock)。 ✅ 处置 = 换全新 DSH_HOME(立刻恢复)或启动前清掉该 .lock。别改官方源码。 ⇒ 探针 / 守护脚本一律「每次全新 DSH_HOME + 就绪前不掐断」。
  4. ELECTRON_RUN_AS_NODE=1 可能就在环境里 ⇒ 不删掉的话 Electron 退化成纯 Node、不出 GUI(像"壳起不来")。

⚠️ 形态选错的典型症状:去 curl 桌面壳的 HTTP 端口找页面 —— 壳压根不开端口(见 §2 第 1 条)。

2. 桌面壳:三条必须先知道的判断

  1. 壳不开监听端口。 官方 README 原文:"It opens no listening port" ⇒ Electron 主进程与内置 Node 子进程走带版本的定长帧管道,渲染层用 dsh-app:// 取资源。 ⇒ ⛔ 别去 curl 壳的 HTTP 端口找页面(没有);要验渲染层请用 --remote-debugging-port。
  2. 开发态里 DSH_DESKTOP_DEV_PROJECT_DIR 直接充当 profile。 main.ts 里 activeProject = development ?? paths.profile ⇒ 跳过 project-manager 的 staging / healthCheck / activate 事务。 ⇒ 想挂插件,自己造 profile 目录,别指望走事务。
  3. ⛔ 别用 grep -nE "https?://" <源码> 判"有没有网络依赖" —— 它会结构性漏掉 electron-updater: 它的 feed 地址在打包产物的 app-update.yml 里,源码里一个 URL 字面量都没有(本项目因此错下过一次结论)。 ✅ 正确口径(本线实测,逐处读码得来):壳的启动链只有三类碰网 —— ① main.ts 启动后 10 s 自动 checkAndPrompt(false) → electron-updater:三重可容忍 (enabled() 要求 app.isPackaged 且 resourcesPath/app-update.yml 存在 ⇒ 开发态/未配 publish 的包零网络; 用 void 发起不阻塞窗口;全程 try/catch + manual=false ⇒ 静默失败不弹窗) ② project-manager.ts 的 DESKTOP_REGISTRY='https://registry.npmjs.org/' ⇒ 只在装/更新插件时(离线装不了插件) ③ host-process.ts 的 async fetch(request) 是进程内 fd 转发(壳↔host),⛔ 不走网络 ⇒ 结论:"启动 → 出窗口 → 实例页"这一段不依赖网络,可以做成离线可用。 ⚠️ 但装成正式包并配了 app-update.yml 之后,10 s 那次检查会真的发请求 ⇒ 届时应复测。
  4. dev.ts 每次启动都重写 profile 的 package.json。 所以手工挂进去的插件会被抹掉。 ⇒ 要么每次重新挂,要么别用 dev.ts(见 §4)。

3. 依赖安装:pnpm 在本机"卡在 added ~100"的真根因

症状:pnpm run <任意脚本> 都先跑一遍安装,然后卡在 added ~104 十几分钟不动(downloaded 0,包全在 store 里)。

根因(三条叠加,缺一不可):

  1. pnpm 的 before-run 安装钩子默认开启 ⇒ 每次 pnpm run 都先做依赖校验,判定"需装"就真的装。
  2. 该次安装要按虚拟 store 重建已有的 node_modules;若 .pnpm 是上一轮失败安装留下的半成品,这一步在本机退化成每包数秒。 ⚠️ 不在网络、不在磁盘、不在 pnpm 版本(11.7 与 11.21 一样中招)—— 别在这三个方向浪费时间。
  3. 若上一轮用过非默认 virtual-store-dir,顶层符号链接会全指向那棵树,并留下 .pnpm-workspace-state-v1.json 记录该配置 ⇒ 任何不带该配置的 pnpm run 都被判"配置变了要重建" ⇒ 死循环。 🔴 附带后果:官方 dev.ts 的 dependencyDir 写死 node_modules/.pnpm/node_modules ⇒ 非默认虚拟 store 会让官方 dev 流程直接失效。

处置(三步):

# ① 把损坏/陈旧的树【改名移走】——⛔ 不是删除
mv node_modules/.pnpm        <外部目录>/pnpm-old
mv node_modules/.pnpm-new    <外部目录>/pnpm-new
mv node_modules              <外部目录>/node_modules-old
# ② 空目录上做默认布局安装
pnpm install --frozen-lockfile --config.minimumReleaseAge=0
# ③ 此后所有 pnpm 调用都带这一条(⭐ 能透传到嵌套 pnpm run,无需 shim)
pnpm --config.verify-deps-before-run=false run <script>
  • ⚠️ 两处必须解释清楚:
    • minimumReleaseAge(供应链校验)在 npmmirror 上会超时并报 … entries failed verification ⇒ 不是真实版本违规,必须 --config.minimumReleaseAge=0。
    • 非标准 virtual-store-dir 会让 pnpm 判"要 purge",无人值守下 ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY。⛔ 别用 CI=true 绕过 —— 那会真的 purge。
  • ⚠️ rm -rf node_modules 会被环境的 safe-delete 护栏拦下(报 SAFE_DELETE_BULK_CONFIRM_REQUIRED)⇒ "删除完成"是假象,你还在同一堆半成品上重试。⇒ 这就是为什么要用 mv 改名而不是删。 护栏机制全文 ⇒ §9(阈值、正规批量通道、Windows 上删 pnpm 树必失败的坑)。
  • ✅ build:native-system 在 Windows 上是空操作(native/system/07-scripts/build.ts:平台非 linux/darwin 且带 --host-addon-only ⇒ 直接 exit(0))⇒ 不需要 MSVC / Python / Windows SDK。别被"打包前置要求"吓住。
  • ⚠️ Electron 二进制不在 npm 包里:全新安装后 node_modules/.pnpm/electron@*/node_modules/electron/ 下没有 dist/ 与 path.txt(该包未列入 allowBuilds ⇒ pnpm 不跑它的脚本)⇒ 会看到 "Downloading Electron binary..." 然后卡死/失败。手动补齐:从 npmmirror 拉 electron-v<ver>-win32-x64.zip ⇒ 解压进 dist/ ⇒ 写 path.txt(内容就是 electron.exe)。

4. 启动:不要用 pnpm run dev:desktop

两个理由:

  1. dev:desktop = pnpm --filter … run dev ⇒ 两层 pnpm run = 两次 before-run 安装钩子。
  2. dev.ts 每次重写 profile ⇒ 挂的插件被抹掉。

做法:自建一个启动器(放在工作区外或本工作区的 .workbuddy/ 下,⛔ 不进官方源码树),步骤:

  1. 复用官方 apps/desktop/07-scripts/development-project.ts 的 prepareDevelopmentProject(),但把 projectDir 指到自己的目录(如 …/.desktop-build/development/project-adapt),与 dev.ts 的 project/ 互不干扰。
    • release 需要:{ schemaVersion:1, version:<apps/desktop 版本>, hostProtocolVersion:<从 src/host-protocol.ts 读>, nodeVersion:process.versions.node, pnpmVersion:<apps/desktop/node_modules/pnpm 的版本> }
    • ⚠️ 该模块只依赖 node 内置 + 本地 ts,不牵连 electron,可以安全 import。
  2. 改 profile 的 package.json,把插件名 push 进 dsh.profile.bundles(basis 的两项 @deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app 必须在前)。
  3. 把插件包 junction 进 profile 的 node_modules/<scope>/<name>。
  4. 直接 spawn(electron, [...]),参数见 §5。
    • require('electron') 要用 createRequire(<apps/desktop/package.json>) 才解析得到。

4.1 🔴 prepareDevelopmentProject() 会偶发卡死在删目录(⛔ 不报错)

  • 症状:启动器日志停在 [devkit] release=… 不动,下一条 [devkit] profile=… 永不出现, 窗口起不来,且没有任何错误输出 ⇒ 极易被误判成"壳坏了 / 方案不行"。
  • 判定(30 秒内可确认):<projectDir>/node_modules 已空,但同层元数据文件 (package.json / pnpm-workspace.yaml / desktop-release.json / desktop.cordis.yml)的 mtime 仍是上一次的 ⇒ 卡在 removeOwnedPath() 的 rmSync(recursive) (development-project.ts 里 removeOwnedPath(options.projectDir) 那一步),还没走到重建。 ⚠️ 这几个文件的 mtime 是最好用的判据:重建会刷新它们。
  • 先排除"junction 遍历误删"(别慌着重装):看 apps/cli / apps/desktop-host 的 mtime 有没有变新、 node_modules/.pnpm/node_modules 条目数有没有掉(正常 ≈ 820)⇒ 都没变 ⇒ 官方树未受损,只是删不动。
  • ✅ 处置 = 移走,不删除:
    taskkill /F /PID <启动器 PID>                      # 先停掉卡住的那个
    mv "<projectDir>" "<projectDir>.stale-<时间戳>"    # mv 在同卷上近乎瞬时
    # 重新启动 ⇒ prepareDevelopmentProject 走 ENOENT 快路径,秒级过
    
    陈旧树留给用户处置(Windows 上删这种树会卡,理由与 §9 同源)。
  • ⚠️ 非必现:同条件下另一次重启(projectDir 存在)顺利通过。⇒ 按"偶发环境坑"记, ⛔ 不要因此去改官方 development-project.ts(那是官方源码,本项目红线 R2)。

4.2 ⚠️ 改插件后必须重启壳

readClientBoot() 之类的读取与模块级常量都在加载时求值 ⇒ 没有热更新。 ⛔ 杀壳只能按端口定位 PID:netstat -ano | grep LISTENING | grep 9333 → taskkill /F /T /PID <pid>。 ⛔⛔ 绝不用 taskkill /IM electron.exe —— WorkBuddy 自己就是 Electron,会把自己杀掉。

5. Electron 启动参数(Windows 本机实测)

// ⛔ 必须先从 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 : () => {} }

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 护栏)。