# Changelog ## 2.1.0 — 让门禁能被打红,也让它知道自己在跑 这一版不改工具行为。它修的是**验证本身的漏洞**——起因是一次对照研究:读了一个成熟项目 (`D:\xiyouji`,837 commits、45 槽门禁)的工程实践,挑出三条能搬的,做了。 ### 起因:CI 是绿的,但打包门禁不在 CI 里 `tools/verify-bundle.mjs` 最近三天抓了 **三次**真实的打包遗漏: | 漏掉的文件 | 后果 | |---|---| | `install.ps1` / `install.sh` | 用户拿到的一行安装脚本不在包里 | | `PRIVACY.md` | 隐私说明不在包里 | | `docs/roadmap.md` | 路线图不在包里 | 而这三次都是**我在本地手动跑出来**的。CI 里根本没有这个脚本——因为它 `import index.js`, 需要本机 DSH 的 `app.asar`,CI runner 上没有,于是**整个文件**被跳过,连同它那些 **只需要读文件**的静态检查一起被跳过。CI 的 `Manifest files exist` 只检查 6 个固定文件存在, 不检查 `files` 字段完整性,结果是**包缺文件、CI 全绿**。 「门禁写了」「门禁在跑」「门禁会红」是三件事。 ### 新增 **`tools/lib/report.mjs`** —— 统一三态报告器:`pass` / `fail` / `unverified`。 `unverified` 既不算通过也不算失败,单独计数并打印在总结里,因为**沉默和成功不能长得一样**。 报告器是纯数据结构,测试可以直接断言结果,而不必解析控制台输出。 **`tools/verify-manifest.mjs`** —— 把 `verify-bundle.mjs` 里**不依赖 DSH** 的静态检查拆出来: 每个模块能解析、每个相对 import 落到真实文件、manifest 字段、`files` 与磁盘的**双向**对账、 patch 行解析、locale、浏览器半边的加载契约(loader id 必须等于包名等)。只 import Node 内置模块, **CI 能跑**;静态检查从此只有一份实现,`verify-bundle.mjs` 引用它。 **`tools/check-script-coverage.mjs`** —— 防「门禁写了但没人跑」这一**类**缺陷: | 规则 | 为什么 | |---|---| | 每个 `tools/*.mjs` 必须被 scripts / CI / test / 另一个工具引用 | 新脚本不能默默变孤儿 | | `verify-*` / `check-*` 必须在 CI 跑,**或**在基线写明理由 | 需要本机 DSH 的那些 | | 基线项一旦不再需要即为失败 | 豁免不许腐烂 | | 基线理由必须 ≥20 字符 | 豁免不是藏东西的地方 | 首次运行就抓到真东西:4 个完全没引用的脚本、5 个不在 CI 的门禁。基线 [`docs/script-baseline.json`](./docs/script-baseline.json) 现为 6 条,每条写明为什么不能进 CI。 **`docs/stats.json` + `tools/status.mjs` + `tools/check-docs.mjs`** —— 文档里的数字不再手写。 `status.mjs --write` **实测**每个值(跑测试、跑参数探针、读注册表),连同出处写进 `stats.json`; README 写 `61`,`check-docs.mjs` 对账,不一致即失败。 第一次运行立刻抓到 README 里**两个错数字**: | 声明 | 实际 | |---|---| | 9 tools / **58** actions | **61** | | **23** 个破坏性动作 | **29**(另有 4 个 `github_api` 写方法按 method 判定) | 它们何时错的已不可考——这正是手写数字的性质。 **`test/wiring.test.mjs`(9 条)** —— 断言 CI 的 **`run:` 块**里真的调用了每个门禁。 锚点必须是 `run:` 而不是 step 名:一个叫「Static delivery gate」但内容是 `echo skipped` 的步骤会骗过任何按名字的断言。顺带断言 `release` 链跑静态门禁——它原本不跑,被测试抓到。 **`test/gates.test.mjs`(23 条)** —— 每个门禁一个**坏输入**:真的建一棵坏掉的临时树,断言 报告里**对应的那一条**是 `fail`。只断言「退出码非 0」会在别的检查失败时假通过。 ### 改 - `.github/workflows/ci.yml`:新增 `verify-manifest`、`check-script-coverage`、`check-docs` 三步;注释写明 offline 套件**故意**不在 CI 跑。 - `package.json`:`verify:static` 串起四个静态门禁;`release` 改为**先**跑静态门禁; `test` 与 `verify:offline` 同步纳入两个新套件(两边清单必须一致,否则其中一个会静默少跑)。 - `README.md` / `README.zh.md`:新增「By the numbers」表,数字均带 stats 声明;修正上述两个错值。 ### 又一次「把散文当代码扫」 同一形态在这次工作里出现**两次**,都是新门禁自己的 bug: 1. `ci.yml` 的注释解释了「`verify-bundle.mjs` 不能在 CI 跑」,扫描把这段**散文**当成 「它已在 CI 运行」,于把一个**正确的**豁免判成过期。 2. README 说明段落里写了 `N` 这个语法示例,`check-docs` 把它当成真实声明, 报 `unknown key "key"`。 修法相同:扫描前剥注释,以及跳过**代码跨度内**的标记。这是本项目第五、第六次记录该形态; 现在它是代码,不再是一条需要人记住的注意事项。 ### 验证 | 套件 | 结果 | |---|---| | 离线测试 | **207/207**(原 176,新增 32 条) | | 静态门禁 | 4/4(manifest / coverage / docs / encoding) | | 完整门禁 | 通过(含 DSH 运行时的动态检查) | | 参数 / 引导 / 快照 / argv | 211/211 · 114/114 · 180/180 · 84/84 | ### 没做,以及为什么 - **不搬它的 45 槽散文正则门禁**(`verify_delivery.py` 57 KB):它防内容文档漂移, 我们防代码与打包漂移,收益/成本不对等;维护它本身还产生了一连串重构批次。 - **不把 `verify-params.mjs` 塞进 CI**:它需要本机 DSH,硬塞只会得到一个永远 `UNVERIFIED` 的步骤。它留在基线里,并注明静态那半已由 `verify-manifest` 覆盖。 --- ## 2.0.0 — 改名 + 产品面补齐 **改名为 `dsh-github-gate`。** 原名叫 `dsh-github-toolkit`,而市场里已有一个同名、同 topics、 更早创建的插件(`QDchuan/dsh-github-toolkit`,18 个工具 + Web 设置页)。在市场里扫过去,我们会 看起来像它的功能更少的旧版本。名字改成 `-gate`:仍含 github(可被搜到),而 "gate" 正是真正的 差异点——每个改状态的动作都过一道审批门。 ### 为什么值得改,而不是辩解 对比是量出来的,不是感觉出来的: | | 同名插件 | 我们(2.0 之前) | |---|---|---| | README 截图 / 徽章 | 1 / 3 | **0 / 0** | | 一行安装 | `install.ps1` | **无(下载 tgz)** | | 配置方式 | **GUI 设置页** | YAML 编辑 | | issue 模板 / dependabot / 发布流水线 | 有 | **全无** | | 错误提示 | 401/403/404/422 各自说清 | 只有认证一条 | | 仓库文件数 | 18 | 85 | | 测试代码量 | ~40 KB | **~160 KB** | 结论很直白:**他们更像一个产品,我们更像一套验证过度的工具。** 用户第一眼看到的是前者。 2.0 补的就是那一半,同时没有丢掉这一半。 ### 新增 - **Settings → Plugins → GitHub 设置页**(`settings.plugins.tab` 槽位)。显示凭据是否已配置、 来自哪一层、账号、引用名与版本;密码框写入凭据存储;**Test connection** 走一次真实 `gh api user`。三条路由全部 `requireGate` 且**缺失 connection 时 fail-closed 403**—— 一个写凭据的页面不能对无法判定身份的调用者开放。页面**无法读回 token**。 - **`lib/format.js`**:统一的失败提示词汇。401 / 403 配额 / 403 SAML / scope 不足 / 404 / 422 / 409 / 网络 / gh 缺失,各自给出**该做什么**;无法识别的失败**返回 undefined**, 而不是一句"出错了"——泛泛的提示会教人跳过提示。 - **`lib/http-json.js`**:`sendJson` / `readJsonBody` 去重(原本分支路由与设置路由各一份)。 - **`install.ps1` / `install.sh`**:下载 + **按 GitHub 公布的 sha256 校验** + 打印安装路径。 实测通过:下载 216827 字节、摘要比对一致。脚本**故意不安装**——安装要改 profile 并需审批。 - **`.github/`**:issue 模板(bug / feature,字段按本插件自己的诊断设计)、PR 模板(含行为契约 与写动作分类勾选)、`dependabot.yml`、**tag 触发的 `release.yml`**(校验 tag 与 package.json 版本一致后打包发布)。 - **`PRIVACY.md`**:逐条列出数据去哪、存哪、怎么删。 - **`docs/roadmap.md`**:做什么、不做什么,以及不做的理由。 - **`npm` 元数据**:`repository` / `homepage` / `bugs` / `engines` 补齐。 ### 改名 29 个文件、约 80 处引用:包名、`cordis.patch.yml` 行、路由前缀 `/dsh-github-gate/*`、 skill provider 名、日志前缀、诊断字段、工具名(`github_*` **不变**,它们是模型看到的接口)。 ### 新增测试 | 套件 | 内容 | |---|---| | `test/settings.test.mjs` | 13 条:fail-closed(无 connection / 401 / 403 / 抛异常 / 未知值)、不回显 token、写 seam、真实 `api user` 调用、method 校验、effect 归属 | | `test/client.test.mjs` | 5 条:用假 loader + 假 React 真实加载 `client.js`,验证两个槽位各自注册、两个组件**构建元素树而不抛异常**、路由只落在本插件命名空间 | **这两组测试存在的原因**:浏览器半边和 HTTP 路由是 Node 测试体系覆盖不到的地方,而它们恰好 是最容易"看起来对、加载就炸"的部分。设置页第一次被测时,假 ctx 缺 `ctx.on`, `installWhenAvailable` 静默跳过安装——测试报 0 条路由,看起来像注册 bug。 ### 验证 | 套件 | 结果 | |---|---| | 离线测试 | **176/176**(原 158,新增 18) | | 参数覆盖 | **211/211**,9 工具,0 NOT covered | | 行为快照 | 180/180 确定性一致 | | bundle / 引导 / 编码 | 全通过 | | `install.ps1` 实机 | 下载 + 摘要校验通过 | --- ## 1.7.1 — 修正 1.7.0 里「必须重启」这个说法(实测推翻了它) **1.7.0 声称安装后「必须重启才加载新世代」。本机实测把这句话推翻了,所以改掉。** ### 观测 安装 1.7.0 之前,运行中的进程服务 1.6.1(8 个工具、6 条提示规则、没有 `updateCheck` 等键)。 执行 `plugin_manager install_bundle`(返回 `application: applied`)之后,**未重启**,立即: | 检查 | 1.6.1 应为 | 实测 | |---|---|---| | `Tool.listTools` 的 `github_*` 数量 | 8 | **9**(含 `github_update`,带 4 个参数) | | `Config.listConfigs` 的投影 schema 键 | 无 `updateCheck` | **有** `updateCheck` / `updateRepo` / `updateChannel` / `updateCacheDir` | | 注入的系统提示词规则数 | 6 | **7**(第 7 条正是新增的更新规则) | 三项都是新世代才存在的产物,所以插件管理器在安装时**重新激活了 entry**,新模块世代已经 在服务,不需要重启。 ### 但这不是「永远不用重启」 同一环境早些时候(本次会话记录的 §9.7)装 0.2.0 后,投影的 `Config` schema **没有**出现 新增字段,直到重启。两次观测相反,说明「要不要重启」取决于具体安装路径与加载器行为, 而不是一个可以写死的事实。 所以现在的说法是**条件式**的,并且给出可执行的验证方式: - 重启是加载新世代的**可靠**方式; - 但不要假设结果 —— 用**只在新版本存在**的工具或 Config 键去验证; - **已安装的 `package.json` 不能作为证据**:pnpm 会在模块可能仍未重载时就替换它, 于是 `pluginVersion()` 会报新版本号而运行的是旧代码。 ### 改动 - `github_update` 的 `stage` 结果:`next.restartRequired` 从 `true` 改为 **`'unverified'`**,输出文本从「两步」改为「一步 + 请验证运行世代」。 - 工具描述、提示词第 7 条、README、`lib/update.js` 的模块文档同步为条件式表述。 - 新增测试断言 `restartRequired === 'unverified'` 且输出包含验证指引; 实机探针同步。 ### 为什么值得发一个版本 因为这行字会直接改变助手的行为:原来第 7 条要求「重启前绝不要声称新版本已激活」, 而实际上新版本可能**已经**激活 —— 那会让模型拒绝承认一件已经发生的事实。文档错了, 行为就跟着错。 --- ## 1.7.0 — 自动更新:检查与暂存,安装和重启留给平台 **新增 `github_update` 工具,两个动作:`check` 报告是否有新版本,`stage` 下载并校验其 sha256,返回可安装的路径。** ### 为什么不做「全自动安装 + 自动重启」 三条实测约束决定了边界,都不是猜的: 1. **这个环境里 Node 的 `fetch` 到不了 github.com。** TLS 被代理拦截、OS 信任但 Node 的 内置 CA 不信任,`fetch` 报 `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,而 `gh` 因为用平台信任库 正常。所以本功能的每一次网络调用都走 `gh` —— 和 `github_api` 早就记录过的同一个理由。 设计前先跑了一遍:匿名 `fetch` 失败、`gh api` 成功(5000/小时)。 2. **替换已安装的包必须重启才生效。** Node 早已 import 了旧模块,写新文件不改变运行中的 世代。本会话直接观测到:装完新版后投影出的 `Config` schema 仍是旧的,直到 Host 重启。 3. **安装由 Harness 插件管理器拥有。** `install_bundle` 会重写 profile 的 `package.json`、 跑 pnpm、应用 patch 行。插件自己去改 profile 会让依赖 spec、lockfile、patch 行三者不一致 —— 而且会绕过平台已经设好的审批点,那正是「允许下载的代码运行」的那道门。 所以能做且该做的两件事在这里,不能做的两件事留给平台: ``` check → 有没有新版本? (只读) stage → 下载并校验摘要 (只写一个本地文件) install → plugin_manager install_bundle (平台,需审批) restart → 用户 (平台) ``` ### `check` - 读 `releases/latest`(`channel: stable`)或 releases 列表(`channel: latest`,含预发布)。 - 报告 installed / latest / 关系(`newer` / `same` / `older` / `unknown`)、release URL、 资产名与大小、GitHub 公布的摘要、以及已暂存的文件。 - **`unknown` 是独立答案**:版本无法解析时不假装「已是最新」。源码 checkout 会比发布版新, 这种情况明确说「installed is AHEAD」,不提示降级。 ### `stage` - `gh release download` 下载,然后**用 GitHub 在 release payload 里公布的 `sha256:` 摘要 校验**。传输成功不等于文件正确,所以校验不是装饰。 - 三态而非两态:`match` / `mismatch` / **`unverified`**(该 release 没公布摘要)。 把「没有摘要可对」当成通过,会让这项检查沦为摆设。 - 摘要不匹配时**拒绝暂存**并给出两个哈希值。 - 已暂存且校验通过的文件直接复用(`reused: true`),避免重复下载;`force: true` 强制重下。 - 结果里明确写 `installed: false` 和 `next.restartRequired: true`,并输出剩余两步。 ### 激活时的提示(可选) `updateCheck: true` 时,激活阶段做一次检查,**24 小时缓存**,有新版只打印一行。 **默认关闭** —— 与 `blockShellGit` 同一条理由:不在用户没要求时联网。工具本身随时可用。 ### 新增配置 | 键 | 默认 | 含义 | |---|---|---| | `updateCheck` | `false` | 激活时检查一次(24h 缓存)并打印一行提示 | | `updateRepo` | `""` | 读取 release 的仓库,空则本项目 | | `updateChannel` | `stable` | `stable` 忽略预发布;`latest` 取其最新 | | `updateCacheDir` | `""` | 暂存目录,空则系统 temp | ### 验证 | 套件 | 结果 | |---|---| | 离线测试 | **157/157**(新增 17 条更新测试) | | 参数覆盖 | **211/211**,9 个工具,0 NOT covered | | 行为快照 | **180/180** 确定性一致(新增 4 条,均不触网) | | bundle / 引导 / 编码 | 全通过 | | **实机端到端**(真实 GitHub release) | **11/11**:真实读取、下载、摘要 `match`、复用、篡改检出 | 实机探针 `tools/probe-update-live.mjs` 拿真实的 `1273984347/dsh-github-toolkit` 跑: 下载 203 KB 资产、校验出的摘要与 GitHub 公布的 `sha256:1b2c234e…` 一致、篡改一个字节后 校验立刻变 `mismatch`。这是「暂存值得做」的证据。 ### 未做,以及为什么 - **自动安装**:需要写 profile(沙箱之外)且绕过审批,见约束 3。 - **自动重启**:插件无法重启承载它的进程;能做到的只有「提示用户重启」。 - **签名校验**:Release 资产由 GitHub 提供摘要,已足够防传输损坏/替换。真正的供应链签名 需要维护者密钥,超出本插件范围。 --- ## 1.6.3 — R4:结构化重构(行为零变化) **这一轮不改任何行为。唯一的目标是让代码可读、可维护、可扩展,并用可执行的证据证明 「没改行为」不是一个声明。** 判断标准不是「更像教科书」,而是三个可测量的问题:一个新人读 `execute` 需要几分钟? 同一个决定在两处出现过几次?一次改动要碰几个文件才能确认安全? ### 为什么敢说「行为零变化」 重构最大的风险是「顺手修了一下」。所以先建了一张网再动刀: - **`tools/snapshot-behaviour.mjs`** —— 驱动全部 8 个工具的每个动作 × 四种参数组合 (minimal / maximal / 无 confirm / 有 confirm),记录**精确的 argv、结果值、拒绝文案**, 共 **176 个快照**。重构前生成 `before.json`,每完成一步 `--compare` 一次。 176/176 全部一致才继续下一步。 - 计时字段(探针里的 `0 ms` / `1 ms`)在比较前归一化——第一次比较就因为这 1 ms 报了一个假差异,而一个会误报的工具会教人忽略它。 这条网比测试更严:测试只检查「有人想到要检查的」,快照检查「没人写下来的」。 ### 提取的公共模块 | 新模块 | 消除了什么 | |---|---| | `lib/result.js` | 结果值形状手写了 **5 处**(registry 的拒绝、`github_api` 的拒绝、`github_auth` 的两条自定义路径、后台任务句柄)。5 份副本 = 5 次漏掉 `truncated` 的机会。现在 `rejection()` / `completed()` 两个构造器 + `missingResultKeys()` 一处校验 | | `lib/argv.js` | 「优先 `--body-file`,否则 `--body`」这个决定在 issue / pr / release 里重复了 **10 次**,其中一处还多一个空串兜底。`pushTextSource()` 一处表达;顺带统一了「argv 每个元素必须是字符串」 | | `lib/result.js` 的 `RESULT_KEYS` | 与 `lib/schema.js` 里的同名常量是两份拷贝——**一份契约两个出处**。`schema.js` 改为重导出 | ### 拆开的过长函数 | 函数 | 之前 | 之后 | 拆法 | |---|---|---|---| | `registry.js` 的 `execute` | 173 行 | 67 行 | 按职责抽出 7 个阶段函数:`checkGates`(第 1+2 道门)、`resolveActionRepo`、`appendRegistryFlags`、`timeoutFor`、`domainExecutable`、`runArgv`、`admitBackground`、`finalizeRun` | | `registry.js` 的 `registerDomainTools` | 118 行 | 29 行 | `defineTool` 的 92 行配置对象 → `defineDomainTool()` | | `client-bridge.js` 的 `installClientBridge` | 308 行 | 30 行 | 四个路由处理器 → `branchHandler` / `diagnosticsHandler` / `branchesHandler` / `switchHandler`;诊断响应体 → `diagnosticsPayload()`;重复的「鉴权 + 方法检查」→ `refuseIfUntrusted` / `refuseIfMethod` / `admitRequest` | | `index.js` 的 `apply` | 190 行 | 100 行 | 70 行依赖对象 → `buildDeps()`;shell-git 守卫 → `installShellGitGuard()` | `apply` 现在读起来是激活发生的顺序:解析配置 → 凭据环境 → runner → 探针 → deps → 注册工具 → 引导 → 审批门 → 路由 → 守卫。 ### 消除的重复 - **`executableFor`**:`registry.js` 和 `runner.js` 各有一份 `cfg.ghPath !== '' ? … : 'gh'`。 两份拼写是「后台任务标签写了一个实际没跑的二进制」的成因。现由 `runner.js` 单一出口。 - **孤儿 JSDoc 两处**:`client-bridge.js` 有一段描述已删除函数的文档块;`schema.js` 中 `RESULT_VALUE_SCHEMA` 的文档被 `requiredStr` 系列隔开,成了无主注释。都已归位。 - 逐字节对比修复前的安装态,确认没有其他残留。 ### 命名与风格 - `executableFor(domain, cfg)` → `domainExecutable(domain, cfg)`(registry 侧), 与 runner 的 `executableFor(cfg, kind)` 区分开:一个是「这个 domain 用哪个可执行文件」, 一个是「某类二进制叫什么」。 - `failure()` / `fail()` 两个别名原来各有一份完整实现,现在都是 `rejection()` 的别名, 注释说明了为什么保留短名(让 `execute` 里的提前返回一眼可辨)。 ### 新增的守卫 - **`test/r3.test.mjs` 的「依赖袋真的接线了」**:`deps` 的每个方法都是 `apply` 局部变量的 闭包,**重构时移进 helper 很容易让它引用到作用域里已不存在的名字**。 `github_auth status` 确实在重构中途抛了 `ReferenceError: credentialEnv is not defined`, 而且激活、注册、其余测试全绿——因为闭包只有在被调用时才求值。现在三个方法各被调用一次。 - **`pushTextSource` 的两条**:一条钉住优先级与类型(含 `0` 不能被丢弃、空串仍是「已提供」), 一条驱动真实动作断言 `--body` / `--notes` 真的按新写法产出。helper 正确但没被使用, 单测照样过——所以第二条是必要的。 ### 验证 | 套件 | 结果 | |---|---| | 行为快照(重构前 vs 重构后) | **176/176 一致** | | 离线测试 | 140/140(原 138,新增 2) | | 参数覆盖 | 203/203 | | bundle 完整性 / 引导 / 编码 | 全通过 | | 真实 gh `--help` 解析 | 80/80 | | positional 数量 vs gh USAGE | 50/50 | --- ## 1.6.2 — R3:七个「不可能成功」的动作和八个没有门的写操作 **这一轮的全部缺陷都有一个共同点:离线测试全绿,而真实 `gh` 拒绝每一条命令。** R2 的结论「`verify` 覆盖不到 `client.js`,也覆盖不到 `gh` 是否接受这些 argv」在这一轮 被完整兑现——八个域里有七个动作**在任何输入下都无法成功**,因为它们传了不存在的 flag、超量的 positional,或者漏了必需的那个。 ### P0 — 一个动作三个错,静默失败 - **`repo rename` 传了两个 positional 且缺 `--yes`。** `gh repo rename []` 只接受一个,`rename new-name a/b` 报 `accepts at most 1 arg(s), received 2`; 即使数量对了,非交互下还要 `--yes`。现在:`repo rename --yes --repo `。 - **`repo archive` 缺 `--yes`。** 同样报 `--yes required when not running interactively`。而 `archive` **没有 `--repo` flag**(与 `rename` 不同)—— 这一点是修完之后才发现的:第一次修复给 `archive`/`delete` 也加了 `--repo`, 被 `probe-argv` 立刻以 `unknown flag: --repo` 打回。三个动作的形态互不相同, 照抄一个到另一个就是这次的教训。 - **`repo create` 传了 gh 不存在的 flag。** `--add-topic` 和 `--fork` 都属于 `gh repo edit` / `gh repo fork`,报 `unknown flag`;fork 已有独立子命令。 另有一个悬空 `--source`:`repo === ''` 时 push 空串再被 `.filter` 删掉, 留下 `flag needs an argument`。 - **`pr review` 从不提交评审。** argv 只有 `pr review [--body]`,而 gh 要求 `--approve` / `--comment` / `--request-changes` 三选一, `pr review 1 --body x` 报 `--body unsupported without --approve, --request-changes, or --comment`。新增 `reviewAction` 参数(必填), 映射到真实 flag。 - **`actions runDelete` 传了 `--yes`。** `gh run delete` 只有 `--help` 和 `--repo`。 - **`pr status` 传了 positional。** `gh pr status [flags]` 不接受任何, 报 `unknown argument "123"`。`status` 改为只读当前分支,参数描述同步。 - **`repo list` 传了 `--affiliation`。** gh repo list 用 `` positional + `--fork` / `--archived` 过滤。 - **`release download` 带 asset 时两个 positional。** gh 只接受一个; asset 改走 `--pattern`。 ### P0 — 安全 - **`github_api` 的 `..` 没有被拦。** `validateApiPath` 严格实现了「必须相对」, 但 `gh api` 会先规范化路径:实测 `/repos/../../../../user` 成功, `/repos/../rate_limit` 成功。同主机内的端点逃逸完全敞开,而读侧没有任何门。 现在路径含 `..` 一律拒绝。 ### P1 — 三层防线漏了两层 - **八个写操作没有标 `destructive`**:`issue comment/reopen/edit/develop`、 `pr comment/ready/edit`。它们既不经过 `confirm` 门,也不经过审批 seam。 最典型的是 `issue develop`——它在远端建分支并本地 checkout,是纯写操作却无任何门。 旧测试的循环体第一行就是 `if (action.destructive !== true) continue`, **结构上无法发现漏标**。新增一条守卫:任何非读动作必须出现在 「destructive」或「local-only」清单里,否则构建失败。 - **`github_api` 完全绕过审批门。** 它的 `request` 动作无法静态分类 (GET 读、POST 写),于是 11 个动作装了审批,唯独这个最不受约束的逃生舱没有。 现在按 `method` 动态判断,`POST/PATCH/PUT/DELETE` 触发 ask。 - **`isOwnerRepo` 不拦 `..`**:配合 `repos/${repo}/branches/...` 可构造同型路径遍历。 ### P2 — 文档/提示词与实现不符 - **系统提示声称 `github_issue` 的 `close` 会弹审批**,而它不是 destructive。 模型据此认为「用户已经同意过」。已修正实现(`close` 标 destructive), 并新增守卫:解析提示词第 4 条规则,断言其中每个动作都在审批映射里。 - **`firstWord` 解析 `gh --version` 失败。** 版本是第三个 token (`gh version 2.89.0 (...)`),代码取第二个,回退到 `"gh"`, `parseInt("gh")` 是 NaN,于是 `isOlderThan` **永远返回 false**—— `minGhVersion` 这个配置项在任何真实输入下都不会生效。逻辑移到 `lib/version.js`,改为扫描第一个形如版本号的 token,并补齐测试。 - **`cfg.env` 里的凭据不被识别。** `resolveToken`/`describeAuth` 读 `process.env`, 而 runner 用 `process.env + cfg.env` 派生子进程环境。结果是 `github_auth status` 报告 `none`,而 gh 实际已认证。三处调用改为传入合并环境。 - **README/index.js 声称「无客户端半部分」。** 实际有 26,713 字节的 `client.js` 和 `package.json` 的 `dsh.client` 块。文档已改为描述真实的浏览器半部分。 - **`auth.js` 声称 token「永不返回」**,而 `token` 动作专门返回它。 声明加上了例外。 - **`release --latest=false` 被注释成「做不到」。** `gh release create --help` 明确记录 `--latest=false`,且带示例。这个凭空发明的限制让用户无法把 某 release 从 latest 撤下。 - **`release delete --cleanup-tag`**:help 一直说「optionally its tag」, 但没有任何参数能请求它。 - **`maxOutputBytes` 是每通道上限**,实际内存可达两倍,且 `truncated` 不置位。 改为两通道共享一个预算。 - **`github_api` 的 401 提示只匹配 stderr**,而 `Bad credentials` 在 stdout, 那两个分支永远不生效。 - **`tokenizeQuery` 不校验引号配对**,奇数个 `"` 静默产出半开引号 term, gh 以无法定位的语法错误回应。现在返回 `{ error }` 并说明原因。 - **`search sort` 描述举例 `created`**,在 repos/commits 上都非法, 模型照抄即报 usage error。改为列出三种动作各自的合法值。 - **`actions status` enum 缺 `startup_failure`**(gh 有 14 个合法值)。 - **`pr create` 的 `--fill` 与 `--body` 可同时成立**,而 gh 的优先级未定义。 ### P3 - **`resolveRefArgs` 现在对字面 ref 施加 `safeBranchName`**(此前只有 `repo branches` 用),并说明为何不构成注入。无效 ref 以 note 说明而不是静默改名。 - **`/branches` 路由的三元表达式两边相同**,条件无意义。 - **`repo.js` 重复声明了 `fork` 参数**(对象字面量重复键,后者覆盖前者)。 ### 新增守卫 - **`tools/probe-argv.mjs`**:80 个 argv 形状交给真实 `gh --help` 解析, 捕获「凭空发明的 flag 名」。 - **`tools/probe-arity.mjs`**:50 个 subcommand 的 positional 数量对照 gh 自己的 USAGE 行,捕获「positional 超量」。 - **`tools/prove-probe.mjs`**:**量化这两个探针能抓到什么、抓不到什么**—— 包括一条诚实的记录:`pr review` 缺 verdict flag 这一类只能靠执行发现, 已改为精确 argv 断言。报告 UNKNOWN 而非 PASS。 - **`test/r3.test.mjs`**:25 条回归,每条对应一个本轮缺陷。`npm test` 与 `npm run release` 均接入。 ### Confirmed by measurement - 136/136 离线测试通过(原 126,新增 10)。 - `probe-argv` 80/80、`probe-arity` 50/50,全部对 gh 2.102.0 实测。 - `prove-probe`:6 类缺陷可离线捕获,1 类不可——后者以精确 argv 断言钉住。 ### 修完之后又审查了一遍:五个我自己引入的缺陷 这一节保留,因为它说明「改完」和「改对」不是一回事。以下五项全部由**事后独立复核** (对比修复前的安装态做逐字节 diff + 针对性探针)发现,且都发生在我以为已经完成之后。 1. **`search` 的坏查询会抛未捕获异常。** `terms()` 在查询非法时返回 `{ error }`, 而五个 argv builder 写的是 `...terms(a.query)`——展开一个对象抛 `TypeError: object is not iterable`。实测 `query: " "` 和未闭合引号都会崩, 而 `""` 因为 `needs` 门先拦截才没暴露。改为 `searchArgv()`,让 `{ error }` 作为 `argv()` 的**整个返回值**交给 registry(那条路径本来就支持拒绝)。 2. **`%2e%2e` 绕过了 `..` 检查。** 第一版只查字面 `..`。实测 `gh api "/repos/%2e%2e/user"` 成功返回 `/user`——HTTP 层解码后再规范化, 与明文形态到达同一端点。改为先 `decodeURIComponent` 再分段检查; `%252e` 这种双重编码仍是字面量,无害。 3. **输出 cap 混用了字节与字符。** `remaining = cap - stdoutText.length` 里 `.length` 是 UTF-16 码元数,而 `chunk.length`/`subarray` 是字节。CJK 输出下 实测突破预算(102 B > 100 B cap),且 `subarray` 会切断多字节字符, `toString('utf8')` 于是产出 U+FFFD——一个原文里没有的字符。改为全程字节计数, 切片回退到字符边界。 4. **`repo archive`/`delete` 被错误地加上 `--repo`。** 第一版修复照抄了 `rename` 的形态,而这三个动作**互不相同**:`rename` 只有 1 个 positional 且接受 `--repo`, `archive`/`delete` 接受 `[]` positional 但**拒绝** `--repo`。 `probe-argv` 当场以 `unknown flag: --repo` 打回——**探针抓自己的效果立竿见影**。 5. **`pr.js` 的运算符优先级缺括号**,`(a && b) || c` 写成 `a && b || c`。 逻辑恰好正确,但读起来像经典 bug。加括号并注明原因。 另有三处措辞与行为不符,一并修正:`resolveRefArgs` 的 note 说 "will refuse it" 而实际只警告;`release asset` 参数对 upload/download 是两种含义(本地路径 vs 远端资产名)却被写成一句;`index.js` 的两个 JSDoc 块因插入顺序错位成了孤儿。 ## 1.6.1 ### Fixed - **分支菜单项对屏幕阅读器读作 `"devcurrent"`。** 菜单项的 `textContent` 是分支名和标记**直接拼接**: ``` {"text":"devcurrent","disabled":true,"current":true} ``` **这是探针读出来的,不是猜的。** 视觉上 `.ghk-menu-item` 的 `gap:6px` 把它们分开, 渲染没问题——**但读出来是一个不存在的词。** 修法:**按钮获得完整的 `aria-label`**(`"dev, current branch"` / `"Switch to main, 2 behind"`), **装饰性 span 标 `aria-hidden`**,另加 `aria-current`。 探针同步改为**同时读 `text` 和 `spoken`**——因为它们是两个不同的事实, **只读第一个正是让这个缺陷一直隐形的原因**: ```js text: row.textContent, // "devcurrent" spoken: row.getAttribute('aria-label') // "dev, current branch" ``` ## 1.6.0 **两个只有「看见」才能发现的缺陷——而它们是第一次看见时找到的。** ### Added - **`npm run probe:ui`**(`tools/probe-ui.mjs --cdp http://127.0.0.1:9222`)—— **通过 CDP 从正在运行的应用里读分支胶囊的真实 DOM。** 这一整轮我反复说「我没有 DOM 访问」。现在有了,**而它第一次运行就找出两个真缺陷 和一个被证伪的担心**。 > **走了一段弯路值得记下**:GUI 在 127.0.0.1:19387 上应答,于是我假设「浏览器 → cookie」。 > **它不是浏览器,是 `DeepSeek Harness.exe`,一个自己铸造会话的 Electron 应用。** > 没有任何浏览器参与,也就没有任何 cookie 可找。 > **我在问「你现在看到这个界面,是在哪个程序里」之前,先花时间去 Chrome 里找 cookie。** ### Fixed - **`