# 开发坑 ## 1. 跨平台与测试 - **Windows 检出是 CRLF**。`core.autocrlf=true` 下 fixture 在盘上是 CRLF,所以对正文做逐字比较 的断言要先按 EOL 归一化(`test/index.test.js` 里那条就是),否则 `npm run check` 会在一台机器 上红、在 CI(ubuntu)上绿。 - **断言里不要写死另一个平台的路径写法**。CI 跑 ubuntu、本机跑 Windows,而 `D:\shared` 在 POSIX 上**不是**绝对路径 —— 它会被当成相对 cwd 的条目,于是断言变成「开发机绿、CI 红」。要断言 「绝对条目不被 cwd 影响」,就用 `join(tmpdir(), …)` 现拼一个本平台绝对路径 (`test/discover.test.js` 的 `~` 用例就是这么写的)。 - **反过来的坑同样存在**:POSIX 上恒真的断言可以在 Windows 上为假。`path.isAbsolute('\\\\server')` 在 win32 上为 `true`(`\\\\server\\share` 经 `path.resolve` 还会带上尾部分隔符),所以 `isPortableAbsolute('\\\\server')` 的期望值必须按平台区分,不能写成 `false`。 只在一个平台成立的行为放进 `{ skip: process.platform !== 'win32' }` 的用例,或让期望值由 `process.platform` 决定。 - **受限沙箱里 `node --test` 的并行 runner 会 EPERM**(spawn 子进程被 pipe 限制挡住)。逐个文件 直接跑即可,见 [development.md §2](./development.md)。 ## 2. 插件行为 - **注入顺序不能改回 splice**。`agent/pre-step` 是 waterfall,而所有注入监听器都插在 **同一个位置**(已领取消息之后),所以**谁最后跑谁占前面**。本行在 host 平面、先于 preset 挂载注册,用 splice 会把 `.github` 规则排到 AGENTS.md **前面**。 现在改成**追加到末尾**,顺序与注册顺序无关。 - **profile patch 层不热重载**。装完/改完 `cordis.patch.yml` 要重启 DSH。实测:往 patch 插入一行 `@deepseek-ai/dsh-tool-str-replace-editor` 后,全局工具注册表里始终没有它。 仓库里的 `.github/**` 不受此限(每个模型步骤重新读盘)。 - **host 平面而不是 preset 平面**。`@deepseek-ai/dsh-tool-cordis` 无条件 `ctx.cordisInspect.register(provider)` 注册**进程级** Host Inspect provider,没有 realm 隔离,于是两个含它的 preset 无法在同一进程共存。实测:把同一份 composition 里唯一一行 `tool-cordis` 禁用后 `standingKeyFor` 立即 `mounted OK`,不禁用则报 `inspect provider "Service" is already registered`。 - **目录失效不能绑在会话 cwd 上,也不能只认 `.github`**。provider 是全局的、一个实例服务所有 工作区,所以任何配置变更都要让它失效,而不只是当前会话 cwd 下的;而且 `paths` 条目**自己** 就是配置目录,路径里没有 `.github` 段 —— 只匹配 `/.github/` 会让共享目录里改技能永远不刷新。 `touchesConfigDir` 两种形状都认(`index.js`),`test/index.test.js` 各钉一条。 - **`paths` 条目是配置目录,不是项目根**。`instructionDirs` / `skillDirs` 在它上面会去掉前导的 `.` 段与 `.github` 段(win32 上 `\` 与 `/` 都接受,`custom/rules` 这类自定义目录原样拼接), `scanSubdirectories` 不作用于它,它的子目录是内容而不是更多的配置目录;它内部的 `.github` 树一律不被读取 —— 连 `'.'`、`'./.github'`、`'x/.github/y'`、`''`、`'/'` 这些退化写法也不行 (fixture 里就放着这样一棵树当负例)。改 `lib/discover.js` 的扫描模型时,cwd 侧与 `paths` 侧 只在 `rootDir`(`applyTo` 的锚点)上分叉,别让两边的 `.github` 语义漂移;另外两个单位可能 解析到同一个源文件(`paths` 点名一个已在走查里的目录 + 自定义目录名),发现结果按绝对路径 去重,别把同一个文件注入两次。 - **`paths` 的默认条目是 `~/.copilot`**(`DEFAULT_PATHS`,`cordis.patch.yml` 与设置 schema 同源)。 它让每个会话默认带上用户级 Copilot 配置,也让**测试必须自带 home**:`apply` 的 `options.homeDir` 与 `discover` 的 `homeDir` 是同一个 seam,`test/index.test.js` 的 `mount` 默认指到一个**不存在**的目录,用例要测默认条目时才指到自己的临时 home。谁把 `discover()` 的 home 换回 `os.homedir()`,用例就会在「开发机上恰好有 `~/.copilot`」时红、在 CI 上绿。 显式 `paths: []` 是关闭它、不是缺省,`normalizeSettings` 认这个区别。 - **配置路径只解析一次,且只在 `lib/discover.js`**:`resolveConfiguredPath(cwd, value, home)` 是唯一的入口(`~` 展开、cwd 相对、cwd 为 `null` 时拒绝相对条目都在它里面), `index.js` 的 `touchesConfigDir` 复用它。别在别处再写一份 `resolve()`, 否则「发现读到的目录」与「改动会让技能目录失效的目录」会漂移。 只有**开头**的 `~` 是主目录(`~name`、`a/~/b` 是普通相对路径),通配符与环境变量不展开。 - **`disable-model-invocation: true` 会让技能不进目录**。这是既定语义,不是插件 bug; 想让模型看到就不要写这一行(或写 `false`)。 - **静态 `inject` 里不能放可选服务,也绝不能放一个不存在的名字。** 这不是「最多拿不到那个 功能」:fiber 会**永远停在 pending**,而客户端启动报告(Desktop 的 `rendererBootReport`) 把任何非 ACTIVE 的条目算作插件加载失败 —— 结果是 **DSH 起不来,落到恢复界面/安全模式**, 日志里还只有一句 `renderer boot failed (plugins: …): The client Loader did not provide an error message.`(`loader.await()` 对 pending 不 reject,所以报告里没有 error 字段)。 2026-09-25 就是这么被 dsh `0.1.7` 改名后的 `settingsScope` 坑掉的 ([compatibility.md §3.6](./compatibility.md))。可选服务要么放在**嵌套的** `ctx.inject([...], cb)` 里(服务不在时回调不执行),要么用 `ctx.get(name)` 现取现判。 对照:同 profile 的 `dshmarket` 也引用了 `settingsScope`,因为只写在嵌套 `ctx.inject` 里, 它的设置页消失了但 DSH 照常启动。 - **页面注册的 cell id 必须等于 Loader 条目 id。** `plugins.item` 的 cell 按 `id` 找卡片、 按同一个 `id` 取表单(`configForms.get(id)`),而 `dsh-settings` 是按**条目 id** 给设置 文档编 key 的:`cordis.patch.yml` 的 `insert[].id`、`index.js` 的 `SETTINGS_ENTRY_ID`、 `lib/client.js` 的 `ENTRY_ID` 是同一个字符串的三份硬编码,改一个必须改另外两个 (`npm run verify:settings` 逐份比对)。`0.1.7` 之前这里曾是「slot key = 设置 namespace」, 那个 namespace 已经不存在了。 - **`Config` 的 volatile 标记决定页面里能看到什么。** `dsh-settings` 的 `volatileForm` 只投影 带 `meta.volatile` 的字段,`write` 又用 `validatePaths` 拒绝非 volatile 路径:多标一个字段, GUI 就能改那本该只在组合配置里的旋钮;一个都不标,该条目**没有任何表单**,页面永远不注册。 本插件只标 `paths`,标记由 `volatile(field)` 落:`.volatile()` 不是每份 schemastery 都有 (profile 就近解析到的那份可能就是没有它的 `3.18.2`),此时用等价的 `.extra('volatile', true)`; 直接调 `.volatile()` 会让 `Config` 建不出来、页面静默消失([compatibility.md §3.9](./compatibility.md))。 另外 volatile 字段解析出来是**活引用**(`{ get(), [Symbol.for( 'cosmokit.volatile.write')] }`),`normalizeSettings` 必须每次读 `.get()`,把 config 快照 一次就等于把改动冻住。 - **「浏览…」不能只走一条路由**。win32 的 DSH Desktop profile 禁用了 `dsh-host-directory-picker-auto`,改挂 `browse` 后端,而 `browse` 没有 `pick` 能力: `uiWorkspace.pickDirectory()` 在那里**必然被拒**(`directory-picker/unavailable`)。 可用的两条路由是 `window.__DSH_DESKTOP_PICK_DIRECTORY__`(Desktop / win32)与 `uiWorkspace.pickDirectory()`(挂 `native` 后端的组合),两条都在时以前者为准 —— 否则一次「浏览」会弹两次框。两条都不在时 `hasChooser` 为假,页面不渲染该按钮; 选择被拒时要给提示,**不吞掉 rejection**:`void browse().then(...)` 那种写法在按钮上 表现为「点了没反应」。 - **设置 schema 不能自己写一个「形状像」的对象**。Cordis 与 `dsh-settings` 只按契约读它, 手写的能通过 host;但浏览器要靠 `schema.toJSON()` 的 `{ uid, refs }` 信封把它重建出来渲染 表单,重建失败时该条目**没有可编辑值**(`decode` 返回 undefined,页面只能渲染空态), 而且没有任何报错。这就是 `@deepseek-ai/schemastery` 必须以真身出现的原因。 - **`dsh.client` 声明了就必须有 bundle**。宿主扫描已启用的 Loader 条目并解析 `exports['./client']`;文件缺失会让客户端激活**大声失败**(不是静默降级)。 改 `package.json` 的 `exports` 时注意别把 `./client` 弄丢。 - **图标与文案也走包自己的 `exports`,但失败是静默的**。host 的 `readPluginMeta` 把 `<包名>/locale/en.json` 当子路径解析、把清单里的 `icon` 当相对文件读;两者不进 `files` 就随 `npm pack` 丢掉,而后果只是回落到面板默认插画与清单的 `name` / `description`, 没有报错。清单因此声明 `./locale/*.json` 导出、把 `icon.svg` 与 `locale/*.json` 写进 `files`;`npm run verify:meta` 与 `test/display-metadata.test.js` 盯这一层。 - **设置写入是带 revision 的**。页面提交时带草稿开始那一刻的 revision,被并发改动抢先会被 拒绝 —— 这是设计(`expectedRevision`),不是失败重试的重试。改页面时不要图省事改成 「不带 revision 的 `set`」,那会静默覆盖别人的改动。 ## 3. Windows 上的 git / gh - **git push 可能需要 TLS 兜底**。schannel 在某些环境取不到凭证 (`SEC_E_NO_CREDENTIALS`,`curl.exe` 同样失败),换 OpenSSL 后端 + 从系统证书库导出的 CA 即可:`git -c http.sslBackend=openssl -c http.sslCAInfo= push`。 - **症状**:`fatal: unable to access 'https://github.com/…': schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS (0x8009030E)`。 - **原因**:本机 TLS 被本地工具箱**中间人**(presented chain 的 issuer 是 `SteamTools Certificate`)。该根证书装在 Windows 证书 store 里,所以浏览器、`gh`、 Go 程序都正常,只有走 schannel 的 git 不行。 - **怎么看出来**:`openssl s_client` 在受限沙箱里**跑不起来**(Cygwin 进程起不来 signal pipe,`Win32 error 5`),改用 node 探针(注意 `s` 要先绑定,回调里才拿得到 `s`, 否则是回调内抛 `ReferenceError`,外面 try/catch 也拦不住): ```js import tls from 'node:tls' const s = tls.connect( { host: 'github.com', port: 443, servername: 'github.com', rejectUnauthorized: false }, () => { console.log(s.getPeerCertificate(true).issuer) }, ) ``` - **修**:把**拦截方**的根证书导成 PEM 再换后端。用 `-c http.sslCAInfo=` 会得到 `SSL certificate problem: unable to get local issuer certificate` —— 那是 CA 选错了,不是网络不通。导出(`Subject` 换成上面看到的 issuer): ```powershell $pem = (Get-ChildItem Cert:\CurrentUser\Root, Cert:\LocalMachine\Root | Where-Object { $_.Subject -like '*SteamTools*' } | ForEach-Object { $b = $_.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($b, 'InsertLineBreaks') + "`n-----END CERTIFICATE-----" }) -join "`n" [System.IO.File]::WriteAllText("$env:TEMP\intercept-ca.pem", $pem) ``` 然后:`git -c http.sslBackend=openssl -c http.sslCAInfo="$env:TEMP\intercept-ca.pem" push`。 **别把它写进 `git config`,也别写进仓库里的任何文件** —— CA 路径是本机的,换机器就失效。 - **沙箱里 gh 的 credential helper 起不来**。全局配置里有 `credential.https://github.com.helper=!'C:\Program Files\GitHub CLI\gh.exe' auth git-credential`, 受限沙箱下它会以 `error: failed to execute prompt script (exit code 66)` + `fatal: could not read Username for 'https://github.com'` 结束 —— 看起来像认证失败,其实是 那个子进程没起来。绕过:**关掉 helper**,用 `gh auth token` 直接给一次性的授权头 ```powershell $pair = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$(gh auth token)")) git -c credential.helper= -c http.extraheader="Authorization: Basic $pair" push ``` token 不要落盘、不要打印;`-c` 只作用于当次命令,不会写进 config。注意 **`$pair` 本身就是 一份可用凭证**,它会出现在进程命令行里(同用户的进程用 `Win32_Process.CommandLine` 就能看到), 所以别把它贴进日志、CI 输出或 issue 里。 - **推送被 `GH007` 拒绝=提交作者邮箱是私密邮箱**。 `remote: error: GH007: Your push would publish a private email address.` —— 本机全局 `user.email` 是一个私密地址,而仓库开了 “block command line pushes that expose my email”。 本仓库历史用的是 noreply 地址,照抄它: ```powershell git log -3 --format='%an <%ae>' # 先看历史用的是哪一种 git -c user.name=NEVSTOP -c user.email=8196752+nevstop@users.noreply.github.com \ commit --amend --no-edit --reset-author # ID 从 gh api user --jq .id 取 ``` `--amend` 只重写**顶端**那一个提交;分支上不止一个提交带私密邮箱时它不够,要么 `git rebase --exec 'git commit --amend --no-edit --reset-author' ` 逐个重写, 要么先在 `git config user.email` 里改成 noreply 再重写整条分支。 已经推上去过再加这些改写,需要 `--force-with-lease` 重推。