# 踩坑记录

这个文件收集本项目**实际踩过**的坑：每条都写清「现象 / 根因 / 怎么判 / 怎么避 / 谁守着」。

分工：`README.md` 描述**现在的设计与用法**，`DESIGN.md` 记录**设计决策与取舍**（为什么这样做、换来什么、代价是什么），这里记录**实际踩过的坑**：现象、根因、以及再动它时容易掉进哪里。

按危险程度排序：前面几条曾经让 dsh 起不来、让闸门空转、或让页面变成只读。

---

## 1. 客户端半的两份声明**不是二选一**

这一条前后错了两个方向，最终以真实事故收场，所以先讲结论：

> `package.json` 的 `dsh.client.inject` 装**包名**（模块图顺序）；
> 模块导出的 `inject` 装**服务名**（让激活等到服务就绪）。
> **两份都要写**，官方插件 `dsh-client-ui-settings-general` 就是两份都写。

### 第一错：读到未声明的服务 → 整个插件加载失败

**现象**：

```
Failed to load plugins
git-for-dsh
failed to apply loader entry 199c02e5 (git-for-dsh): cannot get property "slots" without inject
```

**根因**：模块导出的是 `inject: ['settingsScope', ...]`，而代码里读了 `ctx.slots`。Cordis Guard 对**未声明的服务属性读取**直接抛错，而 `apply` 在页面启动流程里执行 → 整个插件加载失败。

### 第二错（更隐蔽）：把声明整个删掉 → 设置页永久只读

**现象**：插件加载正常、工具可用、日志干净，但设置页**所有控件禁用**。

**根因**：我据此推断"任何声明都会让包被永久 parked"，于是**删掉了整个 `exports.inject`**，全部改用 `ctx.get`。`ctx.get` 确实不会触发 Guard、也确实能优雅降级 —— 但它**同时丢掉了顺序保证**：

```text
apply 可能在 @deepseek-ai/dsh-client-ui-settings 注册 settingsScope 之前执行
  → ctx.get('settingsScope') 返回 undefined
  → 页面退化成 inert scope
  → 控件全部禁用（表现为"禁止用户修改配置"）
```

而那个"声明会 parked"的推断**从未被验证，且被官方插件直接否定**。

### 怎么避

- **服务名写进模块导出的 `inject`**（这是让激活等待服务的唯一机制）；
- **包名写在 `package.json` 的 `dsh.client.inject`**（模块图顺序）；
- `ctx.get` 只作为"这个环境可能没有它"的**额外**保险，不能替代声明。

### 谁守着（这次刻意写死了）

- `test/client.test.mjs` 断言 `exports.inject` **恰好等于** `['slots', 'settingsScope']`；
- `scripts/verify-served-bundle.mjs` 断言声明覆盖实际读取的服务。

**注意这两条断言原来写的是反的** —— 它们要求 `inject` **不存在**，等于把 bug 焊死。*断言也可能在表达错误的设计；修 bug 时要一并修断言。*

---

## 2. 服务必须声明：`ctx.get` 会**静默**返回 undefined

**现象**：插件看起来完全正常 —— 工具能用、页面能开、日志没有任何错误 —— 但**设置页的复选框全部禁用**（读起来就是"禁止用户修改配置"）。

**根因**：Host 半读全局配置用 `ctx.get('settings')`，但**没有在 `inject` 里声明 `settings`**。cordis 对未声明服务用 `ctx.get()` 读取时返回 `undefined`，**不报错**。于是注册设置命名空间那一段被整段跳过：命名空间不存在 → 客户端拿不到 → 只读。

**怎么判**：这一条最难在于"没有任何错误"。判据是问 Host 要事实：

```
registered      : true/false      ← 命名空间到底在不在
settings.writable: true
```

**怎么避**：**要用就直接声明**（`inject: [...]`）；`ctx.get()` 只作为"这个环境可能没有它"的可选回退。

**谁守着**：`test/host.test.mjs` 的 ctx 是代理，读取**未声明且实际存在**的服务会**抛错**。把 bug 重新引入 → 29 个断言失败。

---

## 3. 浏览器半可能仍在使用**旧 bundle**

**现象**：刚修完的 bug"又出现了"，怎么看都像没修。

**根因**：客户端 bundle 是页面加载时取回的模块。页面不重新加载，浏览器就一直用内存里的旧版本 —— 而症状与新修的 bug 一模一样。

**怎么判**：设置页底部有一行 **`页面版本 <构建指纹>`**（由 `scripts/build.mjs` 写入，例如 `2026-09-15 12:07Z/5dcab9b4`）。和仓库里 `lib/client.js` 里的指纹对比：**不一致就是页面旧了，强制刷新即可**。

**怎么避**：改完客户端先 `npm run build`，再强制刷新；报障时先要那一行版本号。

---

## 4. `host` 不在客户端 bundle 的作用域里

**现象**：插件**能加载**，但设置页显示"渲染失败：host is not defined"。

**根因**：`host`（以及 `styles`）是**动态 Client 求值器**的作用域参数 —— 那边是 `new Function('React','console','styles','host',…)`。composition 加载的 bundle 只从 `require` 拿到模块，**没有这些标识符**。

注意它**不是加载期错误而是渲染期错误**：`apply()` 里不碰它就不报，组件一渲染才炸。

**怎么避**：客户端半只引用 `React`（来自 `require`）和 `apply` 收到的参数。需要向 Host 要数据时，用构建期嵌入（本项目就是这么做的）或 `ctx.get` 到的服务，而不是 `host.call`。

**教训**：我当时用"把 `styles`/`host` 注入作用域"的 harness 验证，所以脚本里能跑、页面里炸。**harness 给的作用域必须与真实运行环境一致**。

---

## 5. `styles` 不是服务，样式表要自己插

**现象**：设置页**完全没有样式** —— 没有卡片、没有边框、勾选框和文字之间连间距都没有。

**根因**：我把样式表交给 `ctx.get('styles')` 去插。但 `styles` **不是 Cordis 服务**，而是一个调用方拥有的作用域内建值 → `ctx.get('styles')` 永远 `undefined` → 我自己的守卫"没有就跳过"把整段插入**静默跳过**了。

**怎么避**：bundle **自己**插 `<style>`（`document.head.appendChild`），`styles` 只在恰好存在时优先用。

**谁守着**：`test/client.test.mjs` 装一个假 `document`，断言 `apply` 后**恰好追加 1 个 style 标签**，且标签里含卡片规则与两行规则；渲染检查也会跑真实组件。

---

## 6. memory 持久化 ≠ 不可写

**现象**：复选框禁用，状态行说"本次会话内可改，但不会保存"。用户理解为"权限被禁"。

**根因**：两层错误叠加：

1. `scope.mutate()` **永远**走 `remote.settings.mutate`，**写入照样送到 Host**。`persistence: 'memory'` 只表示"客户端不把这个命名空间读回来当真相源"（非 loopback 页面会这样），**不代表不可写**。我把两者当成一回事，于是 `writable = status==='ready' && writable===true` 在 memory 模式下必然为假 → 全部控件禁用。**这是我造成的，不是环境限制。**
2. 即使能点，memory 模式快照里没有值，勾选后下一次渲染会**弹回未勾选**。

**怎么避**：可用性判据只排除"真的拿不到服务"的情况（`mode: 'inert'`）；页面用本地 `draft` 作为显示源，保证勾选立刻反映；写入失败要**显示出来**而不是静默回退。

**谁守着**：`test/client.test.mjs` 的三条断言：memory 模式不得禁用、只有 inert 才禁用、以及 `controlRows` 递归收集控件行。

---

## 7. 审计命令必须能被 git 接受，否则闸门**空转**

**现象**：配置审计全绿，但**什么都没做** —— 每次调用都放行。

**根因**：我让 git 跑 `config --local --includes --name-only -z`。git 2.55 直接 **exit 129**（`error: no action specified` —— `--name-only` 必须与 `--list` 同用）。而"读不到配置"按设计**不算拒绝**（非仓库目录本来就无配置可读）。两个设计叠在一起 → 闸门永远读不到东西、永远放行。

**更糟的是测试**：单元测试只断言"命令字符串里含 `--includes`" —— 命令根本跑不起来，测试照样绿。

**怎么避**：

- 命令写成 `git config --local --includes --list --name-only -z`；
- 命令**只有一处定义**（`CONFIG_AUDIT_COMMAND`），闸门、单元测试、验证脚本共用，不再各写一份；
- 用**真实 git** 验证它能跑。

**谁守着**：`scripts/verify-config-audit.mjs` 第一条就是"命令 exit 0（一个被拒绝的命令会让闸门空转）"。

**通用教训**：单元测试**测不出**"命令本身无效"。凡是把字符串交给外部程序的闸门，都要有一个真实调用它的验证。

---

## 8. `--local --list` 看不到 `[include]` 进来的键

**现象**：仓库配置里一行 `[include] path = /任意绝对路径`，危险键**照样生效**，但审计看不到。

**根因**：`git config --local --list` **默认不展开 include**；`--includes` 才展开。（实测：被 include 的 `core.fsmonitor` 能执行，而 `--local --list` 对它显示 0 次。）

**怎么避**：审计必须带 `--includes`；本项目还把 `include.path` / `includeif.*.path` **本身**列为危险键 —— include 能把**机器上任意路径**的配置拉进来，且默认不可见。

**谁守着**：`verify-config-audit.mjs` 里有对照：**不带 `--includes` 时同一个键不可见**（这个对照项是必要的，否则"看得到"可能只是碰巧）。

---

## 9. 插件里的 `config` 闸门**不是**对抗 AI 的边界

**现象**：以为禁止了 `git config` 写入就堵住了配置注入。实际上仓库配置在**工作区内**，模型可以直接写那个文件。

**根因**：dsh 的文件策略限制的是**写入范围**，而仓库就在允许写入的工作区里。`git config` 只读化挡的是"**工具自己成为写入口**"，对"绕过工具直接改文件"无效。

**怎么避**：真正承重的是**命令级加固** —— 钉死 `core.fsmonitor`/`core.sshCommand`/`core.gitProxy` 等键 + diff 类强制 `--no-ext-diff --no-textconv`。它的性质是"**不管配置怎么来的都生效**"，因此直接写文件也绕不过。审计是补充（覆盖钉不死的通配键）。

**谁守着**：`scripts/verify-driver-hardening.mjs` 先**直接改 `.git/config`** 注入恶意键，再对比未加固/加固两种运行 —— 并带控制组。

---

## 10. 验证必须有控制组，否则"没看到标记"可能只是没武装

这一条踩了**三次**，每次都差点得出错误结论：

| 场景 | 假象 | 真相 |
| --- | --- | --- |
| 用双引号写 `core.fsmonitor` | "未加固也不执行 → 加固无效" | git 解析时吃掉引号，注入**根本没生效** |
| `execFileSync` 只在失败时返回 stderr | "程序没有执行" | 标记打在 stderr 上，成功时**看不到** |
| 首次 include 测试用相对路径 | "--list 也不显示 → 无盲区" | 路径没解析成功，include **压根没生效** |

**怎么避**：凡是"某个危险行为没有发生"的结论，都必须先证明**在加固前它确实会发生**。本项目三个验证脚本都带控制组。

---

## 11. 测试替身必须照实，否则缺陷会漏过去

夹具比真实实现宽松，就会把 bug 藏起来。已知的四处：

| 替身 | 真实的形状 | 宽松的后果 |
| --- | --- | --- |
| Host 侧 `SettingsScope` | 只有 `get`/`watch`/`update`/`replace` | 写成 `subscribe`/`set`（那是浏览器侧 API）→ 假绿 |
| `ctx` 服务读取 | 读未声明服务**抛错** | 漏掉"声明缺失 → 命名空间没注册 → 只读页面" |
| `ctx.shell.resolve({})` | 省略 workdir 回落到**进程** cwd，不是会话工作区 | 在生产里跑到错的仓库 |
| `ctx.approval` | 有 `overrideOf(session)`，插件靠它解释"策略 never" | 错误文案断言失效 |

另外：Host 测试要用 `applyUnguarded`（无兜底的入口），否则 `apply()` 的兜底会把"夹具坏了"变成"工具未注册"，把真实原因藏起来。

---

## 12. 审批策略和沙箱模式被预设**绑在一起**

**现象**：选了"完全权限"，结果审批策略变成 `never`，于是"写操作前询问我"不再弹提示、直接自动拒绝；用户以为是自己没设对，或者以为是插件在阻止。

**根因**：`dsh-permission-presets` 把两者绑成**同一条预设**：

| 预设 | 沙箱 | 审批 |
| --- | --- | --- |
| `workspace-write` | workspace-write | **ask** |
| `danger-full-access` | danger-full-access | **never** |

默认表里**没有**"完全权限 + 仍弹审批"的组合。

**怎么避**：知道这是**两条独立旋钮**被一条预设同时拨动。要测写档：把会话预设切到 `workspace-write`（= 受限沙箱 + ask）—— **这不影响插件的 git**，因为插件对自己的 git 调用显式指定了沙箱外执行；预设只影响 `bash`/`read`/`write` 等工具。或者关掉插件里的"写操作前询问我"，靠允许清单 + 审计 + 参数闸门三层。

**注意**：审批策略按**会话**记录（写在会话日志里），新会话回到部署默认 `ask`。

---

## 13. 沙箱不管读取 —— 凭据不能靠插件保护

**现象**：以为"隐藏 `~/.gitconfig`、清空 `credential.helper`"就守住了凭据。

**根因**：dsh 的三种文件策略（`read-only`/`workspace-write`/`danger-full-access`）描述的都是**写入范围**。**读取不受这份策略约束**：实测在 `workspace-write` 下，工作区外的文件（含 `~/.git-credentials`）对模型仍然可读。

顺带两个实测结论：

- 强制注入的 `credential.helper=''` **确实**压住全局的 `store`（带控制组验证）—— 所以"允许读取用户级 git 配置"这个开关**换不来凭据能力**，只会让整份全局配置变得可读（用假 token 模拟验证过 token 会进入上下文）。这个开关因此被移除。
- `git config -f <任意文件>` 可以定向读仓库外的文件，因此被列为禁止形式。

**怎么避**：把"凭据不进 AI 上下文"当作**机器级/沙箱级**问题，而不是插件问题。本插件的承诺只有一条：**它自己不读取、不传递、不存储凭据，也不成为漏点**。至于密钥该由哪个 uid 持有、要不要放进模型看不到的命名空间，那是部署方的决定。

---

## 14. 客户端 bundle 是手写的，格式不能猜

**现象**：客户端半整个不生效，或加载报错。

**根因**：客户端半由 `dsh-client-modules` 作为**预构建产物**服务：`window.__ModuleLoader__.load({ id, factory })`，`id` 必须是**包名**，`factory` 接收 `require` 并返回 `module.exports`。写错 id、用 ESM `import`、用 JSX 都会失败。

**怎么避**：照 `src/client.js` 的骨架写（无打包器、可直接阅读）；`require` 只能请求包，跨插件协作走 Cordis 服务。

**谁守着**：`test/client.test.mjs` 用真实 `__ModuleLoader__` 执行源码，并断言"恰好注册一个模块且 id 等于包名"。

---

## 15. 插件为什么对 git 显式指定 `danger-full-access`

**现象**：在会话沙箱里 `git init` 失败，报 `[sandbox: file access denied …]`。

**根因（实测）**：部署策略是 `workspace-write`，而它的 workspace root 是**进程 cwd**（`ctx.shell.resolve({}).workdir`），**不是会话工作区**。于是工作区里的 `.git` 也可能落在允许范围之外。

**怎么避**：插件对每次 git 调用显式传 `sandboxPermissions: { mode: 'danger-full-access', workspaceRoot }`，并由允许清单 + 参数闸门 + 配置审计 + 逐次审批替代沙箱作为约束；同时**自己**把 workdir 解析成会话工作区（不能依赖 shell 的默认值）。

**注意**：这一条只影响**插件自己的 git**。会话切到 `workspace-write` 预设不会妨碍它。

## 20. 跨激活的模块级可变状态

**现象**：代理功能的集成测试里，"端口一直起不来时必须拒绝"那条**没有拒绝** —— 因为上一条测试已经启动过代理，模块级标志还留着"已启动"，于是这次直接跳过探测与启动就放行了。

**根因**：把代理状态写成了**模块级**变量：

```js
const proxyState = { started: false, process: undefined }   // 错误：全局共享
```

它在**每次激活**之间共享。一个插件会被再次激活（重载、多会话），而"我启动过哪个进程"是**那一次激活**的事实；而且停止进程的 disposer 只属于一个 fiber。

**怎么避**：凡是"这一次激活拥有的东西"（进程、句柄、缓存、订阅），都建在 `setup()` 里，由闭包带给使用者，并由该 fiber 的 `ctx.effect` 负责回收。模块级只放**只读常量**。

**谁守着**：`test/host.test.mjs` 的 "host plugin: the operator proxy" 一组 —— 特别是最后两条：一条证明"起了代理并注入环境变量"，紧接一条证明"起不来时必须拒绝并杀掉进程"。**顺序本身就是检查**：如果状态跨激活泄漏，第二条会静默放行。

---

## 19. 只读档的三个写原语：分类不等于形式

**现象**（端到端实测）：`branch <名>`、`tag <名>`、`remote add` 都属于**只读档**，于是它们在**没有任何审批**的情况下创建了引用、写入了 `.git/config`。

**根因**：允许清单按**操作名**分类，而一个操作的只读性和它的**形式**有关 —— `branch` 列出分支是只读，`branch <名>` 是创建引用。目录里只记了前者。

**怎么避**：给这类操作补"形式规则"，并按性质分成两种处理：

| 变更形式 | 碰什么 | 处理 | 理由 |
| --- | --- | --- | --- |
| `branch <名>` / `tag <名>` / `remote prune` | 引用、网络 | **写档 + 审批** | 保留能力，与其它改状态的操作走同一道闸门 |
| `remote add/remove/rename/set-url` | `.git/config` | **直接拒绝** | 与 `config` 写入同类；`set-url` 静默改变后续 push 的去向，而那次 push 的审批提示里没有 URL |

**谁守着**：`test/git-catalog.test.mjs` 的 "a read-tier operation can have a mutating FORM"（断言只读形式 `mutating=false`、变更形式 `mutating=true`、配置类动词被拒），以及 `test/host.test.mjs` 里"变更形式触发审批、列出形式不触发"两条。

**教训**：**分类（tier）与形式（form）是两个维度**。只按操作名分档，就会给"只读操作"配一个能写状态的入口。

---

## 16. 短选项不能一律拒绝：`-c` / `-C` / `-u` 的含义取决于子命令

**现象**（端到端实测时一次性暴露三条）：

```
git switch -c newbranch   → 被拒（这里的 -c 是「创建」）
git commit -C HEAD        → 被拒（这里的 -C 是「复用提交信息」）
git add -u                → 被拒（这里的 -u 是「更新已跟踪文件」）
```

**根因**：把关卡写成"凡 token 等于 `-c`/`-C`/`-u` 一律拒绝"，用来挡全局的配置注入。但**全局选项只在子命令之前才被 git 采纳** —— 用 git 2.55 实测：

| 形式 | 结果 |
| --- | --- |
| `git -c core.pager=evil status` | 真正的注入形式（在子命令**之前**） |
| `git status -c core.pager=evil` | `error: unknown switch 'c'` —— git 自己就拒了 |
| `git status -C <另一个仓库>` | `error: unknown switch 'C'` —— **不会**重定向 |
| `git status --git-dir=<另一个>/.git` | `error: unknown option` —— **不会**重定向 |

也就是说：子命令**之后**的短选项只可能是该子命令自己的旗标（或 git 自己会拒的未知项）。原来那层拒绝**既无保护作用、又误伤日常操作**。而真正危险的位置（子命令之前）已经被"第一个参数必须是目录里的子命令"这条规则封住了。

**怎么避**：

- 短选项必须**带着子命令**判断：`-u` 只对 `fetch`/`pull`/`ls-remote` 才是 `--upload-pack`（指定程序），对 `add`/`commit`/`push` 是普通旗标；
- `-c` / `-C` / `--bare` 不再出现在"子命令之后"的拒绝表里（`switch -c`、`commit -C`、`init --bare` 都是合法用法）；
- 只在**全局位置**（第一个 token）拒绝这些名字。

**谁守着**：`test/git-catalog.test.mjs` 的 "a short flag means what the SUBCOMMAND says it means" 一组断言：既断言合法用法放行，也断言全局位置仍然拒绝。

**注**：这一条和客户端 `inject` 那条是同一个教训 —— **单元测试没有覆盖真实调用方式，是端到端测试先暴露的**。修好后要把旧断言一并改掉（它们原来断言的是错误规则）。

---

## 17. `pull` 分叉时 git 给的建议正是插件拒绝的那条路

**现象**：本地与远端历史分叉时 `git pull` 失败，git 的提示是：

```
hint:   git config pull.rebase false  # merge
```

而 `git config` 的写入形式**被插件拒绝**（见第 9 条）。照提示做会再撞一次墙。

**怎么避**：改用**同等效力的旗标**，它们都在允许清单里：

```sh
git pull --rebase origin main
git pull --no-rebase origin main
git pull --ff-only origin main
```

**这也说明插件的一个取舍**：把配置写入收成只读，代价是"git 让你改配置"这类提示不再可直接执行，必须换成命令行旗标。遇到这种情况，先看该操作有没有对应旗标，而不是去改配置。

---

## 18. 没有凭据时远程写操作失败，是**设计**而非 bug

**现象**：

```
fatal: could not read Username for 'https://github.com': terminal prompts disabled
```

**根因**：插件无条件隐藏 `~/.gitconfig` 与系统配置，并清空 `credential.helper`。因此 git 拿不到 `store` 这类凭据助手，也无法交互式索要用户名。

**这是刻意的**：本插件的承诺是"自己不读取、不传递、不存储凭据"。远程认证应由**外部代理**完成（在 DSH 进程环境里设 `HTTPS_PROXY=…`，git 会继承，因为 `scrubbedParentEnv()` 保留代理变量）。

**判据**：只读的远程操作（`ls-remote`、`fetch`、`clone` 公开仓库）**正常**；写操作（`push`）失败在"读不到用户名"。这说明凭据通道被隔离生效了，而不是网络或插件坏了。

---

## 21. 盲替换改错调用点 → 闸门静默失效

**现象**：审计**不再运行**。测试报 `the audit must run`、以及四条"危险键必须拒绝"变成 `Missing expected rejection`。

**根因**：我给 git 子进程的环境加参数时，用同一段文本去做替换：

```js
env: buildEnv(),
```

这段文本在文件里有**两处** —— 一处是运行 git 的地方（该带设置），另一处是**审计**内部。而审计函数的参数 `policy` 是**危险键策略字符串**，不是设置对象，于是 `policy.current.useHostCredentials` 抛 `TypeError`，被审计自己的 `catch` 当成"这个目录没有配置可读"吞掉 → 返回 `null` → **一切放行**。

**两个教训**：

1. **替换必须有唯一锚点。** 用一行在文件里可能出现多次的短文本做定点修改，等于在赌；要带上足以唯一的上下文，改完立刻回读确认。
2. **`catch` 会把"我的编码错误"伪装成"正常情况"。** 审计的 fail-open 是对的（shell 不可用、非仓库目录），但它**分不清**"读不到配置"和"我自己抛错了"。现在参数类型在 `try` **之外**先校验，类型不对直接抛 —— 那是本文件的 bug，不该被吞。

**谁守着**：`test/host.test.mjs` 的 `host plugin: the repository-config audit` 六条 —— 它们**正是**这次报出问题的那组。这也说明"闸门类"测试必须断言**拒绝发生了**，而不只是断言命令跑通了。

---

## 22. `String.replace` 会解释替换文本里的 `$`

**现象**：改一个文件时，整个文件被拼接错乱 —— 插入点后面出现了文件**开头**的内容，语法直接报错。

**根因**：用字符串做替换时，替换文本里的 `$&`、`` $` ``、`$'`、`$1` 都是**特殊模式**（"匹配之前的全部内容"、"匹配之后的全部内容"、捕获组）。我的插入文本是正则的字符类，里面有 `$` 紧跟引号 —— 于是 `$'` 把**文件剩余部分**插了进来。

**怎么避**：替换文本里可能有 `$` 时，**用替换函数**：

```js
s.replace(anchor, () => inserted)   // 逐字插入，不做任何解释
```

**谁守着**：没有自动守卫 —— 这是纪律问题。改完**必须回读或 `node --check`**。

---

## 23. "末尾统一落盘"的脚本，中途失败会丢掉全部改动

**现象**：脚本报告前几步 `ok`，然后因某个锚点缺失而退出 —— 结果**前面那些"成功"的步骤一个都没生效**。我因此三次丢失改动（导入丢了、设置项丢了、函数丢了），并因为"报错说前几步 ok"而误判文件状态。

**根因**：脚本把 `let s` 在内存里依次替换，最后才 `writeFileSync`。中途 `process.exit(1)` → 内存里的改动全部丢弃。

**怎么避**：**每步立即落盘**（每步 `read → replace → write`），让失败点之前的改动保持有效；锚点缺失时明确报出是哪一步。

**代价与配套**：立即落盘意味着失败后文件是"部分应用"状态。所以配套要求是：**每步的锚点必须唯一**，失败后先 `git diff` 看清楚再继续（`git checkout --` 可回到干净基线）。

---

## 24. 同 uid 下：环境变量与 fd 可读，内存不可读

给"凭据只放在内存里"这种设计判死刑的一组实测（同一 uid，非父子进程）：

| 通道 | 可读？ |
| --- | --- |
| `/proc/<pid>/environ` | **可读** —— 所以"用环境变量把令牌递给 git"会泄漏 |
| `/proc/<pid>/fd/<n>`（别人打开的 fd） | **可读** —— 所以"用继承的管道/文件描述符递令牌"也会泄漏 |
| `/proc/<pid>/mem` | 不可读（Yama `ptrace_scope=1` 之类拦下了） |

**结论**：本机上，只要凭据要交给另一个进程，那个交接通道就是同 uid 可读的。**唯一**能给出硬保证的是"沙箱内根本看不到该文件"（挂载遮蔽），而那属于 dsh 沙箱的职责。

**为什么记下来**：这条把一大类看起来聪明的方案一次排除掉（内存保管、环境变量注入、fd 传递、socket 拉取），省得重新推演一遍。

---

## 25. `ctx.tools.guard` 动态插件够不到，`tools/pre-execute` 可以

**现象**：按 Inspect 目录写的 `ctx.tools.guard(fn)` 在动态插件里报 `is not a function`。

**根因**：**动态插件拿到的是受限服务表面**。实测 `Object.keys(ctx.tools)` 只有 `register, schemas, get`；`guard`、`restrict`、`presentAs`、`executionMode`、`execute` 全都不在。

**怎么避**：用 `tools/pre-execute` —— 它是**事件**（waterfall），`ctx.on` 就能监听，决定类型是：

```ts
{ kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
```

**两个必须记住的限制**：

1. **工具管道只能放行/拒绝/询问，不能改写参数** —— 所以"拦截 bash 里的 git 然后路由到插件"在工具层做不到。
2. **守卫的错误会被自己吞掉**：一个抛错的守卫如果继续抛，会掐断会话里的**每一次**工具调用，所以必须 `try/catch` 后 `next()`。代价是**坏掉的守卫看起来和平庸的守卫一样** —— 因此单元测试要**直接驱动这个监听器**（实测中就是它抓出了"函数没落盘、守卫抛 ReferenceError 却静默放行"）。

---

## 26. 代理：把「端口被占用」当成故障，恰好拒绝了唯一有用的配置

**现象**：首次远程操作看起来像「代理没起来」。按提示换了端口仍然不通，最后发现是**端口填错**：实际代理在 7897，设置里填的是 7890。

**根因**：最初的语义是「端口已被占用 → 提示换端口」，隐含假设是「端口被占 = 冲突」。但这台机器上代理**本来就在跑**，占用那个端口的正是要用的进程。于是唯一能直接工作的配置被当成错误拒绝了 —— 而现象长得像「插件不工作」。

**怎么判**：要问的不是「能不能在这个端口上起服务」，而是「这个端口上是不是已经有服务在监听」。端口测试由 **Host 侧**执行（不是浏览器去连），并且报告不只回答「通不通」，还会扫一遍常见代理端口、指出哪个在监听 —— 因为「端口填错」是这里最容易犯的错。

**怎么避**（现在的语义，四条）：

1. 端口已有服务在监听 → **直接使用它**，不启动、不停止（那是用户的进程）；
2. 端口空闲 + 有启动命令 → 执行启动命令，最多等 8 秒轮询端口，起来后注入代理变量；
3. 端口空闲 + 没有启动命令 → 拒绝，并指出该填哪一项；
4. 启动后 8 秒仍未监听 → 杀掉进程，并报出它的输出。

**教训**：「环境里已经有你需要的东西」不该写成错误分支。把正常的既有状态当成故障，代价是挡掉唯一可用的配置，而且故障现象指向完全错误的方向。

**谁守着**：`test/proxy.test.mjs`（端口探测、等待超时、代理环境变量注入）与 `test/host.test.mjs` 里的代理用例，特别是「起不来时必须拒绝并杀掉进程」那一条。

---

## 27. 日志只写 `tool=bash`：卡死无法定位，时间顺序还会被当成因果

**现象**：排查卡死时日志里只有一行 `tool=bash`，看不出当时在跑哪条命令；而且**闲置时开始**的卡死与**调用中开始**的卡死，在日志里长得一模一样 —— 「最后一行」很容易被误读成肇事者。

**根因**：两处可观测性缺口叠加。调用日志只记工具名，不记命令内容；并且没有任何东西在闲置时写日志，于是最后一行永远属于上一次调用。

**怎么避**：

- `guard.enter` 带上该次 bash 命令的**前 60 个字符**（脱敏后）—— 这是定位「哪条命令引发卡死」的唯一线索，因此**始终记录**；
- 增加**心跳行**（每 5 秒一行，报出当前卡在半途的调用及其已持续时长），把「卡在调用内部」与「闲置时卡死」分开。心跳默认**关闭**（它已经完成过一次使命：把一次挂起定位到某个调用内部），调查卡死时打开、平时关着；
- 两个开关**互相独立**：只开心跳 = 存活探针（日志里只有心跳行）；只开调用日志 = 审计轨迹。它们回答的是两个不同问题 —— 日志说「插件做了什么」，心跳说「进程还活着吗、卡在哪」。

**教训**：可观测性缺口本身就是一个坑：它会让人在错误的方向上耗掉大量时间（见第 31 条的排查过程）。把两个不同的问题合并到一个开关里，等于牺牲掉其中一个。

**谁守着**：`test/log.test.mjs`（两个开关互不影响、心跳行格式、脱敏与长度上限）与 `test/host.test.mjs` 的日志用例。

---

## 28. 设置命名空间里不能有两个同名键 —— 客户端会静默覆盖

**现象**：加"插件总开关"后，整个测试套件炸了：`$.enabled expected boolean but got …`，以及 `boolean true is not iterable`。

**根因**：我把开关命名为 `enabled`，而 `enabled` **已经存在**：它是允许清单那个**数组**。schema 里两个同名键互相打架；客户端的解码对象字面量里也是两个 `enabled`，**后者静默覆盖前者** —— 那会把整个允许清单变成一个布尔值。

**怎么避**：新设置项先搜一遍命名空间里有没有同名键。名字要**说清归属**：不是笼统的 `enabled`，而是 `pluginEnabled`（对照 `enabled` = 操作清单）。

**教训**：这次是**测试抓住的**，因为 schema 的类型校验在加载期就报错。如果两个键同类型，它就会静默错下去 —— 所以别指望测试兜住命名冲突，**先搜再命名**才是纪律。

---

## 29. 守卫在每次工具调用里做同步文件系统调用

**现象**：用户报告"AI 一调用工具，整个 WSL 就冻住"。关掉插件后不再出现。

**根因**：守卫跑在 **dsh 进程里（不在 bash 沙箱内）**，而它**每一次**工具调用都要：受保护路径 `realpathSync` ×2，再对**模型给的路径**（通常就在 `/mnt/d`）`realpathSync` 一次。`realpathSync` 落到 **drvfs（Windows 盘）** 上是同步调用 —— Windows 侧一忙就长时间阻塞，**卡住的是 dsh 进程本身**，表现即整个界面冻住。

**怎么避**：

- 受保护路径**按配置缓存**（配置变了才重算），而不是每次调用重算；
- 路径参数**先做纯字符串比较，零系统调用**；
- 只有**最后一段文件名**与受保护文件名相同时，才做一次 realpath（软链接唯一能藏身的情形）。

**教训**：`tools/pre-execute` 的守卫是**同步**的，且**在 dsh 进程里对每一次工具调用执行**。任何同步 I/O 都等于给每次工具调用加了阻塞风险 —— 在没有把握之前，宁可退化成纯内存比较。

**谁守着**：`test/host.test.mjs` 的守卫用例仍然驱动真实监听器（拒绝/放行/ask/allow 与"自身出错不得中断调用"）。

---

## 30. 页面解码表与 Host 档位表漂移 → 选项"看起来无效"

**现象**：用户在设置页把原生 git 改成「限制」，保存后**又变回「禁止」**，看起来改不动。

**根因**：原生 git 的档位从 3 个（deny/ask/allow）扩到 4 个（多了 restrict），而客户端**解码**那一步还在用旧的 3 档表校验：

```js
nativeGitPolicy: GUARD_COPY.some((entry) => entry.id === section.nativeGitPolicy)
  ? section.nativeGitPolicy
  : CATALOG.defaults.nativeGitPolicy,   // restrict 不在旧表里 → 回落成默认 deny
```

存进文档的值是对的，**只是显示回落到默认** —— 所以现象像"设置无效"，实际是"读数错了"。

**怎么避**：**同一份选项集不要维护两份**。测试上更要紧的是判据：

```js
// 从 Host 的 catalog 读出它接受的全部 id，逐个喂给客户端解码器，断言原样通过
for (const id of NATIVE_GIT_POLICIES) {
  assert.equal(decode({ nativeGitPolicy: id }).nativeGitPolicy, id)
}
```

**教训**：两张必须一致的表，就该有一条**往返测试**；照抄 id 列表的测试抓不住漂移，因为它和实现一起漂。

---

## 31. 一个死循环 —— 而且它只和 bash 有关（真凶）

**现象**：装上插件后，AI 每次走 bash 就可能整台 WSL 冻住几十秒；插件一卸载就完全不出现。用户反复强调"这是插件的 bug""这个 bug 在日志出现之前就有"——两次都对。

**根因**：`invokesGit`（`restrict` 档的判别器，只在 bash 分支调用）里，`<` 和 `>` 属于 `WORD_BREAK`（结束一个词）**却不在 `COMMAND_START`**，而且没有任何分支处理它们：

```js
const start = index
while (index < command.length && !WORD_BREAK.has(command[index])) index += 1   // < > 立刻命中
const word = command.slice(start, index)   // → word 为空，index 原地不动
```

于是外层 `while` **永不前进**。守卫跑在 dsh 进程里、对每次 bash 调用都执行，所以**任何带重定向的命令**（`2>&1`、`2>/dev/null`、`> file`）都会把整个进程绞死 —— 而这类重定向几乎每条命令都有。

**它解释了全部观测**：只有 bash 会卡（判别器只在 bash 跑）；写入从不卡；卸载插件就不卡；卡死都在"守卫窗口内"（enter 有、exit 无）；**心跳同时消失**（事件循环被占死）；磁盘与内存空闲而 CPU 恰好是**一个核**的量（16 核机器上显示 6.5%）；只有部分调用卡（取决于命令里有没有重定向）；需要 `restrict` 档（`deny`/`ask` 用 `containsNativeGit`，那里没有这个环）。

**怎么避**：

- **每一轮循环必须保证消费输入**：修复里加了结构性的 `if (index === start) index += 1`，这样以后任何分支忘记前进也不会再冻住宿主；
- 当循环遍历的是**用户输入**时，先把畸形输入列进测试（`>>>`、`<<<`、`a<>b`、`""`…）；
- **给测试加超时**（`--test-timeout`）：这一族 bug 表现为"挂住"而不是"失败"，没有超时它就一直是隐形的。

**教训（比 bug 本身更值钱）**：我连续给出了三个错误结论 —— "环境问题""同步日志写冻住事件循环""需要更多采样" —— 而真因是一段**纯逻辑死循环**。我之所以没找到，是因为一直困在**性能/资源**的框架里（同步 I/O、drvfs、内存、杀毒），而没有问最朴素的问题：**这段代码在任何输入下都会返回吗？** 用户的判断（"就是插件""和 bash 有关""日志之前就有"）三次都比我的分析准。

---

## 32. 同步循环只有计数器能救，进程卡死只有别的进程能救

**背景**：第 31 条那个死循环让整个 harness 冻住。事后要问的不是"怎么写出没有死循环的代码"（那做不到 ✗），而是"**怎么让这类错误不再冻结整个系统**"。

**两条结论，都反直觉**：

**① 定时器救不了同步循环。** 我最初想的是"加个看门狗，超时就中断" ✗ —— 但**同步循环转着的时候，事件循环上其他任何代码都不会运行**，定时器回调根本没有机会被调用。这一点在日志里有直接证据：卡死时**心跳戛然而止**，因为写心跳的回调就是被饿死的那个 ✗。
⇒ 唯一有效的是**循环自己会去查的计数器**：`createBudget()` 让 `spend()` 在预算耗尽时返回 false，把它放进循环条件里，扫描就自己结束了 ✓，守卫随后按**最严策略**拒绝并说明原因（宁可拒一条超长命令，也不能冻住宿主）。预算设在远高于真实命令的量级，只有"代码没预料过的输入"才触发 ✓。

**② 进程卡死时，进程内的一切都无能为力。** 我一开始想做"设置页上的重启按钮" ✗ —— **设置页正是由那个被卡住的进程提供的**：它点不动、接口不回、定时器不响 ✗✗。
⇒ 只有**另一个进程**能动手。仓库里因此带了 `tools/watchdog.sh`：它**探测 dsh 自己的 web 服务**（回答来自事件循环 → 超时就说明循环没在跑 ✓），检测到卡死后用 **`kill -9`** 重启 —— 事件循环卡死时 JavaScript 写的信号处理器没有机会运行，**只有内核处理的信号能穿透** ✓。它**不依赖本插件**，所以在"插件就是元凶"时照样管用 ✓。

**附带的一般原则**：

- 任何跑在宿主进程里、又是**同步**执行的代码，都要按"**它可能冻结宿主**"来设计，而不是按"它应该很快"；
- 系统要留一条**不必加载出问题组件就能启动**的逃生口（这里是 `DSH_GIT_TOOL_DISABLED=1`，以及设置页里那个"软开关"的区别）；
- 顺手修掉的是真优化：`isCommandStringQuote(command.slice(0, index))` 每个引号都复制一遍前缀，是 O(n²) ✗ —— 改成向前短扫描后，2 万引号的命令从灾难级降到 **2ms** ✓。

---

## 33. 失败必须有人知道 —— 静默失败清单

**做法**：把插件里每一处 catch 都过了一遍，问同一个问题：**这里出错时，谁会知道？** 找出四处真的会"悄悄过去"的地方：

| 位置 | 原来 | 现在 |
| --- | --- | --- |
| 日志自己的写入 | 吞掉 —— 日志坏了看起来像"今天很安静" | 通过回调报给宿主终端（**每条消息只报一次**，免得坏日志把每次调用变成噪音） |
| **仓库配置审计** | 直接返回 null —— "审计没跑成"与"没发现危险键"**完全同形**（这正是当初"审计静默失效"那个 bug 的残余） | 明确报出"本次未做审计 + 原因"；关键键仍由强制配置钉死，所以调用继续 |
| 日志读取路由 | 读失败被吞成空字符串 —— **读不了与空日志长得一模一样** | 只有文件不存在保持安静，其它错误报到页面上 |
| 审批覆盖查询 | 失败等同于"用户没有覆盖" | 报出来 |

**故意保持安静的，只有一处**（并在代码里写明理由）：受保护路径的 realpath 失败 —— 那**几乎总是"该文件不存在"**，属正常情况，且词法比较已经完成、保护依然生效。全都报出来的话，真正的失败会被噪音淹掉。

**教训**：空的 catch 是最容易写出"安全网失效数周无人知"的语法结构。每次写它都该问：**这个失败会不会改变某个判断的语义？** 会（审计→没发现、读取→空、覆盖→没覆盖），就必须报；不会（可选路径不存在），才可以安静 —— **且要在代码里写清为什么**。

---

## 34. 缓存一个安全判定：键必须覆盖每个输入，代价必须写明

**背景**：`git_exec` 每次调用要起两个 git 进程（子命令 + 配置审计）。审计结果显然可以缓存 —— 但**"显然"通常就是安全网变弱的地方** ✗。

**怎么做得安全**：

- **缓存原始材料，不缓存结论**：缓存里放的是"解析出的配置键名"，判定每次按当前子命令**重新计算** ✓ —— 键名不含子命令，所以子命令不该进缓存键；而结论依赖子命令，所以它根本不该被缓存；
- **键覆盖每个输入**：目录 + 策略 + 仓库配置的**状态戳**（mtime+size，含 `config.worktree`）✓；配置一改戳就变，缓存自失效 ✓；
- **无法验证就不缓存**：worktree 里 `.git` 是文件、没有可监视的配置 → 直接不缓存 ✓（宁可每次多跑一次，也不要一个无法验证的缓存）；
- **把残余代价写出来**：`[include]` 引入的文件路径，不额外问 git 就看不到（而那正是要省掉的调用）→ 对**被 include 文件**的修改由 **30 秒 TTL** 兜住，不是立刻 ✗。这一点写进了代码注释、README 和测试 ✓。**能写明的代价就不是隐患，没写明的才是。**

**顺带一条关于守卫的实践**：这次我用 heredoc 把脚本内联在 bash 命令里，结果**被自己的守卫拒了** —— 正文里含有看起来像 git 调用的片段，命中了 `restrict` 判别器 ✓。一直以来的做法（用 write 工具写脚本、bash 里只留一条短命令）反而不会被误伤 ✓。**这是"内容级检查"的必然代价**：它会把"写代码"误判成"跑命令" ✗ —— 拒一条命令可以接受，漏掉一次真调用不行 ✓。

---

## 35. schema 的默认值会掩盖迁移分支 —— 用户选过的"关闭"被静默升级

**背景**：把布尔设置 `scanScripts` 换成三档 `scriptCheckPolicy`，并在 `normalizePolicy` 里写了迁移：新键没有值时读旧布尔（`false` → `off`）。测试却红了 —— `scanScripts: false` 并没有变成 `off`。

**根因**：新键在 schema 上带了 `.default("strict")`。schema 在**读取时**就把"文档里没有这个键"填成默认值，于是 `normalizePolicy` 看到的 `scriptCheckPolicy` 永远是 `"strict"` —— **迁移分支永远不可达**，用户当年选的"关闭"被静默升级成"严格"（方向安全，但违背意图，且毫无提示）。

**怎么避**：**要做迁移的键，不要给它 schema 默认值** —— 让"缺省"保持缺省，把默认值的决定权交给归一化层（它能看到旧键）。schemastery 的 `union` 本身就接受未设置（校验为 `undefined`），去掉 `.default()` 即可，不需要任何 hack。

**一般化**：只要"默认值"与"迁移逻辑"同时存在，**谁先跑**就决定迁移是否有效 —— 默认值是 schema 层的事、迁移是归一化层的事，而 schema 在前。凡是"旧配置要延用"的场景都该问：**归一化看到的，是用户的输入，还是 schema 补上的默认值？**

**谁守着**：`test/host.test.mjs` 的三条档位用例（严格／限制／关闭的行为）加 `reads the boolean the tier replaced`（迁移），以及 `test/client.test.mjs` 里把 Host 的档位列表喂进客户端解码器的往返测试。

---

## 36. Windows 的空设备 `NUL`：git 认它，Node 不认 —— 而且 Node 会把它写成真文件

**背景**：`buildEnv()` 用**平台空设备**隐藏用户级配置、并让仓库钩子不被运行（Windows 上是 `NUL`，其余是 `/dev/null`）。Windows 侧做真机验证时，验证脚本要在工作目录里检查这个设备。

**根因**：`NUL` 是**设备名**，不是文件路径 —— 它由 Win32 在**打开时**解释。git for Windows 走 MSYS2 打开它，行为与 `/dev/null` 完全一致（真机 22 项断言全绿，其中两组是对照实验：未钉住时钩子真的把提交拦下、用户级配置里的键真的读得到；钉住后钩子**从未**运行、那个键读不到）。但 **Node 的 `fs` 不解释设备名**：`existsSync('NUL')` 为 `false`，`statSync`／`readFileSync` 报 ENOENT；更糟的是 `fs.writeFileSync('NUL', …)` —— 它会经 `\\?\` 长路径在**当前目录创建一个真实的文件 `NUL`**。

**代价**：那个真文件**删不掉**（资源管理器、PowerShell `Remove-Item`、libuv `unlink` 都失败），只能用 `fs.unlinkSync('\\\\?\\' + 绝对路径)`；而它留在工作树里会让 `git add -A` 直接失败（`fatal: unable to stat 'NUL': No such file or directory`，exit 128）—— "清理自己留下的痕迹"于是变成一次解谜。

**怎么避**：**不要用 Node 的 fs 去校验或写入空设备**。要验证就验证**谁在解释它**：给 git 一组对照实验（钩子是否真的没跑、用户级配置是否真的读不到），而不是检查"环境变量里写了什么"。清理时优先用 `\\?\` 前缀的绝对路径，或干脆避免对设备名做文件操作。

**一般化**：**跨平台的值要按"谁解释它"来验证** —— 同一个字符串交给 shell、git、Node 还是内核，解释规则各不相同，"我这边看着对"证明不了任何事。

**谁守着**：`test/git-catalog.test.mjs` 断言 `NULL_DEVICE` 按平台取值、且强制配置表与它一致；Windows 真机侧本次另用了一份 `verify-nul-device.mjs` 做行为对照（尚未入库）。
