@lolkda/dsh-chrome-devtools-mcp 0.0.0-stage → 0.3.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.
@@ -0,0 +1,391 @@
1
+ # Chrome DevTools 设置页与版本管理
2
+
3
+ ## 1. 文档信息
4
+
5
+ - 文档标识:chrome-devtools-settings
6
+ - 规格状态:已确认
7
+ - 最后更新:2026-10-23
8
+ - 实现状态:已实现
9
+ - 验证状态:通过
10
+ - 关联文档:无
11
+
12
+ ## 2. 目标与范围
13
+
14
+ ### 背景
15
+
16
+ 当前 `@lolkda/dsh-chrome-devtools-mcp@0.2.0` 是纯 patch bundle,直接用 `npx chrome-devtools-mcp@latest` 启动。它没有设置页,也没有宿主接口,用户无法在 DSH 内查看 MCP 状态、查看版本或切换版本。
17
+
18
+ ### 目标
19
+
20
+ - 在 DSH 设置弹窗左侧导航新增“Chrome MCP”入口。
21
+ - 展示 MCP 进程、Chrome CDP 连通性、配置版本、运行版本和最新版本。
22
+ - 支持在 `latest` 与 GitHub Releases 中的稳定版本之间切换。
23
+ - 版本选择保存后写入状态文件,重启 DSH 进程后由 launcher 使用新版本。
24
+ - 生成并复制 Chrome 远程调试启动命令。
25
+ - 生成并复制 SSH 反向隧道命令。
26
+ - 提供 Windows OpenSSH Client 与可选 OpenSSH Server 的安装、启用命令。
27
+
28
+ ### 非目标
29
+
30
+ - 不在浏览器中直接执行 Windows 上的 SSH 命令。
31
+ - 不实现不重启 DSH 的热切换。
32
+ - 不启动 Chrome,不管理 Chrome 安装。
33
+ - 不自动发布新版本到 npm。
34
+ - 不把 Chrome CDP 端口暴露到局域网。
35
+
36
+ ## 3. 参与深度与委托
37
+
38
+ - 参与深度:架构与设计
39
+ - 局部例外:无
40
+ - 需要用户确认:设置入口位置、版本切换需重启生效、隧道命令采用生成并复制、Windows OpenSSH 命令需一并提供。上述事项已确认。
41
+ - 模型自主决定:宿主模块拆分、API 字段、状态推断方式、缓存策略、样式实现、测试组织。
42
+ - 当前执行授权:在 `/app/project/dsh-chrome-devtools-mcp` 内实现、测试、打包,并在临时 profile 中验证;不修改当前正式 `web` profile。
43
+
44
+ ## 4. 行为要求与约束
45
+
46
+ ### R-001:设置入口
47
+
48
+ - 参与者或触发方:打开 DSH 设置的用户。
49
+ - 前置条件与输入:插件已安装并在 web profile 中启用。
50
+ - 必须满足的行为:设置弹窗左侧导航出现“Chrome MCP”,点击后渲染本插件页面。
51
+ - 输出或状态变化:页面进入加载状态并请求状态与版本数据。
52
+ - 边界与失败行为:状态接口失败时页面仍渲染,显示错误与重试按钮。
53
+
54
+ ### R-002:状态展示
55
+
56
+ - 参与者或触发方:设置页加载或用户点击刷新。
57
+ - 前置条件与输入:宿主可读取状态文件与运行时记录。
58
+ - 必须满足的行为:展示 MCP 进程状态、CDP 可达性、配置版本、运行版本、最新版本和 CDP 端点。
59
+ - 输出或状态变化:不可达状态显示明确原因,不伪装为已连接。
60
+ - 边界与失败行为:运行时记录缺失、PID 已退出、CDP 超时均显示为对应状态。
61
+
62
+ ### R-003:版本列表
63
+
64
+ - 参与者或触发方:设置页加载或用户刷新版本列表。
65
+ - 前置条件与输入:宿主可访问 GitHub Releases,或已有缓存。
66
+ - 必须满足的行为:默认列出 `latest` 与稳定版本;prerelease 折叠;版本按语义版本降序。
67
+ - 输出或状态变化:显示数据来源(GitHub / 缓存 / npm)。
68
+ - 边界与失败行为:GitHub 限流或失败时使用缓存;无缓存时回退 npm;全部失败时显示错误并允许手动输入版本。
69
+
70
+ ### R-004:版本保存
71
+
72
+ - 参与者或触发方:用户选择版本并点击应用。
73
+ - 前置条件与输入:版本为 `latest` 或合法 `x.y.z`。
74
+ - 必须满足的行为:合法选择原子写入状态文件;非法版本拒绝保存;页面提示“重启 DSH 后生效”。
75
+ - 输出或状态变化:配置版本立即更新,运行版本保持不变,直到重启。
76
+ - 边界与失败行为:写文件失败时不改变现有状态,显示错误。
77
+
78
+ ### R-005:启动版本解析
79
+
80
+ - 参与者或触发方:DSH 启动 MCP client。
81
+ - 前置条件与输入:launcher 读取状态文件。
82
+ - 必须满足的行为:`latest` 使用 `chrome-devtools-mcp@latest`;固定版本使用 `chrome-devtools-mcp@x.y.z`。
83
+ - 输出或状态变化:launcher 写入运行时记录并启动 npx。
84
+ - 边界与失败行为:状态文件缺失或损坏时回退 `latest`;npx 启动失败时写退出信息并以非零码结束。
85
+
86
+ ### R-006:SSH 反向隧道
87
+
88
+ - 参与者或触发方:用户在设置页填写 DSH 主机 SSH 用户名(不是 Windows 登录用户)。
89
+ - 前置条件与输入:DSH 页面地址可用。
90
+ - 必须满足的行为:生成 `ssh -N -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -R 9222:127.0.0.1:9222 <user>@<host>`;提供复制按钮。
91
+ - 输出或状态变化:复制成功显示反馈;失败时选中文本供手动复制。
92
+ - 边界与失败行为:用户名为空时使用占位符,不把空字符串拼进命令。
93
+
94
+ ### R-007:Windows OpenSSH 命令
95
+
96
+ - 参与者或触发方:用户展开 Windows OpenSSH 区域。
97
+ - 前置条件与输入:无。
98
+ - 必须满足的行为:提供 OpenSSH Client 检查/安装命令,以及可选 OpenSSH Server 安装、启动、开机自启和防火墙命令。
99
+ - 输出或状态变化:命令可复制。
100
+ - 边界与失败行为:明确说明反向隧道只需要 Client,Server 是可选能力。
101
+
102
+ ### R-008:失败与降级
103
+
104
+ - 参与者或触发方:网络、进程、文件或接口失败。
105
+ - 前置条件与输入:任一依赖不可用。
106
+ - 必须满足的行为:单点失败不阻止页面渲染;错误信息可读;不影响其他设置页。
107
+ - 输出或状态变化:记录错误到响应;不抛出未处理异常破坏插件树。
108
+ - 边界与失败行为:宿主半边安装失败时只降级该半边并保留 DSH 启动。
109
+
110
+ ### R-009:安全与校验
111
+
112
+ - 参与者或触发方:版本保存、命令生成、API 请求。
113
+ - 前置条件与输入:外部输入。
114
+ - 必须满足的行为:版本只接受 `latest` 或 semver;状态写入使用临时文件 + rename;不经过 shell 执行用户输入;API 走官方 `/api` 注册表。
115
+ - 输出或状态变化:拒绝非法输入并返回明确错误。
116
+ - 边界与失败行为:不将 CDP 端口暴露到局域网;不在日志中记录令牌。
117
+
118
+ ### R-010:Chrome 远程调试启动命令
119
+
120
+ - 参与者或触发方:用户准备连接 Chrome。
121
+ - 前置条件与输入:用户在设置页打开“启动 Chrome”区域。
122
+ - 必须满足的行为:提供使用相对路径 `.\chrome.exe` 的 PowerShell 命令,包含 `--remote-debugging-port=9222`、独立的 `--user-data-dir`、`--no-first-run` 和 `--no-default-browser-check`;提供复制按钮和 CMD 版本。
123
+ - 输出或状态变化:命令被复制,用户进入 Chrome 安装目录后执行。
124
+ - 边界与失败行为:不硬编码 Chrome 安装路径;路径由用户通过 `chrome://version` 自行确认。
125
+
126
+ ### 相关约束
127
+
128
+ - 当前 DSH 版本:`0.2.1-rc.2`。
129
+ - 官方 `/api` 通道由 `ctx.connection.fetch.register` 提供,Host/Origin fence 与会话鉴权由 DSH 完成。
130
+ - 设置页使用 `settings.section` 插槽,排在“技能”之后。
131
+ - 默认连接地址保持 `http://127.0.0.1:9222`。
132
+ - 浏览器页面不能直接执行本机 SSH 命令。
133
+
134
+ ## 5. 关键决定
135
+
136
+ ### D-001:Host + Client + Launcher 三段结构
137
+
138
+ - 状态:已确认
139
+ - 结论:插件由宿主半边、浏览器半边和 launcher 组成。
140
+ - 理由:纯 patch 无法提供设置页、状态接口和版本选择。
141
+ - 考虑过的备选:继续纯 patch;由设置页直接修改 profile YAML。
142
+ - 未采用原因:纯 patch 无 UI 能力;直接改 YAML 易破坏用户配置且无法可靠校验。
143
+ - 影响范围:R-001 至 R-005、R-008、R-009。
144
+
145
+ ### D-002:版本选择写入状态文件
146
+
147
+ - 状态:委托范围内决定
148
+ - 结论:使用 `$DSH_HOME/dsh-chrome-devtools-mcp/state.json` 保存 `{ mode, version }`。
149
+ - 理由:launcher 与宿主共享一个稳定、可校验的契约,不修改 profile。
150
+ - 考虑过的备选:写 profile `cordis.patch.yml`、写环境变量。
151
+ - 未采用原因:profile 修改不可逆风险高;环境变量无法在运行中更新。
152
+ - 影响范围:R-004、R-005。
153
+
154
+ ### D-003:版本列表以 GitHub 为主、npm 回退
155
+
156
+ - 状态:委托范围内决定
157
+ - 结论:优先 GitHub Releases,缓存 10 分钟;失败时回退 npm registry。
158
+ - 理由:用户明确要求 GitHub tag;npm 是最终安装源,作为兜底更可靠。
159
+ - 考虑过的备选:只查 GitHub;只查 npm。
160
+ - 未采用原因:GitHub 会限流;只查 npm 缺少 release 页面链接。
161
+ - 影响范围:R-003、R-008。
162
+
163
+ ### D-004:状态通过进程 + CDP 推断
164
+
165
+ - 状态:委托范围内决定
166
+ - 结论:launcher 写 `runtime.json`,宿主检查 PID,并请求 `/json/version`。
167
+ - 理由:DSH mcp-client 没有公开状态查询接口。
168
+ - 考虑过的备选:解析进程表;直接连接 MCP 做 tools/list。
169
+ - 未采用原因:解析进程表无法知道 spec;MCP 握手成本高且需要额外协议实现。
170
+ - 影响范围:R-002。
171
+
172
+ ### D-005:版本切换需重启 DSH
173
+
174
+ - 状态:已确认
175
+ - 结论:保存后不热重启 MCP,页面明确提示重启 DSH 进程。
176
+ - 理由:当前没有受支持的 MCP 子进程重启接口。
177
+ - 考虑过的备选:杀掉子进程依赖自动重连;触发 HMR。
178
+ - 未采用原因:可能造成工具注销或连接状态不确定。
179
+ - 影响范围:R-004、R-005。
180
+
181
+ ### D-006:隧道“一键”定义为生成并复制
182
+
183
+ - 状态:已确认
184
+ - 结论:浏览器只负责生成和复制命令,不执行。
185
+ - 理由:浏览器无法在 Windows 本机执行 SSH;代跑需要额外的 SSH 主机授权。
186
+ - 考虑过的备选:调用 api-ssh MCP 执行。
187
+ - 未采用原因:当前没有保证可用的 Windows SSH 主机配置。
188
+ - 影响范围:R-006、R-007。
189
+
190
+ ### D-007:设置入口顺序
191
+
192
+ - 状态:委托范围内决定
193
+ - 结论:`settings.section` 使用 `order: 64`,排在“技能”之后。
194
+ - 理由:与现有设置页顺序一致,避免插入到高频配置项之间。
195
+ - 考虑过的备选:排在最前。
196
+ - 未采用原因:Chrome DevTools 属于扩展配置,不是基础设置。
197
+ - 影响范围:R-001。
198
+
199
+ ### D-008:Chrome 启动命令使用相对路径
200
+
201
+ - 状态:已确认
202
+ - 结论:命令使用 `.\chrome.exe`,用户先进入 Chrome 安装目录。
203
+ - 理由:不同机器安装位置不同,硬编码 `C:\Program Files\...` 不可靠。
204
+ - 考虑过的备选:自动探测安装路径;提供多个常见路径。
205
+ - 未采用原因:探测跨机器不可行;多路径列表仍会遗漏。
206
+ - 影响范围:R-010、AC-011。
207
+
208
+ ## 6. 验收标准
209
+
210
+ ### AC-001:设置入口可见
211
+
212
+ - 对应规则:R-001
213
+ - 前置条件:插件已安装并在 web profile 中启用。
214
+ - 操作或输入:打开设置弹窗。
215
+ - 预期结果:左侧导航出现“Chrome MCP”;点击后页面渲染状态卡片。
216
+ - 验证状态:通过
217
+ - 验证证据:Playwright 在临时 web profile 打开设置,左侧导航显示“Chrome MCP”,页面标题与状态卡片正常渲染。
218
+
219
+ ### AC-002:状态展示
220
+
221
+ - 对应规则:R-002
222
+ - 前置条件:MCP 进程存在或不存在;CDP 可达或不可达。
223
+ - 操作或输入:打开页面或点击刷新。
224
+ - 预期结果:页面分别显示进程状态、CDP 状态、配置版本、运行版本、最新版本;不可达时不显示为已连接。
225
+ - 验证状态:通过
226
+ - 验证证据:Playwright 与 GET /status 均显示运行中 PID、CDP 无法连接、配置版本、运行版本、最新版本;页面没有把不可达显示为已连接。
227
+
228
+ ### AC-003:版本列表
229
+
230
+ - 对应规则:R-003
231
+ - 前置条件:GitHub 可访问或存在缓存。
232
+ - 操作或输入:打开页面。
233
+ - 预期结果:显示 `latest` 与稳定版本,版本降序,prerelease 默认折叠,显示数据来源。
234
+ - 验证状态:通过
235
+ - 验证证据:GET /versions 返回 GitHub Releases,latest=1.12.0,版本按 1.12.0、1.11.0、1.10.1 降序;UI 下拉可见,预发布计数单独返回。
236
+
237
+ ### AC-004:切换到 latest
238
+
239
+ - 对应规则:R-004、R-005
240
+ - 前置条件:当前为固定版本。
241
+ - 操作或输入:选择 `latest` 并应用,重启 DSH。
242
+ - 预期结果:`state.json` 为 `{ mode: "latest" }`;launcher 使用 `chrome-devtools-mcp@latest`。
243
+ - 验证状态:通过
244
+ - 验证证据:POST /version 返回 configured.mode=latest;state.json 写入 latest;重启后 runtime.spec 为 chrome-devtools-mcp@latest,进程参数一致。
245
+
246
+ ### AC-005:切换到固定版本
247
+
248
+ - 对应规则:R-004、R-005
249
+ - 前置条件:版本列表中包含 `1.12.0`。
250
+ - 操作或输入:选择 `1.12.0` 并应用,重启 DSH。
251
+ - 预期结果:`state.json` 为 `{ mode: "pinned", version: "1.12.0" }`;launcher 使用 `chrome-devtools-mcp@1.12.0`。
252
+ - 验证状态:通过
253
+ - 验证证据:POST /version 写入 pinned 1.12.0;重启后 runtime.spec 与进程参数均为 chrome-devtools-mcp@1.12.0。UI 选择 1.11.0 后同样写入 state.json。
254
+
255
+ ### AC-006:隧道命令复制
256
+
257
+ - 对应规则:R-006
258
+ - 前置条件:页面地址为 `http://192.168.1.100:3080`,用户名为 `alice`。
259
+ - 操作或输入:填写用户名并点击复制。
260
+ - 预期结果:命令包含 `-R 9222:127.0.0.1:9222 alice@192.168.1.100`,剪贴板写入成功并显示反馈。
261
+ - 验证状态:通过
262
+ - 验证证据:Playwright 填入 alice 后页面生成相同命令;点击复制后 navigator.clipboard.readText() 返回完全一致的命令。
263
+
264
+ ### AC-007:Windows OpenSSH 命令
265
+
266
+ - 对应规则:R-007
267
+ - 前置条件:无。
268
+ - 操作或输入:展开 Windows OpenSSH 区域。
269
+ - 预期结果:可见 Client 与 Server 两组 PowerShell 命令,并说明反向隧道只需要 Client。
270
+ - 验证状态:通过
271
+ - 验证证据:Playwright 展开后可见 Add-WindowsCapability Client/Server、Start-Service、New-NetFirewallRule 命令及“反向隧道只需要 OpenSSH Client”说明。
272
+
273
+ ### AC-008:网络失败降级
274
+
275
+ - 对应规则:R-003、R-008
276
+ - 前置条件:GitHub 返回 403 且存在缓存。
277
+ - 操作或输入:打开页面或刷新。
278
+ - 预期结果:显示缓存版本并提示“GitHub 限流,使用缓存”;页面不白屏。
279
+ - 验证状态:通过
280
+ - 验证证据:单元测试覆盖 GitHub 失败、GitHub 空列表、空缓存忽略三种路径,均回退 npm 或旧缓存;临时 profile 的 UI 在缓存数据下正常显示。
281
+
282
+ ### AC-009:非法版本拒绝
283
+
284
+ - 对应规则:R-004、R-009
285
+ - 前置条件:请求体版本为 `../../evil`。
286
+ - 操作或输入:调用版本保存接口。
287
+ - 预期结果:返回 400;状态文件不变。
288
+ - 验证状态:通过
289
+ - 验证证据:POST /version 返回 400 invalid-selection;state.json 保持原值。单元测试也覆盖 traversal 输入。
290
+
291
+ ### AC-010:工具名保持稳定
292
+
293
+ - 对应规则:R-005
294
+ - 前置条件:插件安装后重启 DSH。
295
+ - 操作或输入:查看 MCP 工具列表。
296
+ - 预期结果:工具名仍为 `mcp__chrome-devtools__*`,serverName 仍为 `chrome-devtools`。
297
+ - 验证状态:通过
298
+ - 验证证据:连接 Chrome/155.0.8059.85 后,mcp__chrome-devtools__list_pages 返回 1 个页面,mcp__chrome-devtools__take_snapshot 与 take_screenshot 成功;工具命名空间保持 mcp__chrome-devtools__*。
299
+
300
+ ### AC-011:Chrome 启动命令
301
+
302
+ - 对应规则:R-010
303
+ - 前置条件:插件已安装并打开设置页。
304
+ - 操作或输入:查看“启动 Chrome”区域并点击复制。
305
+ - 预期结果:命令以 `.\chrome.exe` 开头,包含 9222、user-data-dir、no-first-run、no-default-browser-check;不包含硬编码安装路径;复制成功。
306
+ - 验证状态:通过
307
+ - 验证证据:Playwright 在临时 profile 打开设置页,截图包含“启动 Chrome”卡片;点击复制后剪贴板内容为 `.\chrome.exe --remote-debugging-port=9222 --user-data-dir="$env:LOCALAPPDATA\ChromeDebug" --no-first-run --no-default-browser-check`,命令中没有硬编码安装路径。
308
+
309
+ ## 7. 未决问题与假设
310
+
311
+ ### Q-001:prerelease 默认隐藏
312
+
313
+ - 类型:暂用假设
314
+ - 内容:默认只展示稳定版本,prerelease 折叠。
315
+ - 影响范围:R-003、AC-003。
316
+ - 是否阻塞:否。
317
+ - 决定或验证方式:实现后按用户反馈调整。
318
+
319
+ ### Q-002:SSH 用户名记忆
320
+
321
+ - 类型:暂用假设
322
+ - 内容:DSH 主机 SSH 用户名保存在浏览器 `localStorage`,仅用于生成命令。
323
+ - 影响范围:R-006。
324
+ - 是否阻塞:否。
325
+ - 决定或验证方式:实现后验证清除浏览器数据会恢复为空。
326
+
327
+ ### Q-003:多实例运行记录
328
+
329
+ - 类型:暂用假设
330
+ - 内容:多个 DSH 会话共享 `runtime.json` 时,状态显示最新写入的记录。
331
+ - 影响范围:R-002。
332
+ - 是否阻塞:否。
333
+ - 决定或验证方式:先按单实例模型实现,后续按需改为 PID 列表。
334
+
335
+ ## 8. 技术设计
336
+
337
+ ### 8.1 模块
338
+
339
+ - `lib/index.js`:宿主入口,延迟安装 HTTP 端点。
340
+ - `lib/routes.js`:注册 `/api/dsh-chrome-devtools-mcp/*`。
341
+ - `lib/state.js`:状态文件读写与校验。
342
+ - `lib/status.js`:PID、CDP、运行时状态。
343
+ - `lib/versions.js`:GitHub Releases、npm 回退、缓存。
344
+ - `lib/tunnel.js`:SSH 与 Windows OpenSSH 命令文本。
345
+ - `bin/launcher.mjs`:读取状态并启动 `npx chrome-devtools-mcp@<spec>`。
346
+ - `client/client.js`:注册 `settings.section` 并渲染页面。
347
+
348
+ ### 8.2 数据文件
349
+
350
+ ```text
351
+ $DSH_HOME/dsh-chrome-devtools-mcp/state.json
352
+ $DSH_HOME/dsh-chrome-devtools-mcp/runtime.json
353
+ $DSH_HOME/dsh-chrome-devtools-mcp/releases-cache.json
354
+ ```
355
+
356
+ ### 8.3 HTTP 接口
357
+
358
+ | 方法 | 路径 | 用途 |
359
+ |---|---|---|
360
+ | GET | `/api/dsh-chrome-devtools-mcp/status` | 进程、CDP、配置与运行版本 |
361
+ | GET | `/api/dsh-chrome-devtools-mcp/versions?refresh=1` | 版本列表与来源 |
362
+ | POST | `/api/dsh-chrome-devtools-mcp/version` | 保存版本选择 |
363
+ | GET | `/api/dsh-chrome-devtools-mcp/tunnel` | 主机地址与命令模板 |
364
+
365
+ ### 8.4 启动流程
366
+
367
+ ```text
368
+ DSH 启动
369
+ → mcp-client 启动 launcher
370
+ → launcher 读取 state.json
371
+ → 选择 chrome-devtools-mcp@latest 或 @x.y.z
372
+ → 写入 runtime.json
373
+ → spawn npx
374
+ → MCP client 完成 initialize/tools list
375
+ ```
376
+
377
+ ### 8.5 状态推断
378
+
379
+ - PID 存活:`process.kill(pid, 0)`。
380
+ - CDP:`GET <browserUrl>/json/version`,1.5 秒超时。
381
+ - 运行版本:runtime.json 的 `spec`;`latest` 时标注“启动时解析”。
382
+ - 配置版本:state.json。
383
+ - 最新版本:版本列表中的 latest。
384
+
385
+ ### 8.6 安全
386
+
387
+ - 版本只接受 `latest` 或 `^\d+\.\d+\.\d+$`。
388
+ - 状态写入使用同目录临时文件 + rename,权限 `0600`。
389
+ - launcher 使用 `spawn` 数组参数,不设置 `shell: true`。
390
+ - SSH 命令只生成与复制,不执行。
391
+ - HTTP 端点走官方注册表,由宿主完成鉴权。
package/lib/index.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Host half: registers the settings-page API.
3
+ *
4
+ * The route install is deliberately optional. A headless or SDK profile has no
5
+ * web connection, and a settings page must never prevent the profile from
6
+ * booting.
7
+ */
8
+
9
+ import { installRoutes, ROUTE_PREFIX } from './routes.js'
10
+ import { normalizeBrowserUrl, DEFAULT_BROWSER_URL } from './status.js'
11
+ import { resolveDshHome } from './state.js'
12
+
13
+ export const name = 'dsh-chrome-devtools-mcp'
14
+ export const inject = []
15
+
16
+ function serviceOf(ctx, service) {
17
+ try {
18
+ const direct = ctx?.[service]
19
+ if (direct !== undefined) return direct
20
+ } catch {
21
+ // Frozen proxies can throw on property access.
22
+ }
23
+ try {
24
+ return ctx?.get?.(service)
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ function attempt(install, label, log) {
31
+ try {
32
+ const dispose = install()
33
+ return typeof dispose === 'function' ? dispose : () => {}
34
+ } catch (error) {
35
+ const message = error instanceof Error ? error.message : String(error)
36
+ log(`${label}安装失败,该部分已停用:${message}`)
37
+ return () => {}
38
+ }
39
+ }
40
+
41
+ export function apply(ctx, config = {}) {
42
+ const dshHome = typeof config.dshHome === 'string' && config.dshHome.trim() ? config.dshHome.trim() : resolveDshHome()
43
+ const browserUrl = normalizeBrowserUrl(config.browserUrl || process.env.CHROME_DEVTOOLS_BROWSER_URL || DEFAULT_BROWSER_URL)
44
+ const log = (message) => {
45
+ console.warn(`[dsh-chrome-devtools-mcp] ${message}`)
46
+ }
47
+ const effect = typeof ctx.effect === 'function' ? (fn, label) => ctx.effect(fn, label) : (fn) => {
48
+ const dispose = fn()
49
+ return typeof dispose === 'function' ? dispose : () => {}
50
+ }
51
+
52
+ const install = (inner) => {
53
+ const connection = serviceOf(inner, 'connection')
54
+ if (!connection || typeof connection.fetch?.register !== 'function') return () => {}
55
+ return installRoutes(connection, { dshHome, browserUrl })
56
+ }
57
+
58
+ const ready = serviceOf(ctx, 'webServer') !== undefined && serviceOf(ctx, 'connection') !== undefined
59
+ if (ready) {
60
+ effect(() => attempt(() => install(ctx), 'HTTP 端点', log), 'chrome-devtools-mcp: HTTP 端点')
61
+ return
62
+ }
63
+
64
+ if (typeof ctx.inject !== 'function') {
65
+ log(`缺少 connection 服务,且当前上下文不支持延迟注入;${ROUTE_PREFIX} 未注册`)
66
+ return
67
+ }
68
+ ctx.inject(['webServer', 'connection'], (inner) => {
69
+ effect(() => attempt(() => install(inner), 'HTTP 端点', log), 'chrome-devtools-mcp: HTTP 端点')
70
+ })
71
+ }
72
+
73
+ export { ROUTE_PREFIX }
package/lib/launch.js ADDED
@@ -0,0 +1,44 @@
1
+ /** Pure launch planning, kept outside bin/ so it can be tested without starting npx. */
2
+
3
+ import { readState, resolveDshHome, VERSION_PATTERN } from './state.js'
4
+
5
+ export const MCP_PACKAGE = 'chrome-devtools-mcp'
6
+ export function resolveSpec(state) {
7
+ if (state && state.mode === 'pinned' && typeof state.version === 'string' && VERSION_PATTERN.test(state.version)) {
8
+ return `${MCP_PACKAGE}@${state.version}`
9
+ }
10
+ return `${MCP_PACKAGE}@latest`
11
+ }
12
+
13
+ export function npxCommand(platform = process.platform) {
14
+ return platform === 'win32' ? 'npx.cmd' : 'npx'
15
+ }
16
+
17
+ export function buildNpxArgs(state, passthrough = []) {
18
+ return ['-y', resolveSpec(state), ...passthrough]
19
+ }
20
+
21
+ export async function readLaunchState(dshHome = resolveDshHome()) {
22
+ return readState(dshHome)
23
+ }
24
+
25
+ /**
26
+ * The DSH child process may not inherit DSH_HOME, so the patch passes the
27
+ * profile's DSH home explicitly. Strip that private argument before npx sees it.
28
+ */
29
+ export function parseLauncherArgs(argv, env = process.env) {
30
+ const passthrough = [...argv]
31
+ let dshHome = resolveDshHome(env)
32
+ const index = passthrough.indexOf('--dsh-home')
33
+ if (index !== -1) {
34
+ const value = passthrough[index + 1]
35
+ if (!value || value.startsWith('-')) throw new Error('--dsh-home 缺少目录参数')
36
+ dshHome = value
37
+ passthrough.splice(index, 2)
38
+ }
39
+ return { dshHome, passthrough }
40
+ }
41
+
42
+ export function runtimeRecord({ pid, spec, startedAt = new Date().toISOString() }) {
43
+ return { pid, spec, startedAt }
44
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Exact `/api` endpoints for the settings page.
3
+ *
4
+ * Registration goes through the official connection fetch registry, so the
5
+ * Host's origin fence and browser-session authentication already ran before a
6
+ * request reaches this module.
7
+ */
8
+
9
+ import { assertVersionExists, cachedVersionNames, loadVersions } from './versions.js'
10
+ import { getStatus, normalizeBrowserUrl } from './status.js'
11
+ import { normalizeSelection, writeState } from './state.js'
12
+ import { tunnelInfo } from './tunnel.js'
13
+
14
+ export const ROUTE_PREFIX = '/api/dsh-chrome-devtools-mcp'
15
+
16
+ const ENDPOINTS = [
17
+ { path: '/status', methods: ['GET'] },
18
+ { path: '/versions', methods: ['GET'] },
19
+ { path: '/version', methods: ['POST'] },
20
+ { path: '/tunnel', methods: ['GET'] },
21
+ ]
22
+
23
+ const MAX_BODY_BYTES = 4096
24
+
25
+ function json(status, body) {
26
+ return new Response(JSON.stringify(body), {
27
+ status,
28
+ headers: {
29
+ 'content-type': 'application/json; charset=utf-8',
30
+ 'cache-control': 'no-store',
31
+ },
32
+ })
33
+ }
34
+
35
+ function failure(status, message, code = 'error') {
36
+ return json(status, { ok: false, code, error: message })
37
+ }
38
+
39
+ async function readBody(request) {
40
+ const length = Number(request.headers.get('content-length'))
41
+ if (Number.isFinite(length) && length > MAX_BODY_BYTES) throw Object.assign(new Error('请求体过大'), { status: 413 })
42
+ const text = await request.text()
43
+ if (text.length > MAX_BODY_BYTES) throw Object.assign(new Error('请求体过大'), { status: 413 })
44
+ try {
45
+ return JSON.parse(text)
46
+ } catch {
47
+ throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 })
48
+ }
49
+ }
50
+
51
+ export function installRoutes(connection, options) {
52
+ if (!connection || typeof connection.fetch?.register !== 'function') {
53
+ throw new Error('connection 服务缺少 fetch.register,无法注册设置页端点')
54
+ }
55
+ const disposers = ENDPOINTS.map((endpoint) =>
56
+ connection.fetch.register({
57
+ path: `${ROUTE_PREFIX}${endpoint.path}`,
58
+ methods: endpoint.methods,
59
+ requestBody: 'buffered',
60
+ fetch: (request) => handle(request, options),
61
+ }),
62
+ )
63
+ return () => {
64
+ for (const dispose of disposers) if (typeof dispose === 'function') dispose()
65
+ }
66
+ }
67
+
68
+ export async function handle(request, options = {}) {
69
+ const url = new URL(request.url)
70
+ const path = url.pathname.replace(/\/+$/, '')
71
+ try {
72
+ if (path === `${ROUTE_PREFIX}/status`) {
73
+ return json(200, {
74
+ ok: true,
75
+ ...(await getStatus({
76
+ dshHome: options.dshHome,
77
+ browserUrl: normalizeBrowserUrl(options.browserUrl),
78
+ fetchImpl: options.fetchImpl,
79
+ })),
80
+ })
81
+ }
82
+ if (path === `${ROUTE_PREFIX}/versions`) {
83
+ return json(200, {
84
+ ok: true,
85
+ ...(await loadVersions({
86
+ dshHome: options.dshHome,
87
+ refresh: url.searchParams.get('refresh') === '1',
88
+ fetchImpl: options.fetchImpl,
89
+ })),
90
+ })
91
+ }
92
+ if (path === `${ROUTE_PREFIX}/tunnel`) {
93
+ return json(200, { ok: true, ...tunnelInfo(options.tunnel) })
94
+ }
95
+ if (path === `${ROUTE_PREFIX}/version`) {
96
+ if (request.method !== 'POST') return failure(405, '只支持 POST')
97
+ const body = await readBody(request)
98
+ let selection
99
+ try {
100
+ selection = normalizeSelection(body)
101
+ } catch (error) {
102
+ return failure(400, error.message, 'invalid-selection')
103
+ }
104
+ if (selection.mode === 'pinned') {
105
+ try {
106
+ const known = await cachedVersionNames(options.dshHome)
107
+ await assertVersionExists(selection.version, { fetchImpl: options.fetchImpl, knownVersions: known })
108
+ } catch (error) {
109
+ return failure(400, error.message, 'invalid-version')
110
+ }
111
+ }
112
+ const configured = await writeState(options.dshHome, selection)
113
+ return json(200, { ok: true, configured, restartRequired: true })
114
+ }
115
+ return failure(404, '端点不存在', 'not-found')
116
+ } catch (error) {
117
+ const status = Number(error?.status) || 500
118
+ return failure(status, error instanceof Error ? error.message : String(error))
119
+ }
120
+ }