dsh-github-flow 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.en.md +112 -17
  2. package/README.md +115 -17
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +81 -22
  5. package/docs/images/1.png +0 -0
  6. package/lib/api/route-helpers.d.ts +19 -0
  7. package/lib/api/route-helpers.js +54 -0
  8. package/lib/api/routes-actions.js +214 -9
  9. package/lib/api/routes-lifecycle.js +8 -1
  10. package/lib/api/routes-overview.d.ts +26 -0
  11. package/lib/api/routes-overview.js +396 -36
  12. package/lib/api/routes-proxy.js +8 -1
  13. package/lib/api/routes.d.ts +11 -1
  14. package/lib/api/routes.js +15 -2
  15. package/lib/cache.d.ts +56 -4
  16. package/lib/cache.js +77 -7
  17. package/lib/client.d.ts +8 -3
  18. package/lib/client.js +5542 -1391
  19. package/lib/destructive.d.ts +59 -0
  20. package/lib/destructive.js +285 -0
  21. package/lib/executor/args-guard.d.ts +17 -0
  22. package/lib/executor/args-guard.js +23 -0
  23. package/lib/executor/error-classifier.d.ts +55 -0
  24. package/lib/executor/error-classifier.js +199 -0
  25. package/lib/executor/failure-anchors.d.ts +52 -0
  26. package/lib/executor/failure-anchors.js +116 -0
  27. package/lib/executor/git-parser.d.ts +124 -0
  28. package/lib/executor/git-parser.js +397 -0
  29. package/lib/executor/pr-review.d.ts +95 -0
  30. package/lib/executor/pr-review.js +175 -0
  31. package/lib/executor/run-log.d.ts +36 -0
  32. package/lib/executor/run-log.js +78 -0
  33. package/lib/executor.d.ts +277 -6
  34. package/lib/executor.js +1265 -94
  35. package/lib/github-service.d.ts +1 -1
  36. package/lib/main.d.ts +92 -18
  37. package/lib/main.js +186 -95
  38. package/lib/renderers/common.d.ts +0 -3
  39. package/lib/renderers/common.js +8 -44
  40. package/lib/renderers/markdown.d.ts +1 -0
  41. package/lib/renderers/markdown.js +1 -0
  42. package/lib/renderers/pr.js +26 -1
  43. package/lib/renderers/task.d.ts +9 -0
  44. package/lib/renderers/task.js +120 -0
  45. package/lib/review-rules.d.ts +60 -0
  46. package/lib/review-rules.js +204 -0
  47. package/lib/shared/state-badges.d.ts +42 -0
  48. package/lib/shared/state-badges.js +102 -0
  49. package/lib/task-primitives.d.ts +109 -0
  50. package/lib/task-primitives.js +194 -0
  51. package/lib/tool-tiers.d.ts +57 -0
  52. package/lib/tool-tiers.js +168 -0
  53. package/lib/tools/agent-task.js +4 -4
  54. package/lib/tools/api.js +18 -6
  55. package/lib/tools/cache.js +42 -40
  56. package/lib/tools/codespace.js +4 -12
  57. package/lib/tools/discussion.js +4 -4
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +72 -18
  61. package/lib/tools/image-utils.d.ts +27 -0
  62. package/lib/tools/image-utils.js +73 -3
  63. package/lib/tools/issue.js +16 -28
  64. package/lib/tools/label.js +4 -15
  65. package/lib/tools/pr.d.ts +47 -0
  66. package/lib/tools/pr.js +170 -33
  67. package/lib/tools/project.js +4 -13
  68. package/lib/tools/release.js +11 -22
  69. package/lib/tools/repo.js +4 -4
  70. package/lib/tools/ruleset.js +9 -6
  71. package/lib/tools/run.js +76 -24
  72. package/lib/tools/secret.js +11 -17
  73. package/lib/tools/skill.js +18 -22
  74. package/lib/tools/status.js +4 -4
  75. package/lib/tools/task.d.ts +219 -0
  76. package/lib/tools/task.js +457 -0
  77. package/lib/types.d.ts +514 -1
  78. package/lib/ui/help-modal.d.ts +6 -0
  79. package/lib/ui/help-modal.js +124 -16
  80. package/lib/ui/issue-modal.d.ts +14 -0
  81. package/lib/ui/issue-modal.js +370 -0
  82. package/lib/ui/markdown-lite.d.ts +66 -0
  83. package/lib/ui/markdown-lite.js +312 -0
  84. package/lib/ui/network-banner.d.ts +12 -0
  85. package/lib/ui/network-banner.js +35 -0
  86. package/lib/ui/pr-modal.d.ts +17 -0
  87. package/lib/ui/pr-modal.js +555 -0
  88. package/lib/ui/repo-badges.d.ts +43 -0
  89. package/lib/ui/repo-badges.js +82 -0
  90. package/lib/ui/repo-selector.d.ts +14 -0
  91. package/lib/ui/repo-selector.js +112 -0
  92. package/lib/ui/run-modal.d.ts +26 -0
  93. package/lib/ui/run-modal.js +486 -0
  94. package/lib/ui/storage.d.ts +47 -0
  95. package/lib/ui/storage.js +139 -0
  96. package/lib/ui/styles.d.ts +1 -1
  97. package/lib/ui/styles.js +1330 -44
  98. package/lib/ui/sync-settings-modal.js +1 -1
  99. package/lib/ui/utils.js +13 -1
  100. package/lib/ui/workspace-panel.js +226 -29
  101. package/lib/version.d.ts +1 -1
  102. package/lib/version.js +1 -1
  103. package/package.json +9 -2
@@ -35,8 +35,8 @@
35
35
  │ │ │ │ │
36
36
  │ ▼ ▼ ▼ │
37
37
  │ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
38
- │ │ 18 大 Agent 工具 │ │ 人类 Slash 指令 │ │ Host HTTP WebServer │ │
39
- │ │ (defineTool DSL)│ │ /gh status │ │ (Effect Disposer 清理)│ │
38
+ │ │ 19 大 Agent 工具 │ │ 人类 Slash 指令 │ │ Host HTTP WebServer │ │
39
+ │ │ (手写descriptor)│ │ /gh status │ │ (Effect Disposer 清理)│ │
40
40
  │ │ - pr (+attach) │ │ /gh upgrade │ │ - /api/github/overview│ │
41
41
  │ │ - issue (+attach│ │ /gh help │ │ - /api/github/action │ │
42
42
  │ │ - run (workflow)│ └──────────────────┘ │ - /api/github/refresh │ │
@@ -49,13 +49,14 @@
49
49
  │ │ - ruleset(check)│ │ - /update-settings │ │
50
50
  │ │ - cache (治理) │ │ - /proxy (全局代理) │ │
51
51
  │ │ - gist (分享) │ │ - /auth (轻量探针) │ │
52
- │ │ - status (健康) │ └──────────┬────────────┘ │
53
- │ │ - agent_task │ │ │
54
- │ │ - discussion │ ▼ │
55
- │ │ - project (v2) │ ┌───────────────────────────┐ │
56
- │ │ - codespace(MVP)│ │ git-watcher (独立服务) │ │
57
- │ │ - help (元指南) │ │ - .git/HEAD & index │ │
58
- │ └────────┬─────────┘ │ - 800ms 防抖/35s 超时 │ │
52
+ │ │ - status (健康) │ │ - /issue-detail │ │
53
+ │ │ - agent_task │ │ - /pr-detail (PR 审阅)│ │
54
+ │ │ - discussion │ │ - /run-detail (Run) │ │
55
+ │ │ - project (v2) │ └──────────┬────────────┘ │
56
+ │ │ - codespace(MVP)│ ┌───────────────────────────┐ │
57
+ │ │ - help (元指南) │ │ git-watcher (独立服务) │ │
58
+ │ └────────┬─────────┘ │ - .git/HEAD & index │ │
59
+ │ │ │ - 800ms 防抖/35s 超时 │ │
59
60
  │ │ └───────────────────────────┘ │
60
61
  │ ▼ │ │
61
62
  │ ┌──────────────────────────────────┐ │ │
@@ -82,14 +83,14 @@
82
83
  │ ┌─────────────────────────────────────────────────────────────────────┐ │
83
84
  │ │ 模块化 UI 架构 (src/ui/ ➔ 经 scripts/build.mjs 拓扑聚合单一 Bundle) │ │
84
85
  │ │ │ │
85
- │ │ · styles.ts (1290行自适应 CSS, 构建期紧凑压缩) │ │
86
+ │ │ · styles.ts (自适应 CSS, 构建期紧凑压缩) │ │
86
87
  │ │ · icons.ts (Octocat, Git 官方菱形分支矢量组件, 插件徽标) │ │
87
88
  │ │ · storage.ts (LocalStorage SWR 状态机与偏好存储) │ │
88
89
  │ │ · components/ (分支下拉/认证引导/升级通知/离线横幅/状态胶囊) │ │
89
- │ │ · modals/ (Commit 详情弹窗 / 4分区全局设置弹窗) │ │
90
+ │ │ · modals/ (Commit 详情弹窗 / PR 审阅工作台 / Issue 在线查看 / 全局设置弹窗) │ │
90
91
  │ │ · panels/ (首页全景大面板 workspace-panel.ts) │ │
91
92
  │ │ │ │
92
- │ │ 核心组装入口 (src/client.ts, ~1,260 行高内聚): │ │
93
+ │ │ 核心组装入口 (src/client.ts, 高内聚): │ │
93
94
  │ │ - applyTabState (纯 DOM 互斥状态应用, 满足单测白盒契约) │ │
94
95
  │ │ - renderGithubRepoView (五 Tab 核心视图: Git/未提交/PR/Issue/Run) │ │
95
96
  │ │ - GithubRightbarBody (右侧栏主体, 状态引用解耦与时序安全守护) │ │
@@ -114,8 +115,8 @@
114
115
 
115
116
  ### 2.3 上下文 Token 保护与防御性输出包装 (Token Budget Guard)
116
117
  - 所有的 `gh` 查询原生采用 `--json <fields>` 结构化投影;
117
- - 对长文本输出(如大型 PR 的完整 `diff`、CI 失败步骤日志)设置安全截断阈值(默认 24KB),采用码点安全切割与二分查找字节截断(`src/executor/process-utils.ts`),保留头尾关键上下文并添加显式截断标记,杜绝撑爆模型上下文;
118
- - 列表与详情输出经独立 Markdown 渲染引擎(`src/renderers/` 7 大领域模块)转化为高密度表格与卡片(带状态徽章、可点击超链接与竖线转义防护),实测 20 条 PR 列表输出压缩 70%+ Token;
118
+ - 对长文本输出(如大型 PR 的完整 `diff`、CI 失败步骤日志)设置安全截断阈值(默认 24KB),采用码点安全切割与二分查找字节截断(`src/executor/process-utils.ts`),保留头尾关键上下文并添加显式截断标记,杜绝撑爆模型上下文;**程序化消费的大载荷可按调用点显式放宽**(PR diff 与 Issue 详情放宽至 2MB 字符 / 8MB 字节),因为这两类数据只在服务端切片后渲染,不进入模型上下文;**且一旦真的被截断必须显式失败**——截断会让 `JSON.parse` 被跳过、`data` 变 `undefined`,静默降级会渲染出「元数据全空 + 零评论」的假详情;
119
+ - 列表与详情输出经独立 Markdown 渲染引擎(`src/renderers/` 15 大领域模块 + `common.ts` + `markdown.ts` 门面)转化为高密度表格与卡片(带状态徽章、可点击超链接与竖线转义防护),实测 20 条 PR 列表输出压缩 70%+ Token;
119
120
  - 所有 Agent 工具的输出统一经过归一化,列表自动打包为 `{ items, count }`,标量打包为 `{ result }`,100% 严丝合缝契合 DSH 工具网关模式约束;
120
121
  - **gh 版本守卫降级链(runGuarded)**:preview 命令与新特性按 `minVersion` 门槛路由——版本达标直通主命令、不足走 `fallback` 降级链路(结果携带 `degraded/degradedReason` 结构化标注,**降级不是静默的**,渲染层对 degraded 结果显式追加链路说明)、无降级时报错附平台升级指引;版本探测自身失败 fail-open 直通(守卫是路由优化不是安全边界)。版本比较采用数值分段(`src/executor/version-utils.ts`,杜绝 `'2.96.0' > '2.100.0'` 字典序陷阱),探测结果 30s TTL 缓存且只缓存成功值;
121
122
  - **AI_AGENT 识别信号注入**:每次 gh 子进程启动注入 `AI_AGENT=dsh`(gh detect.go 最高优先级通用信号)——gh ≥2.99 自动切换 text-only spinner、≥2.100 命令误用时打印完整 help(模型可据此自我纠正)、telemetry 归因到 dsh;旧版 gh 忽略该变量零副作用。经 `agentEnvInjection` 配置开关(默认开启)可恢复 gh 原生行为。
@@ -136,7 +137,7 @@
136
137
  为彻底解决单文件臃肿过长(如旧版 `client.ts` 超过 5,200 行、`executor.ts` 超过 1,100 行)的技术债,项目已全面落地分层解耦与自包含构建规范:
137
138
 
138
139
  ### 3.1 后端执行引擎分层 (`src/executor/`)
139
- - [src/executor.ts](../src/executor.ts)(瘦身至 ~820 行):保留 `GhExecutor` 类核心编排(命令白名单、执行通道、认证与版本缓存),完整 Re-export 子模块符号,保持 100% 向后兼容;
140
+ - [src/executor.ts](../src/executor.ts)(瘦身至只保留编排层):保留 `GhExecutor` 类核心编排(命令白名单、执行通道、认证与版本缓存),完整 Re-export 子模块符号,保持 100% 向后兼容;
140
141
  - [src/executor/git-parser.ts](../src/executor/git-parser.ts):纯文本解析器(`status -sb`、`log`、`branch -a`),无 I/O、无副作用;
141
142
  - [src/executor/version-utils.ts](../src/executor/version-utils.ts):语义化版本数值分段比对纯算法;
142
143
  - [src/executor/process-utils.ts](../src/executor/process-utils.ts):`spawnWithStdin` 进程管道流控与 UTF-8 字符边界截断算法;
@@ -154,22 +155,54 @@
154
155
 
155
156
  ### 3.4 前端客户端解耦与自包含 Bundle 机制 (`src/ui/` + `src/client.ts`)
156
157
  - **源码物理剥离**:
157
- - [src/client.ts](../src/client.ts)(由 5,290 行直降至 **1,263 行,瘦身 76%**):仅保留右侧栏核心协同 `renderGithubRepoView` 与 `GithubRightbarBody` 及插件入口;
158
- - 12 个独立子模块下沉至 [src/ui/](../src/ui/) 目录(样式 1,295 行、全景大面板 732 行、设置弹窗 561 行、业务卡片 60~290 行);
158
+ - [src/client.ts](../src/client.ts)(保留右侧栏协同层与插件入口):保留右侧栏核心协同 `renderGithubRepoView` 与 `GithubRightbarBody` 及插件入口;新增 UI 优先下沉到 `src/ui/`,不在入口文件里堆叠;
159
+ - **19 个独立子模块**下沉至 [src/ui/](../src/ui/) 目录:样式(最大的单模块)、全景大面板、PR 审阅弹窗、Issue 查看弹窗、Run 详情弹窗、设置弹窗、使用指南,其余卡片类组件体量更小;
160
+ > 这里刻意不写绝对行数。行数会随每次重构变化,而本文档没人会跟着改——历史上同一个 `client.ts` 在本文件里同时存在「~1,260 行」与「约 2,400 行」两个互斥数字,`executor.ts` 也写着早已失效的「~820 行」。需要精确数字请直接看文件。
159
161
  - **命名空间防护**:
160
162
  - 严禁在与 `package.json` 的 `exports["./client"]` 同级创建名为 `client/` 的目录,统一命名为 `src/ui/` 与编译产物 `lib/ui/`,彻底杜绝模块解析器产生“同名文件 vs 目录”的歧义报错;
161
163
  - **自包含 Bundle 聚合器 (`scripts/build.mjs`)**:
162
164
  - 构建脚本按严格拓扑顺序将 `lib/ui/` 子模块内联注入 `lib/client.js` 的 `window.__ModuleLoader__.load` 闭包中,移除 `export` 关键字;
163
- - 保证 `GithubRightbarBody` 位于 `GithubWorkspacePanel` 之前(满足切片测试断言);
164
- - 对嵌入的 1,290 行纯 CSS 实施紧凑压缩,消除空行与多余注释,使编译产物从 5,282 行减至 4,008 行;
165
+ - **顺序即依赖**:`markdown-lite → pr-modal → issue-modal → …` 不可随意重排(`issue-modal` 复用了 `pr-modal` 的 `CollapsibleBody`);`workspace-panel` 置于后置列表,保证 `GithubRightbarBody` 位于 `GithubWorkspacePanel` 之前;
166
+ - 对嵌入的纯 CSS 实施紧凑压缩;压缩后强制校验「无注释定界符残留 / 无行首 `*` 悬空段 / 花括号配平」,任一项不过直接构建失败——非法 CSS 会被浏览器**静默丢弃整条规则**,只表现为「样式莫名不生效」,极难排查;
165
167
  - **React Hook 时序安全防线**:
166
- - 组件内引用的 `useRef`(如 `containerRef`)必须在组件声明最前列初始化,严禁跨行下置引发变量提升 `undefined`;所有的 Ref 访问点必须配备 `containerRef && containerRef.current` 守卫,杜绝运行时渲染崩溃。
168
+ - 全部 Hook(含 `useEffect`)必须位于任何 early return 之前,否则「参数由空变有值」时两次渲染的 Hook 数量不一致,触发 `Rendered fewer hooks than expected` 崩溃;
169
+ - `renderGithubRepoView` 这类每次渲染都重新执行的普通函数内**禁止**调用 Hook,状态必须住在有 Hook 的组件体内再传参;
170
+ - 组件内引用的 `useRef`(如 `containerRef`)必须在组件声明最前列初始化,严禁跨行下置引发变量提升 `undefined`;所有的 Ref 访问点必须配备 `containerRef && containerRef.current` 守卫。
171
+
172
+ ### 3.5 客户端加载器:对宿主 client 契约的适配层
173
+
174
+ `src/client.ts` 里的 `window.__ModuleLoader__.load({ id, factory })` **不是本插件的设计,而是宿主客户端的加载契约**:
175
+
176
+ - 全局声明在 `src/client.ts` 顶部(`Window.__ModuleLoader__`),唯一调用点在文件末尾的 `load({ id: "dsh-github-flow", factory })`;
177
+ - `factory` 收到的 `require` 是**宿主注入的模块解析器**(`react`、`react-dom` 等由宿主提供,插件不打包也不得打包自己的副本),`id` 必须与 `package.json` 的 `dsh.client.entry` 归属一致;
178
+ - 该 API **不是官方公开文档化的稳定接口**,语义由宿主实现决定。一旦宿主调整加载方式,**只改这一层**,`src/ui/` 下的 19 个子模块一行都不用动;
179
+ - 因为它是"适配层",`test/phase36-host-contract.test.mjs` 对它的**形状**做了断言(入口存在、id 固定、`apply`/`inject` 已导出、20 个子模块全部内联、闭包内无残留 ESM `export`),而不是只靠"跑起来没报错"来验证。
180
+
181
+ ### 3.6 本地开发联调机制 (Local Development & Junction Linking)
182
+
183
+ 当开发者在本地克隆代码库进行定制与二次开发时,需严格遵循本地联调机制:
184
+
185
+ 1. **Junction 目录联接直通机制**:
186
+ - DSH 宿主环境的各个 Profile(如 `~/.dsh/profiles/web`、`~/.dsh/profiles/desktop`)采用 pnpm 管理依赖。若通过 `file:` 协议安装插件,pnpm 会将其作为独立快照拷贝到 `node_modules` 中,并在后续操作中直接复用内容缓存哈希(`resolved 0, reused 1`);
187
+ - 同时,DSH 内置的 pnpm (v11.x) 默认开启严格构建拦截策略,当检测到本地依赖包含 `prepare: "pnpm run build"` 脚本时,会直接抛出 `ERR_PNPM_IGNORED_BUILDS` 阻断安装;
188
+ - **解法**:在 Windows 环境下将 Profile 的 `node_modules\dsh-github-flow` 替换为指向本地 Git 工作区根目录的原生 **Junction(目录联接)**,直接消除拷贝过程,实现工作区产物与宿主运行时的物理同一性。
189
+ 2. **两阶段生效边界(编译与热刷新)**:
190
+ - 宿主加载的入口为编译后的 `lib/main.js` 与 `lib/client.js`,修改 `src/` 源码后必须执行 `pnpm run build` 生成最新产物;
191
+ - **纯前端 UI / 样式变更**(`src/client.ts`、`src/ui/`):由于 DSH Web 客户端在页面载入时实时读取静态资源,按 **Ctrl + F5 强制刷新浏览器**即可即时呈现最新视图,**无需重启 DSH 进程**;
192
+ - **后端服务 / 执行引擎 / 工具实现**(`src/tools/`、`src/api/`、`src/executor.ts`、`src/main.ts`):受 Node.js 底层 ESM 模块缓存机制限制,必须**重启 DSH 进程**才能完成服务重载。
193
+ 3. **联接保护与自愈**:
194
+ - 切勿在已建立 Junction 的 Profile 目录下执行 `dsh plugin install` 或 `pnpm install`,以防包管理器抹除联接并退化为静态拷贝;
195
+ - 若联接失效或被覆盖,可使用以下命令一键恢复直通:
196
+ ```powershell
197
+ New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-github-flow" -Target "D:\Desktop\AgentWork\DSHQuestion\dsh-github-flow" -Force
198
+ ```
167
199
 
168
200
  ---
169
201
 
170
202
  ## 4. 自动化测试体系 (Test Suite)
171
203
 
172
- 项目配备了 **20 个独立阶段的全量自动化回归测试套件(`test/run-all.mjs`)**,覆盖 **880+ 项全量自动化测试断言**:
204
+ 项目配备了 **47 个独立阶段的全量自动化回归测试套件(`test/run-all.mjs`)**,合计 **2000+ 项断言**(精确条数以 `node test/run-all.mjs` 末尾的汇总输出为准,此处只给量级):
205
+ - 阶段数与 `test/run-all.mjs` 的 `phases` 数组由 `test/phase35-doc.test.mjs` 对账,改一处漏一处会直接红;
173
206
  - `phase1`:Issue / Run / PR / Repo / Help 命令装配与确认门禁;
174
207
  - `phase2`:Native Markdown 渲染与 Token 压缩率实测(>60% 降幅);
175
208
  - `phase3`:Release 资产交付、Secret 密文管道与 Skills 检索;
@@ -189,4 +222,30 @@
189
222
  - `phase17`:github_skill publish(agentskills.io 规范自检、--fix 修复与发布闭环);
190
223
  - `phase18`:github_agent_task(云端编码智能体会话)与 github_discussion(社区讨论与RFC);
191
224
  - `phase19`:github_project(Projects v2 看板)、github_codespace(云端开发机)与 skill update;
192
- - `phase20`:TelemetryRingBuffer、Cordis tools/result 事件总线运行时遥测、降级率统计与健康度看板。
225
+ - `phase20`:TelemetryRingBuffer、Cordis tools/result 事件总线运行时遥测、降级率统计与健康度看板;
226
+ - `phase21`:未提交数据滚动分页、文件级 Diff 二级下钻与 DSH 原生打开文件联动;
227
+ - `phase22`:remote 配置指纹与缓存失效契约(parseRemoteUrls 段扫描、指纹对书写顺序/命名免疫、worktree `.git` 指针解析、TtlCache 指纹命中与保守失效);
228
+ - `phase23`:离线身份解析对齐 git 规则(branch.<当前分支>.remote → origin → 首个三级回落、`.` 特殊值、六种 URL 形态、worktree HEAD/config 分居、旧「取第一个 github.com」正则移除断言);
229
+ - `phase24`:仓库拓扑与权限字段(isFork / parent 拼接 / viewerPermission 五档白名单)、远程布局四分类(github-only / mixed / no-github / no-remote)与混合布局的顺序免疫选择、默认分支从 refs/remotes/<remote>/HEAD 符号引用动态推断;
230
+ - `phase25`:右侧栏仓库徽章可视化(resolveRepoBadges 纯函数:fork 徽章与血缘行、READ/TRIAGE 只读徽章、可写与未知权限零徽章、parent.url 协议白名单防 javascript:/data: 注入)、bundle 内联断言与「零新增网络往返」静态约束;
231
+ - `phase26`:写操作权限自愈(GraphQL 权限错误补入确定性分类、permissionRemedy 按 Triage/Write/Admin 三档给出可操作指引并拼入 error 供模型自愈、限流 403 不误判、非权限错误零噪音、前端快捷按钮保持可用的防回退断言);
232
+ - `phase27`:关联仓库自由切换(getAssociatedRepos 双 remote / Fork+Parent 纯本地多仓推导、RepoSelectorDropdown 下拉组件、setDefaultRepo 官方 set-default 对齐与防注入白名单、Fork 血缘行一键双向快捷切换);
233
+ - `phase28`:PR 在线审阅工作台与主面板标签页解耦(splitUnifiedDiff 按文件切片索引、core.quotePath 八进制转义还原、含 ` b/` 路径的等值分割判定、删除文件路径回退、getPrDetail 参数白名单与 diff 阈值放宽不被截断、响应体不回传 fullDiff 防膨胀、**行级审查评论独立 API 通道 + original_line 回退 + 失败降级**、/api/github/pr-detail 路由真实调用与 405/400/500 错误路径、**action/branch-switch 请求体 256KB 上限与 413 行为验证**、主面板 mywork 拆为 prs/issues 四标签页、React Hooks 顺序静态防回退);
234
+ - `phase29`:数据可见性与 PR 弹窗交互(PR/Issue 查询移除 `--state=open` 硬过滤并补齐 state 字段、clampFilePaneWidth 纯函数边界与容器极窄保下限、文件区布局偏好 localStorage 往返与脏数据回退、分隔条拖拽 document 级监听与卸载兜底清理、折叠双恢复入口、**PR 标题行与 GitHub 跳转按钮同行(min-width:0 + flex-shrink:0 防长标题挤出)**、空态区分「筛选为空」与「账号无记录」);
235
+ - `phase30`:PR 弹窗 Markdown 渲染与滚动适配(markdown-lite 零依赖解析器:标题/粗斜体/行内码/围栏代码块/列表/引用/分隔线/任务列表;链接协议白名单拒绝 javascript:/data:/vbscript:/协议相对;HTML 标签降级为纯文本;estimateContentLines 折叠阈值估算;卡片 flex:0 0 auto 修复「长内容无法滚动」;CollapsibleBody 提升到模块顶层避免内联组件导致展开状态被重置)。
236
+ - `phase31`:Issue 在线查看工作台(getIssueDetail 参数白名单含小数/负数拒绝、**2MB 字符阈值防长讨论被截断成假空详情**、老版本 gh `Unknown JSON field` 的 BASE 字段降级链与 fieldsDegraded 标记、非字段类失败不重试、截断与解析缺失显式失败并给出浏览器直达链接、state/author/labels/milestone/subIssuesSummary/closedByPullRequestsReferences 归一化、SPAM 隐藏评论标记保留、图片清单正文+评论合并去重、/api/github/issue-detail 路由 200/400/405/500 全路径、action 通道 issue_detail 对称可用、主面板与右侧栏 Issue 卡片改点击进弹窗 + 同行 GitHub ↗ 外跳 stopPropagation + 卡片禁内联 flex 布局、onSelectIssue 追加在形参末尾、React Hooks 顺序与 bundle 三模块拓扑顺序防回退)。
237
+ - `phase32`:右侧栏 PR/Issue 状态筛选(**单仓库查询显式 `--state all`**,修复「只显示已打开」根因——gh 默认 state 就是 open,数据在到达浏览器前已被过滤;条数上限 10→20 并抽成 RIGHTBAR_LIST_LIMIT 常量,依据 `gh pr list` 按 number 降序而非 updatedAt,取 10 条时非 OPEN 仅剩 5 条;触顶下发 prsTruncated/issuesTruncated 并渲染提示;PR 四档 all/open/merged/closed 与 Issue 三档 all/open/closed 胶囊复用既有样式类、计数先于过滤保证数字反映全量;偏好按 cwd 隔离存储、非法值与脏 JSON 归一回退、大写输入归一;空态区分「仓库无记录」与「筛选无结果」并给切换引导;Hook 位置(不可落在 renderGithubRepoView 内)、切仓库对齐只依赖 cwd、**作用域键无碰撞(/a/b 与 /a_b 不得压成同一个键)**、卡片布局纪律防回退)。
238
+ - `phase33`:使用指南内容与代码事实对账(help-modal 从 4 Tab 扩到 5 Tab,新增「18 大工具速查」;**工具数量对 src/main.ts 的工具工厂注册语句数量、action 总数对 src/tools 的 enum 累计、Tab 名称对 TAB_DEFS 与 workspace-panel 逐个核对**,代码演进而指南没跟上即失败;覆盖 PR/Issue 在线弹窗、状态筛选三档、Markdown 渲染、仓库切换器、提交详情、Diff 下钻、view_images 等近期能力;FAQ 扩至 6 条含「列表为空但网页有记录」与「为何需重启 DSH」;**防编造断言:指南中每条 `gh auth refresh -s <scope>` 必须能在源码找到出处**;**github_help 知识库同步对账:mapping 表声明的每组 action 数逐个对 src/tools 的 enum 长度、每个业务 action 必须在知识库有落点、规模口径统一为「18 工具 / 103 业务 action」(排除 help 的 21 查询主题与 api 的 4 HTTP method),Agent 侧知识库与 UI 侧使用指南必须同口径**)。
239
+ - `phase34`:**gh CLI 契约测试**(离线采集 18 工具 × 103 action 的全部 argv,逐条校验下发的每个 flag 与 `--json` 字段都真实存在于缓存下来的 `gh <cmd> --help` 里;`scripts/cache-gh-help.mjs` 抓取 124 个子命令的帮助并裁剪成 fixture;覆盖 124 argv / 317 flag / 256 个 `--json` 字段,SKIP 白名单显式登记「需真实凭证或真实文件」的少数条目)。
240
+ - `phase35`:**文档数字派生测试**(README / README.en / help.ts / help-modal.ts 里的「工具数 / 业务 action 数」全部从 `src/main.ts` 与 `lib/tools/*.js` 派生,一个都不手写;同时守卫三条历史漂移:不再出现 `104`、不再出现不可复现的「87%(104/120)」、README 的 `ctx.github` 示例必须是 `async`;并对账 `docs/architecture.md` 声明的测试阶段数与 `run-all.mjs` 的 `phases` 数组长度)。
241
+ - `phase36`:**宿主契约测试**(① 客户端 Bundle 形状:loader 入口 / id / apply+inject 导出 / 19 个 Bundle 子模块内联(18 UI + 1 shared)/ 闭包内无残留 ESM `export`;② 插件元数据:`inject` 收敛为唯一硬依赖 `tools`、`provide='github'`、无 `ctx.github` 裸赋值兜底、Config 已迁 Schemastery、`peerDependencies` 只声明宿主 cordis、`exports["./host"]` 指向的产物真实存在;③ 18 个工具的 descriptor 逐个复刻宿主 enforced JSON Schema 子集规则——关键字白名单、`type` 取值域、`properties`/`required`/`items`/`additionalProperties` 的归属约束、`required ⊆ properties`、`enum` 标量性,以及 `output.schema`/`output.render`/`execute` 的必备性;④ **真实 cordis Context 加载冒烟**——只 provide `tools` 时插件必须 ACTIVE 且 18 工具可用(这条专抓 mock 抓不到的缺陷:cordis 的 Context 是 Proxy,读未 inject 的服务属性会**抛错**而不是返回 undefined)、`commands`/`webServer` 晚于插件就绪仍能补注册、volatile 热更新不得冲掉显式配置的 `maxOutputBytes`)。
242
+ - `phase37`:**工具档位契约测试**(`applyTier` 纯函数:默认零破坏——18 工具 / 24,043 字符实测不变;`core` 5 / 8,534(−64.5%)、`ci` 7 / 9,943、`maintain` 8 / 13,274;裸工具名与预设可混用、未知档位 `console.warn` 一次后忽略而非静默清空;`read_only` 17 / 22,761——`github_api` 整体剔除、14 个混合工具剪 action enum 且**交集为空**、description 前置 `[只读模式]`;纯函数性质:`Object.freeze` 入参不抛错、重复调用结果一致、剪枝产物与入参解引用)。
243
+ - `phase38`:**破坏性操作闸门契约测试**(声明表覆盖 16 个 `(tool, action)`;`cache delete` 五条分支真值表——`all`/`cache_id`/`key` 三条都拦、无选择器不拦仍抛精确错、`list` 不误伤;条件闸门不**过度**拦截——`release upload` 不带 clobber、`skill publish` 带 `dry_run`/`fix`、`pr checkout` 不带 force、`issue transfer` 缺 `target_repo` 全部放行;9 条主文案逐字快照;`wrapWithGate` 只替换 `execute`,`name`/`parameters`/`output` 保持同一引用,拦截时**零副作用**不触达工具本体)。
244
+ - `phase39`:**状态徽章共享映射契约测试**(`src/shared/state-badges.ts` 必须零 import——构建期会被整体内联,有依赖就会在 bundle 作用域里变 undefined 导致白屏;六个纯函数的状态→图标/色调覆盖表;服务端 `format*Badge` 22 条输出与旧实现**逐字一致**;`lib/client.js` 内每个共享函数恰好 1 份定义、无残留 `shared/` import;`build.mjs` 把共享模块排在内联顺序最前且剥 import 的正则覆盖 `ui|shared` 两族)。
245
+ - `phase42`:**审阅锚点契约测试**(s7:`getPrDetail` 必须取回 40 位十六进制的 `headRefOid`/`baseRefOid`——分支会移动,SHA 才是可钉住的审查锚点;`renderers/pr.ts` 的分支行渲染成 `feat/x@a1b2c3d`,缺 SHA 时**不得**出现 `@undefined`;`changedFiles`/`filesTruncated`/`diffIncomplete` 三个截断字段各自成立且互不复用)。
246
+ - `phase43`:**行级评注写入契约测试**(s7:`gh pr review` 没有行级参数,唯一通道是 `POST /pulls/{n}/reviews`;`buildPrReviewPayload` 的 REST 字段形状与「一次说完所有校验错误」;`createPrReview` 的 argv 严格等于 `['api','--method','POST','repos/o/r/pulls/12/reviews','--input','-']` 且 stdin 含 event/comments[].side;**422 降级**把评注折叠进 body(内容不丢、显式 `degraded`);403 不降级;**不传 comments 时 argv 与改前逐字节一致**)。
247
+ - `phase44`:**仓库规则与严重度契约测试**(s7:`extractSection` 的标题边界——代码围栏内的 `##` 不算标题、一级标题同样终止截取;`normalizeSeverity` 只补不删、幂等、`[P0]` 永不降级;`readReviewRules` 无 `AGENTS.md` 时返回 `null` 且 `pr view` 输出**不含** `reviewRules` 键)。
248
+ - `phase45`:**CI 失败日志锚点萃取测试**(s7:pytest `FAILED` / node `AssertionError` / Go `panic:` / tsc `error TSxxxx` 四类锚点命中;相邻锚点合并成一块;单块行数 ≤ `2*radius+1`;`maxBlocks` 与 `droppedBlocks` 显式计数;无锚点走尾部 200 行回退;`log_failed` 已把输出预算放开到 2MB 字符/8MB 字节——否则截断发生在萃取之前)。
249
+ - `phase46`:**任务级原语契约测试**(s8:`classifyCommits` 的 `feat!:` 必须归 breaking(否则版本少跳一级);`suggestVersion` 的 auto 推导(breaking→major / feat→minor / 其余→patch)与无 tag 时从 0.1.0 起步;`selectFilesByBudget` 超预算只给元信息不硬塞;`finalizeEnvelope` 的 `cost.chars` 由信封体积计算而非手写;`github_task` 三个原语的端到端行为(review_pr 读模式零写回、写模式必须 confirm 且 commit_id 钉在 head SHA、diagnose_ci 只挑失败 job 并在日志截断时 `totalUnknown=true`、prepare_release **绝不自动发版**);档位层面 core=3 个工具、只读模式剪掉 review_pr 保留另两个原语)。
250
+ - `phase47`:**Actions Run 在线详情契约测试**(`getRunDetail` 的 `run_id` 只认纯数字——它是 `gh run view` 的位置参数,`--force`/`--web` 必须在触达子进程前拒绝且 argv 零产出;repo 白名单额外拒 `-` 开头与 `..`;`--json` 字段集含 `jobs` 且预算 2MB/8MB;缺 `steps` 归一化为空数组、`failedJobs`/`failedSteps` 只在服务端算一次;`truncated` 或解析缺失显式失败并给浏览器直达;`Unknown JSON field` 走 BASE 字段集重试且只重试一次;`getRunLogExcerpts` 用 `-j <jobId> --log-failed` **定向**拉取、最多 3 个 job、绝不使用 `--log` 整包、单 job 失败进 `errors[]` 而整体仍 `ok:true`;`run-log.ts` 的纯文本摘要(锚点块 / 尾部 200 行回退 / 两道截断声明 / 不产生 Markdown 围栏);`/api/github/run-detail` 与 `/api/github/run-logs` 的 200/400/405/500 与 `job_id` 逗号/重复参数两种写法;前端 `onSelectRun` 追加在形参末尾、Actions 卡片标题行拆分 + 外跳 `stopPropagation` + 禁内联 `display:flex`;弹窗 Hook 先于 early return、日志必须走 `<pre>` 且不接 markdown-lite;bundle 内联顺序 `issue-modal → run-modal` 与 20 个子模块计数)。
251
+ - `phase41`:**侧栏入口图标契约测试**(issue #9 回归网:宿主 `renderSlot("sidebar.panellist", { size: wide ? 16 : 18, active })` 注入的 `size` 必须被透传——从 `lib/client.js` 抽出 `GithubSidebarIcon` 函数体、在沙箱里真调,断言 16/18 两档尺寸、根节点必须是裸 `svg`(有 wrapper 就会把图标格撑宽、标签右移 12px)、`fill` 用 `currentColor` 继承行颜色、缺省/非数字 `size` 兜底 16、`active` 不改变输出结构)。
package/docs/images/1.png CHANGED
Binary file
@@ -39,6 +39,25 @@ export declare function sendJson(res: any, status: number, data: any): void;
39
39
  * 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
40
40
  */
41
41
  export declare function readJsonBody(req: any, maxBytes?: number): Promise<any>;
42
+ /**
43
+ * 本地 HTTP 写端点的跨站门禁。
44
+ *
45
+ * 威胁模型(写准,别夸大):防的是「任意网页用 simple request 打本地端口」这一类
46
+ * CSRF——`Content-Type: text/plain` 的跨站 POST 不触发预检,浏览器会照发。
47
+ * **不防 DNS rebinding**:攻击者页面若与目标同 host 名,`Origin === Host` 就成立。
48
+ * 真正挡住 rebinding 需要校验 Host 白名单或引入 token,属另一件事。
49
+ *
50
+ * 两道门:
51
+ * 1) `Origin` 必须为空(同源导航或非浏览器客户端)或与 `Host` 同源;
52
+ * 2) 需要读 JSON body 的端点额外要求 media type 恰为 `application/json`
53
+ * ——它不在 simple request 白名单里,浏览器会强制先发预检。
54
+ *
55
+ * 为什么允许 Origin 为空:同源 fetch 在部分场景下不带 Origin,非浏览器客户端
56
+ * (curl / 脚本)也不带。这两类不构成跨站威胁,拦掉只会误伤自家前端。
57
+ */
58
+ export declare function guardLocalWrite(req: any, res: any, opts?: {
59
+ requireJson?: boolean;
60
+ }): boolean;
42
61
  /** 各路由分组共享的依赖包类型:注册器装配时一次性注入,分组内不再触碰全局 */
43
62
  export interface RouteDeps {
44
63
  bindRoute: (route: any) => void;
@@ -132,3 +132,57 @@ export function readJsonBody(req, maxBytes = 256 * 1024) {
132
132
  req.on('error', reject);
133
133
  });
134
134
  }
135
+ /**
136
+ * 本地 HTTP 写端点的跨站门禁。
137
+ *
138
+ * 威胁模型(写准,别夸大):防的是「任意网页用 simple request 打本地端口」这一类
139
+ * CSRF——`Content-Type: text/plain` 的跨站 POST 不触发预检,浏览器会照发。
140
+ * **不防 DNS rebinding**:攻击者页面若与目标同 host 名,`Origin === Host` 就成立。
141
+ * 真正挡住 rebinding 需要校验 Host 白名单或引入 token,属另一件事。
142
+ *
143
+ * 两道门:
144
+ * 1) `Origin` 必须为空(同源导航或非浏览器客户端)或与 `Host` 同源;
145
+ * 2) 需要读 JSON body 的端点额外要求 media type 恰为 `application/json`
146
+ * ——它不在 simple request 白名单里,浏览器会强制先发预检。
147
+ *
148
+ * 为什么允许 Origin 为空:同源 fetch 在部分场景下不带 Origin,非浏览器客户端
149
+ * (curl / 脚本)也不带。这两类不构成跨站威胁,拦掉只会误伤自家前端。
150
+ */
151
+ export function guardLocalWrite(req, res, opts = {}) {
152
+ // 早退前必须把请求体排空:Node 对未被消费的请求体通常直接断连,
153
+ // 客户端 fetch 会拿到网络错误而不是我们刚写好的 403/415。
154
+ const reject = (status, error) => {
155
+ try {
156
+ req.resume?.();
157
+ }
158
+ catch {
159
+ /* 排空失败不影响响应 */
160
+ }
161
+ sendJson(res, status, { ok: false, error });
162
+ return false;
163
+ };
164
+ const origin = typeof req.headers?.origin === 'string' ? req.headers.origin : '';
165
+ const host = String(req.headers?.host || '');
166
+ if (origin) {
167
+ let originHost = '';
168
+ try {
169
+ originHost = new URL(origin).host;
170
+ }
171
+ catch {
172
+ // Origin 形如 "null"(沙箱 iframe / file://)时 URL 解析失败 → 视为不同源
173
+ originHost = '';
174
+ }
175
+ if (!originHost || originHost !== host) {
176
+ return reject(403, '跨站请求已拒绝:Origin 与本地服务不同源');
177
+ }
178
+ }
179
+ if (opts.requireJson) {
180
+ // 精确比对 media type:`text/plain; x=application/json` 这种"看起来带 json"的值
181
+ // 用 includes 会误判通过——那不是校验,是自我安慰。
182
+ const mediaType = String(req.headers?.['content-type'] || '').split(';')[0].trim().toLowerCase();
183
+ if (mediaType !== 'application/json') {
184
+ return reject(415, '请求必须使用 Content-Type: application/json');
185
+ }
186
+ }
187
+ return true;
188
+ }
@@ -5,7 +5,7 @@
5
5
  import { spawn } from 'node:child_process';
6
6
  import { hasGitDir } from '../workspace.js';
7
7
  import { watchGitMetadata } from '../git-watcher.js';
8
- import { isSafeExternalUrl, sendJson } from './route-helpers.js';
8
+ import { isSafeExternalUrl, sendJson, guardLocalWrite } from './route-helpers.js';
9
9
  /**
10
10
  * 构造「在新终端窗口运行命令」的平台命令(execFile 数组形态,不经 shell)。
11
11
  *
@@ -141,8 +141,21 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
141
141
  kind: 'exact',
142
142
  path: '/api/github/refresh',
143
143
  handler: async (req, res) => {
144
- cacheMap.clear();
145
- globalCache.clear();
144
+ // 只接受 POST:这个端点没有方法约束时,任意网页可用 <img src> 触发 GET,
145
+ // 等于给「清空缓存」开了个跨站后门。前端两处调用都是 POST。
146
+ if (req.method !== 'POST') {
147
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
148
+ return;
149
+ }
150
+ // 无请求体,只校验 Origin。
151
+ if (!guardLocalWrite(req, res))
152
+ return;
153
+ // 连容灾底座一起清:用户点「重试刷新」的语义是「宁可空白也重来」。
154
+ // 只清活动缓存会留下一个致命死角——失败分支从不写缓存,于是 fallback
155
+ // 里的旧快照一旦冻结就永久生效(仓库身份改错了也永远出不来),
156
+ // 刷新按钮点了等于没点。
157
+ cacheMap.clear({ keepFallback: false });
158
+ globalCache.clear({ keepFallback: false });
146
159
  sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
147
160
  },
148
161
  });
@@ -155,9 +168,33 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
155
168
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
156
169
  return;
157
170
  }
171
+ // 跨站门禁:本地服务上的写操作不能被任意网页用 simple request 触发。
172
+ if (!guardLocalWrite(req, res, { requireJson: true }))
173
+ return;
174
+ // 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
175
+ // 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
176
+ // 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
177
+ const MAX_ACTION_BODY_BYTES = 256 * 1024;
158
178
  let body = '';
159
- req.on('data', (chunk) => { body += chunk; });
179
+ let bodyBytes = 0;
180
+ let bodyTooLarge = false;
181
+ req.on('data', (chunk) => {
182
+ if (bodyTooLarge)
183
+ return;
184
+ bodyBytes += chunk.length;
185
+ if (bodyBytes > MAX_ACTION_BODY_BYTES) {
186
+ bodyTooLarge = true;
187
+ body = '';
188
+ // 报 KB 而非字节数:262144 这种数字对人没有意义
189
+ sendJson(res, 413, { ok: false, error: `请求体超过 ${MAX_ACTION_BODY_BYTES / 1024}KB 上限` });
190
+ req.destroy();
191
+ return;
192
+ }
193
+ body += chunk;
194
+ });
160
195
  req.on('end', async () => {
196
+ if (bodyTooLarge)
197
+ return;
161
198
  try {
162
199
  const payload = body ? JSON.parse(body) : {};
163
200
  const cwd = payload.cwd || process.cwd();
@@ -243,6 +280,28 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
243
280
  sendJson(res, 200, { ok: true, currentBranch: r.currentBranch, message: `已成功切换到分支 ${r.currentBranch}` });
244
281
  break;
245
282
  }
283
+ case 'set_default_repo':
284
+ case 'set_active_repo': {
285
+ const target = typeof payload.repo === 'string' ? payload.repo.trim() : (typeof payload.remote === 'string' ? payload.remote.trim() : '');
286
+ if (!target) {
287
+ sendJson(res, 400, { ok: false, error: '缺少目标仓库全名或 remote 别名' });
288
+ return;
289
+ }
290
+ const r = await executor.setDefaultRepo(cwd, target);
291
+ if (!r.ok) {
292
+ sendJson(res, 400, { ok: false, error: r.error });
293
+ return;
294
+ }
295
+ // 彻底重置缓存(包含容灾快照),使下一次概览请求立即以新基准重新发起探测
296
+ cacheMap.clear({ keepFallback: false });
297
+ globalCache.clear({ keepFallback: false });
298
+ sendJson(res, 200, {
299
+ ok: true,
300
+ target: r.target,
301
+ message: `已成功将基准仓库切换为 ${r.target}`,
302
+ });
303
+ break;
304
+ }
246
305
  case 'file_diff':
247
306
  case 'get_file_diff': {
248
307
  const fileTarget = typeof payload.file === 'string' ? payload.file.trim() : (typeof payload.filePath === 'string' ? payload.filePath.trim() : '');
@@ -264,11 +323,34 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
264
323
  sendJson(res, 200, { ok: true, data: r });
265
324
  break;
266
325
  }
326
+ case 'pr_detail':
327
+ case 'get_pr_detail': {
328
+ const prNumber = Number(payload.number || payload.prNumber);
329
+ const targetRepo = typeof payload.repo === 'string' ? payload.repo.trim() : undefined;
330
+ const r = await executor.getPrDetail(cwd, prNumber, targetRepo);
331
+ sendJson(res, r.ok ? 200 : 400, r);
332
+ break;
333
+ }
334
+ case 'issue_detail':
335
+ case 'get_issue_detail': {
336
+ // 与 pr_detail 对称的 Issue 详情通道:供 POST 调用方(Agent/脚本)复用,
337
+ // 与 GET /api/github/issue-detail 共用同一个 executor 方法。
338
+ const issueNumber = Number(payload.number || payload.issueNumber || payload.issue_number);
339
+ const targetRepo = typeof payload.repo === 'string' ? payload.repo.trim() : undefined;
340
+ const r = await executor.getIssueDetail(cwd, issueNumber, targetRepo);
341
+ sendJson(res, r.ok ? 200 : 400, r);
342
+ break;
343
+ }
267
344
  default:
268
345
  sendJson(res, 400, { ok: false, message: `未知的操作类型: ${payload.action}` });
269
346
  }
270
347
  }
271
348
  catch (err) {
349
+ // 客户端发来的畸形 JSON 是 400,不是服务端故障;顺带不再把解析器内部消息透出去
350
+ if (err instanceof SyntaxError) {
351
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
352
+ return;
353
+ }
272
354
  sendJson(res, 500, { ok: false, error: err.message || '操作执行失败' });
273
355
  }
274
356
  });
@@ -307,9 +389,33 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
307
389
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
308
390
  return;
309
391
  }
392
+ // 跨站门禁:切分支是本服务最危险的写操作,与 /action 同一套校验。
393
+ if (!guardLocalWrite(req, res, { requireJson: true }))
394
+ return;
395
+ // 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
396
+ // 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
397
+ // 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
398
+ const MAX_ACTION_BODY_BYTES = 256 * 1024;
310
399
  let body = '';
311
- req.on('data', (chunk) => { body += chunk; });
400
+ let bodyBytes = 0;
401
+ let bodyTooLarge = false;
402
+ req.on('data', (chunk) => {
403
+ if (bodyTooLarge)
404
+ return;
405
+ bodyBytes += chunk.length;
406
+ if (bodyBytes > MAX_ACTION_BODY_BYTES) {
407
+ bodyTooLarge = true;
408
+ body = '';
409
+ // 报 KB 而非字节数:262144 这种数字对人没有意义
410
+ sendJson(res, 413, { ok: false, error: `请求体超过 ${MAX_ACTION_BODY_BYTES / 1024}KB 上限` });
411
+ req.destroy();
412
+ return;
413
+ }
414
+ body += chunk;
415
+ });
312
416
  req.on('end', async () => {
417
+ if (bodyTooLarge)
418
+ return;
313
419
  try {
314
420
  const payload = body ? JSON.parse(body) : {};
315
421
  const cwd = payload.cwd || undefined;
@@ -337,7 +443,106 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
337
443
  });
338
444
  },
339
445
  });
340
- // 5. 本地 Git 元数据定向监听(专供右侧栏开启「本地 Git 改动实时感知」时长轮询)
446
+ // 5. PR 在线详情与代码差异比对(在线代码审阅专用,单次往返聚合元数据、评论流与按文件切片的 Unified Diff)
447
+ bindRoute({
448
+ kind: 'exact',
449
+ path: '/api/github/pr-detail',
450
+ handler: async (req, res) => {
451
+ if (req.method !== 'GET') {
452
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
453
+ return;
454
+ }
455
+ try {
456
+ const u = new URL(req.url || '', 'http://127.0.0.1');
457
+ const number = Number(u.searchParams.get('number') || u.searchParams.get('prNumber'));
458
+ const cwd = u.searchParams.get('cwd') || undefined;
459
+ const repo = u.searchParams.get('repo') || undefined;
460
+ const r = await executor.getPrDetail(cwd, number, repo);
461
+ sendJson(res, r.ok ? 200 : 400, r);
462
+ }
463
+ catch (err) {
464
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 PR 详情异常' });
465
+ }
466
+ },
467
+ });
468
+ // 6. Issue 在线详情(正文 / 评论时间线 / 标签 / 里程碑 / 关联 PR)
469
+ // 与 pr-detail 同构:GET 读语义、单次往返、参数白名单校验全部下沉到 executor。
470
+ bindRoute({
471
+ kind: 'exact',
472
+ path: '/api/github/issue-detail',
473
+ handler: async (req, res) => {
474
+ if (req.method !== 'GET') {
475
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
476
+ return;
477
+ }
478
+ try {
479
+ const u = new URL(req.url || '', 'http://127.0.0.1');
480
+ const number = Number(u.searchParams.get('number') || u.searchParams.get('issueNumber'));
481
+ const cwd = u.searchParams.get('cwd') || undefined;
482
+ const repo = u.searchParams.get('repo') || undefined;
483
+ const r = await executor.getIssueDetail(cwd, number, repo);
484
+ sendJson(res, r.ok ? 200 : 400, r);
485
+ }
486
+ catch (err) {
487
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 Issue 详情异常' });
488
+ }
489
+ },
490
+ });
491
+ // 6.1 Run 在线详情(run 元数据 + jobs/steps 树)。
492
+ // 与 pr-detail / issue-detail 同构:GET 读语义、单次往返、参数白名单全部下沉到
493
+ // executor(run_id 会被当作 `gh run view` 的位置参数,校验点只能有一处)。
494
+ bindRoute({
495
+ kind: 'exact',
496
+ path: '/api/github/run-detail',
497
+ handler: async (req, res) => {
498
+ if (req.method !== 'GET') {
499
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
500
+ return;
501
+ }
502
+ try {
503
+ const u = new URL(req.url || '', 'http://127.0.0.1');
504
+ const runId = u.searchParams.get('run_id') || u.searchParams.get('runId') || '';
505
+ const cwd = u.searchParams.get('cwd') || undefined;
506
+ const repo = u.searchParams.get('repo') || undefined;
507
+ const r = await executor.getRunDetail(cwd, runId, repo);
508
+ sendJson(res, r.ok ? 200 : 400, r);
509
+ }
510
+ catch (err) {
511
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 Run 详情异常' });
512
+ }
513
+ },
514
+ });
515
+ // 6.2 Run 失败日志摘要(按需拉取,慢链路独立成路由)。
516
+ // 为什么不并进 run-detail:实测单次日志 4–40 秒且会被代理直接掐断,
517
+ // 合并后弹窗首屏要等最慢的那条链路——结构数据明明已经到手却显示不出来。
518
+ bindRoute({
519
+ kind: 'exact',
520
+ path: '/api/github/run-logs',
521
+ handler: async (req, res) => {
522
+ if (req.method !== 'GET') {
523
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
524
+ return;
525
+ }
526
+ try {
527
+ const u = new URL(req.url || '', 'http://127.0.0.1');
528
+ const runId = u.searchParams.get('run_id') || u.searchParams.get('runId') || '';
529
+ const cwd = u.searchParams.get('cwd') || undefined;
530
+ const repo = u.searchParams.get('repo') || undefined;
531
+ // 兼容 `job_id=1&job_id=2` 与 `job_id=1,2` 两种写法(上限由 executor 兜底)
532
+ const jobIds = u.searchParams
533
+ .getAll('job_id')
534
+ .flatMap((v) => String(v).split(','))
535
+ .map((v) => v.trim())
536
+ .filter(Boolean);
537
+ const r = await executor.getRunLogExcerpts(cwd, runId, jobIds, repo);
538
+ sendJson(res, r.ok ? 200 : 400, r);
539
+ }
540
+ catch (err) {
541
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 Run 失败日志异常' });
542
+ }
543
+ },
544
+ });
545
+ // 7. 本地 Git 元数据定向监听(专供右侧栏开启「本地 Git 改动实时感知」时长轮询)
341
546
  // 严格安全纪律:只针对 .git/index 与 .git/HEAD 实施定向非递归监听,严禁监听源码目录防句柄耗尽;
342
547
  // 800ms 防抖合并;连接断开时自动关闭 watcher 释放系统资源(委托给底层 git-watcher 服务)。
343
548
  bindRoute({
@@ -374,7 +579,7 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
374
579
  }
375
580
  },
376
581
  });
377
- // 6. 纯本地 Git 状态毫秒级轻量查询(专供工作区改动时的局部定向刷新)
582
+ // 8. 纯本地 Git 状态毫秒级轻量查询(专供工作区改动时的局部定向刷新)
378
583
  // 严格安全与性能纪律:
379
584
  // 1. 动静解耦:仅执行本地 getLocalGitStatus、getGitCommitLog 与 getGitBranches;
380
585
  // 2. 100% 零网络调用、零远端 GitHub API 请求、零 Token 额度消耗,耗时仅 5~10ms;
@@ -424,7 +629,7 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
424
629
  }
425
630
  },
426
631
  });
427
- // 7. 单个未提交文件的 Git Diff 差异查询(供右侧栏 FileDiffModal 调用)
632
+ // 9. 单个未提交文件的 Git Diff 差异查询(供右侧栏 FileDiffModal 调用)
428
633
  bindRoute({
429
634
  kind: 'exact',
430
635
  path: '/api/github/file-diff',
@@ -458,7 +663,7 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
458
663
  }
459
664
  },
460
665
  });
461
- // 8. 本地未提交文件列表轻量流式分页查询(供右侧栏滑动加载调用)
666
+ // 10. 本地未提交文件列表轻量流式分页查询(供右侧栏滑动加载调用)
462
667
  bindRoute({
463
668
  kind: 'exact',
464
669
  path: '/api/github/uncommitted-files',
@@ -1,5 +1,5 @@
1
1
  import { loadUpdateSettings, saveUpdateSettings, isValidUpdateMode, describeUpdateMode } from '../update-settings.js';
2
- import { sendJson, readJsonBody } from './route-helpers.js';
2
+ import { sendJson, readJsonBody, guardLocalWrite } from './route-helpers.js';
3
3
  export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
4
4
  const { bindRoute } = deps;
5
5
  // 1. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
@@ -31,6 +31,10 @@ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
31
31
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
32
32
  return;
33
33
  }
34
+ // 跨站门禁:一键升级是最敏感的写链路(会改动用户 profile 依赖)。
35
+ // 本端点无请求体,因此只校验 Origin,不要求 Content-Type。
36
+ if (!guardLocalWrite(req, res))
37
+ return;
34
38
  try {
35
39
  // 一键升级是用户显式动作,不受 checkUpdate 配置熔断:check() 在禁用时
36
40
  // 会返回 latestVersion 缺失的兜底态,此时回退 'latest' 让 pnpm 自行解析
@@ -71,6 +75,9 @@ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
71
75
  sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
72
76
  return;
73
77
  }
78
+ // 跨站门禁:更新源设置会写盘并改变后续所有版本探测的走向。
79
+ if (!guardLocalWrite(req, res, { requireJson: true }))
80
+ return;
74
81
  try {
75
82
  const body = await readJsonBody(req);
76
83
  const mode = body?.mode;