From 2cfc65e463c8ac114fbb65bd41dd9411bd8f3adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 17:37:05 +0800 Subject: [PATCH 01/25] docs: spec for sctl browser foundation --- docs/specs/2026-09-28-browser-foundation.md | 255 ++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/specs/2026-09-28-browser-foundation.md diff --git a/docs/specs/2026-09-28-browser-foundation.md b/docs/specs/2026-09-28-browser-foundation.md new file mode 100644 index 0000000..cdc1f9b --- /dev/null +++ b/docs/specs/2026-09-28-browser-foundation.md @@ -0,0 +1,255 @@ +# sctl Browser 第 1 期:地基 + + + +> Status: Draft +> Owner: sctl maintainers +> Last updated: 2026-09-28 + +**目标:** 发布一个独立的 Chromium 扩展 *sctl Browser*。它和 ScriptCat 同时配对到 `sctl serve` 上,让用户日常使用的一个或多个浏览器都能通过 sctl 命令行和 MCP 被列出来,并能对其中的标签页和窗口执行列出、打开、关闭、激活。每个浏览器用一个稳定的、由用户自己起的名字来指定。 + +**不可退化的约束:** ScriptCat 连在同一个 daemon 上,一切照常工作。无论是配对浏览器、给它改名、忘记它,还是多个浏览器同时在线,都不会断开 ScriptCat,不会让它的密钥失效,也不会改变任何现有 `scripts.*` 命令或 MCP 工具的行为和退出码。 + +本期是五期规划中的第 1 期。后续各期列在[不在本期范围](#不在本期范围)里,每期各写一份 spec。 + +## 问题 + +1. **sctl 驱动不了用户真正在用的浏览器。** + - 它唯一的对端是 ScriptCat,能调的方法只有脚本管理这一类(`protocol.json` 的 `methods` 里是 8 个 `scripts.*`)。 + - AI agent 或 shell 脚本没有任何办法看到或操作用户日常使用、已经登录的浏览器会话。 + - Playwright 这类工具会另外启动一个干净的浏览器,这不是用户想要的。 +2. **daemon 只接受一个扩展连接。** `bridge.Server` 只保留一个 `active` 连接,新认证的连接会替换掉旧连接(`internal/daemon/bridge/server.go` 的 `setActive`,注释写的是「v1 单实例」)。第二个扩展连上来就会把 ScriptCat 挤掉。 +3. **配对第二个扩展会冲掉 ScriptCat 的密钥。** 每次配对成功,daemon 都会生成一个新的长期密钥 K,并覆盖唯一的密钥文件(`internal/daemon/bridge/conn.go` 里先 `auth.NewLongTermKey` 再 `s.keys.Save(k)`)。ScriptCat 下次重连时握手就会失败。 +4. **请求无法路由。** `bridge.Server.Call` 把所有调用都发给唯一的活动连接,只检查这个连接声明了哪些方法(`internal/daemon/bridge/call.go`)。daemon 既不知道某个方法该由哪个扩展处理,也不知道调用方指的是多个浏览器中的哪一个。 + +## 角色与用户故事 + +1. 作为在终端里驱动浏览器的用户,我希望每个浏览器只用 `sctl connect` 配对一次,之后 sctl 就能一直连到它,不用再做任何设置。 +2. 作为同时开着多个浏览器或多个 Chrome 用户配置的用户,我希望每个浏览器都有一个我自己起的短名字,这样我写 `--browser work` 时绝不会操作到别的浏览器。 +3. 作为通过 `sctl mcp` 接入的 AI agent,我希望能在指定的浏览器里列出、打开、关闭、激活标签页,并能列出窗口,这样我就能在用户真实的会话里工作。 +4. 作为 shell 脚本作者,我希望有表格和 JSON 两种输出,退出码可预期,这样脚本能根据结果分支处理。 +5. 作为用户,我希望工具栏弹窗让我一眼看出这个浏览器连没连上、叫什么名字、没连上时该怎么办,并且支持浅色和深色、中文和英文。 + +## 设计决策 + +| # | 决策 | 依据与被否方案 | +|---|---|---| +| 1 | 做一个新的独立扩展,而不是在 ScriptCat 里加方法。 | 用户决定。被否:扩展 ScriptCat。它虽然已经有 `debugger`、`tabs`、`scripting` 权限和一个扩展内的 DOM agent,但用户希望浏览器控制独立于脚本管理器。 | +| 2 | 只支持 Chromium 系浏览器(Chrome、Edge),最低版本 116,Manifest V3。 | 后续几期依赖 `chrome.debugger`,Firefox 没有这个 API。被否:一开始就支持多浏览器,那样会被迫设计两套自动化方案。 | +| 3 | 浏览器相关的方法不做逐次人工审批。持有控制令牌的进程可以操作所有已配对浏览器的所有标签页。 | 用户决定(「可以不考虑那么多安全问题」)。被否:只允许操作 sctl 专属标签组、按站点授权。这些做法会增加用户不想要的操作负担。这项取舍会写进威胁模型(见[文档](#文档))。 | +| 4 | 新扩展沿用现有的双向 HMAC 配对和握手。 | 直接复用现有代码,成本几乎为零,还能防止本机其他进程冒充 daemon 来接管浏览器。被否:不做认证直接连接。 | +| 5 | 允许多个浏览器实例同时在线。每个实例有一个实例 ID 和一个唯一名称。 | 用户决定(选项 b)。被否:同一时间只连一个浏览器、新连接替换旧连接。那样更简单,但不适合同时用多个用户配置的用户。 | +| 6 | daemon 为每个浏览器实例各存一把密钥,并持久保存一份已配对实例及其名称的登记表。ScriptCat 继续用原来的密钥文件。 | 这样不动 ScriptCat 就能解决问题 3。有了登记表,名称可以在所有已配对浏览器中保持唯一,还能给出明确的「已离线」错误。被否:所有扩展共用 ScriptCat 的 K,那样忘记一个浏览器就会让所有对端一起失效。 | +| 7 | daemon 按方法归属路由:`scripts.*` 发给 ScriptCat,浏览器方法发给解析出的目标浏览器实例。 | 复用 daemon 本来就在协商的方法声明。被否:每种扩展各开一个 daemon 端口或进程,那样监听端口、控制令牌和审计都要重复一份。 | +| 8 | 浏览器相关命令采用 gh 风格,先写对象再写动作(`sctl browsers …`、`sctl tabs …`、`sctl windows …`)。现有脚本命令保持 kubectl 风格不变。 | 浏览器领域的对象和动作都很多,塞进 `get`、`delete` 会把「删脚本」和「关标签页」混在一起。被否:所有命令都改成先写对象,那会破坏现有脚本命令。 | +| 9 | 扩展源码放在本仓库的 `extension/` 目录下,使用 pnpm、Vite、React、TypeScript、Tailwind CSS v4 和 shadcn/ui,配 ESLint、Prettier 和 Vitest。 | 技术栈和工具由用户决定。放在同一仓库,可以直接使用从 `protocol.json` 生成的 TypeScript 类型,不用跨仓库同步。被否:单独开一个仓库。 | +| 10 | WebSocket 放在 offscreen 文档里,和 ScriptCat 一样。 | 用户决定。MV3 的 service worker 可能被挂起,offscreen 文档能让连接一直保持。 | +| 11 | 扩展打包成 zip 附在每个 GitHub Release 上,用户通过「加载已解压的扩展程序」安装。 | 设计评审时已确认。本期不上架 Chrome 应用商店。 | +| 12 | 弹窗采用「同门」风格:ScriptCat 的天蓝色和 ScriptCat 的 logo,加一条「这个浏览器 ↔ daemon」的连线,用线型表示连接状态。工具栏图标直接用 ScriptCat 的 logo,不做修改。 | 用户从三个方向(铭牌、同门、终端)中选定。用户接受两个扩展的工具栏图标看起来完全一样。 | +| 13 | 弹窗提供外观设置(跟随系统、浅色、深色)和语言设置(跟随浏览器、中文、English),两项默认都跟随系统和浏览器。 | 用户要求。因为语言可以手动切换,弹窗文案要用扩展自带的词典,不能用 `chrome.i18n`,后者无法在运行时切换语言。清单里的扩展名称和描述仍然走 `_locales`,跟随浏览器语言。 | + +## 浏览器实例的身份与配对 + +**实例身份。** 扩展首次安装时随机生成一个实例 ID,存在扩展本地存储里。卸载后重装会得到一个新实例,需要重新配对。 + +- 每个实例有一个名称,由小写字母、数字和 `-` 组成,长度 1–32 个字符。 +- 默认名称是浏览器品牌加上实例 ID 的前 4 位十六进制,例如 `chrome-3f2a`、`edge-91c0`。 +- 名称以 daemon 那边保存的为准,在该 daemon 的所有已配对浏览器实例中唯一,在线和离线的实例都算。 + +**配对。** + +- *前提:* 用户已经运行 `sctl connect`,终端正在显示配对码。 +- *操作:* 用户在弹窗里输入配对码,点「配对」。 +- *结果:* + - **配对码有效:** daemon 为这个实例签发密钥,用默认名称写进登记表,弹窗进入已连接状态。 + - **配对码错误或已过期:** 两种情况合并成同一条提示,因为 daemon 从不告诉客户端握手失败的原因(见 [threat-model.md](../threat-model.md))。 + - **连不上 daemon:** 弹窗写出它尝试连接的地址,提示用户启动 `sctl serve` 或修改地址。 + +**ScriptCat 不受影响。** ScriptCat 自己的配对和握手不变。没有声明自己是浏览器实例的连接,一律当作 ScriptCat 处理。daemon 对 ScriptCat 继续保持「新连接替换旧连接」的行为。 + +**握手绑定实例身份。** 浏览器实例认证时要声明自己的类型和实例 ID,这个身份会参与握手 MAC 的计算。daemon 据此选用该实例的密钥,截获的 MAC 也无法被冒充成另一个实例重放。 + +**重命名。** + +- *前提:* 实例处于已连接状态。 +- *操作:* 用户在弹窗里保存一个新名称。 +- *结果:* 扩展用新名称重新连接一次。 + - **名称未被占用:** daemon 更新登记表。 + - **名称已被另一个已配对实例占用:** daemon 拒绝这次改名,弹窗提示「名称 X 已被另一个浏览器使用」,并保留原名称。 +- 实例未连接时不能重命名。 + +**从 daemon 侧忘记。** `sctl browsers forget <名称|ID>` 会删除该实例的密钥和登记记录;如果它在线,还会断开连接。该实例下次握手会失败,弹窗显示「需要重新配对」。 + +**从扩展侧忘记。** 弹窗里的「断开并忘记」经确认后删除扩展这边保存的密钥。确认框会说明:daemon 那边的密钥会一直保留,直到运行 `sctl browsers forget <名称>`。 + +**重连。** + +- **配对后连不上 daemon:** 扩展按指数退避重试,间隔从 1 秒开始,最长 30 秒。弹窗显示第几次重试和倒计时。点「立即重试」会马上重试,并重置退避间隔。 +- **配对后握手被拒:** 扩展停止自动重试,弹窗显示「daemon 不再认可这个浏览器」和「重新配对」按钮。 + +**daemon 地址。** 扩展默认连接 `127.0.0.1:8643`,可以在设置里修改,要和 `sctl serve --listen-address` 一致。保存后会断开当前连接,按新地址重连,已有的配对保持不变。 + +## 路由与目标选择 + +所有浏览器命令和 MCP 工具都接受一个可选的目标浏览器参数,可以写名称,也可以写实例 ID 前缀。 + +- **命令行默认值:** 可以用环境变量 `SCTL_BROWSER` 设置。显式传入的 `--browser` 优先于 `SCTL_BROWSER`。 +- **在哪里解析:** 由 daemon 解析目标,因为只有 daemon 知道哪些实例在线。 + +| 在线的已配对浏览器 | 列表类命令(`tabs list`、`windows list`),未指定目标 | 操作类命令(`tabs open`、`close`、`activate`),未指定目标 | +|---|---|---| +| 0 个 | 报错「没有已连接的浏览器」,退出码 3 | 同左 | +| 恰好 1 个 | 使用这一个 | 使用这一个 | +| 多于 1 个 | 汇总所有在线浏览器;表格多一列 BROWSER,JSON 的每一项带上浏览器名称和 ID | 报错并列出在线的候选浏览器,要求加 `--browser`,退出码 3 | + +**目标相关的错误:** 下面三种情况各有一个稳定的错误码,列在 `protocol.json` 的 `errorCodes` 里,退出码都是 3。 + +- 目标是一个已配对但当前离线的实例。 +- 目标不对应任何已配对实例。 +- 实例 ID 前缀同时匹配到多个实例。 + +**调用中途断开:** 调用进行时目标浏览器断开,这次调用作废,处理方式和现在 ScriptCat 一样:返回 `OPERATION_EXPIRED`,退出码 2。 + +`scripts.*` 调用继续路由给 ScriptCat,它们现有的错误和退出码都不变。 + +## 第 1 期的命令与工具 + +| 命令行 | MCP 工具 | 作用 | +|---|---|---| +| `sctl browsers [list]` | `browsers_list` | 列出已配对的浏览器实例:名称、实例 ID、在线或离线、品牌和版本、扩展版本、连接时间 | +| `sctl browsers forget <名称\|ID>` | 无(仅命令行) | 忘记一个已配对实例,行为见上文 | +| `sctl tabs list [--window N]` | `tabs_list` | 列出标签页:标签 ID、窗口 ID、是否激活、是否固定、标题、URL | +| `sctl tabs open [--window N] [--background]` | `tabs_open` | 打开一个标签页并输出它的标签 ID。默认在最近使用的窗口里打开,并切换到这个标签。 | +| `sctl tabs close ...` | `tabs_close` | 关闭一个或多个标签页 | +| `sctl tabs activate ` | `tabs_activate` | 激活该标签页,并把焦点切到它所在的窗口 | +| `sctl windows list` | `windows_list` | 列出窗口:窗口 ID、是否有焦点、窗口状态、标签数 | + +**参数和输出:** + +- 所有浏览器命令都接受 `--browser`;除 `browsers_list` 外,上表每个 MCP 工具都有可选的 `browser` 参数。 +- 所有命令都支持 `-o json`,默认输出表格。 +- 标签页或窗口 ID 不存在时返回 `NOT_FOUND`,退出码 3。 + +**页面内容不可信:** 标签页标题和 URL 由网页控制,所以只作为结构化数据返回,绝不拼进工具描述里。MCP 工具描述保持静态文本,沿用现有「脚本可控文本」的规则。 + +**`sctl status`:** 额外列出各浏览器实例及其在线状态。现有的 ScriptCat 字段和退出行为不变。 + +**协议:** 新方法先加进 `protocol.json`,再从它生成 Go 和 TypeScript 代码。扩展通过现有的 `$session.capabilities` 协商,声明自己实现了哪些浏览器方法。 + +## 扩展弹窗 + +弹窗就是点击工具栏图标弹出的窗口,宽 360px。它一失去焦点就会关闭,所以用户在里面输入的配对码会存进会话存储,重新打开弹窗时恢复。 + +**布局,从上到下:** + +- **顶栏:** ScriptCat 的 logo、产品名和一句用途说明,右侧是设置按钮(齿轮)。 +- **连线区:** 左边是「这个浏览器」或实例名称,中间是连线和状态文字,右边是 daemon 地址。连线的样式表示状态: + - 绿色实线:已连接 + - 琥珀色流动虚线:重连中 + - 红色长虚线:认证被拒,需要重新配对 + - 灰色稀疏点线:未配对 +- **正文:** 随状态变化,见下方状态列表。 +- **底栏:** 左边是 daemon 地址,点击进入设置;右边在已配对时是「断开并忘记」,未配对时显示扩展版本号。 + +**状态:** 每个状态都有对应的设计。 + +1. 未配对 +2. 配对中:输入框、按钮和设置入口都禁用 +3. 配对失败 +4. 配对时连不上 daemon +5. 已连接:依次显示 + - 实例名称和「重命名」按钮 + - 可复制的实例 ID、浏览器、daemon 版本、已连接时长 + - 一条可复制的 `sctl tabs list --browser <名称>` +6. 重命名中:按 Enter 保存,按 Esc 取消 +7. 名称已被占用 +8. 重连中:显示重试倒计时和「立即重试」 +9. 认证被拒:显示「重新配对」 +10. 确认断开并忘记:打开时焦点默认在「取消」上 +11. 设置 + +**设置页**包含外观(跟随系统、浅色、深色)、语言(跟随浏览器、中文、English)和 daemon 地址。 + +- 外观和语言点击后立即生效,并保存在扩展本地存储里。 +- 地址要点「保存并重连」才生效。 + +**视觉规范:** 配色以 ScriptCat 的天蓝色为基础。 + +| 用途 | 浅色 | 深色 | +|---|---|---| +| 主色 | `#1296DB` | `#3AA9E6` | +| 背景 | `#FFFFFF` | `#131A21` | +| 衬底 | `#F2F7FB` | `#1A232C` | +| 正文 | `#1B2733` | `#E4ECF3` | +| 次要文字 | `#5C6B7A` | `#8C9BA9` | +| 分隔线 | `#DCE5ED` | `#283440` | +| 成功 | `#16A06A` | `#3CC98A` | +| 警告 | `#D48806` | `#E9A93A` | +| 危险 | `#D6453D` | `#F06A5F` | + +- **字体:** 界面文字用系统字体,不打包字体文件;中文依次回退到苹方、微软雅黑、Noto Sans CJK。命令、地址、名称、ID 用系统等宽字体。 +- **组件:** 使用 shadcn/ui,通过它的 CSS 变量换肤。 + +**无障碍:** + +- 所有控件都能用键盘操作,并有可见的焦点框。 +- 状态变化通过 polite 级别的实时区域播报。 +- 错误提示使用 alert 角色。 +- 白色或带色的文字与背景的对比度不低于 4.5:1。 +- 两个动画(重连时流动的连线、配对中的转圈)在系统开启「减少动态效果」时停止。 + +**Mockup:** 本地有一份可点击的 mockup,覆盖全部状态,两种外观、两种语言都能切换,位置是 `.dev-kit/artifacts/2026-09-28-browser-foundation/mockups/`(已加入 gitignore,不属于本次变更)。以上文字描述是约束性的要求,mockup 只作示意。 + +## 扩展工程 + +扩展从本仓库的 `extension/` 目录构建。 + +- **脚本和 CI:** 提供 lint、格式检查、类型检查、单元测试和构建脚本,仓库 CI 会和 Go 的检查一起运行它们。 +- **协议检查:** 扩展使用的协议生成代码过期时,`make protocol-check` 也会失败。 +- **发布:** 发布流程会把 `sctl-browser-extension-.zip` 附在每个 GitHub Release 上,版本号和 sctl 的发布版本一致。 +- **权限:** 只申请第 1 期需要的 `tabs`、`storage`、`offscreen`,后续各期需要的权限在各自的 spec 里再加。 + +## 文档 + +以下文档在本期一并修改,各自只改自己负责的内容(归属见 [docs/README.md](../README.md)): + +- **[architecture.md](../architecture.md):** 第二种扩展、多连接、按方法归属路由,以及 `extension/` 目录。 +- **[protocol.md](../protocol.md):** 握手中的对端身份、浏览器实例的路由语义、新方法和新错误码。 +- **[threat-model.md](../threat-model.md):** + - 浏览器控制按设计没有人工审批,持有控制令牌就能操作所有已配对浏览器。 + - 凭据清单里加上每个实例的密钥文件和登记表。 + - daemon 侧审计要记录浏览器实例的配对失败和握手失败。 +- **[AGENTS.md](../../AGENTS.md):** 更新进程示意图。「最终控制权始终在扩展一侧」这句话收窄为只适用于 ScriptCat 的写操作审批和源码读取审批,并写明浏览器控制是例外。 +- **[development.md](../development.md):** 扩展的工具链、命令和 CI 任务。 +- **[mcp.md](../mcp.md) 和两份 README:** 浏览器扩展的安装与配对,以及新增的工具。 + +## 不在本期范围 + +- **第 2 期,浏览器数据管理:** 书签、阅读列表、标签组及更多标签页和窗口整理功能、历史记录、最近关闭、下载、Cookie、清除浏览数据、扩展管理。 +- **第 3 期,页面自动化:** 经 `chrome.debugger` 中转 CDP;在 sctl 侧实现页面快照与元素引用、输入操作、自动等待、截图和执行脚本。 +- **第 4 期,调试:** console、异常和网络事件的缓存与查询。 +- **第 5 期,Playwright 和 Puppeteer:** 提供供 `connectOverCDP` 连接的 CDP 端点。 +- **其他不做的:** + - 支持 Firefox 和 Safari + - 上架 Chrome 应用商店 + - 浏览器控制的逐次审批或限定可操作的标签页范围 + - 让工具栏图标和 ScriptCat 区分开 + - 支持多个 ScriptCat 同时连接 + +## 测试决策 + +| 测试层 | 验证什么 | 可参照的现有测试 | +|---|---|---| +| Go bridge 测试:用真实 WebSocket 连接模拟的对端 | ScriptCat 和两个浏览器实例同时连接;调用按方法归属和目标正确路由;同一实例重连只替换它自己;配对浏览器不影响 ScriptCat 的密钥和连接;握手绑定了实例身份;重命名时名称保持唯一;忘记后连接被断开,之后握手被拒 | `internal/daemon/bridge/server_test.go`、`audit_test.go` | +| 控制 API 测试 | 目标选择表的每一行、三种目标相关错误、列表类调用的汇总 | `internal/daemon/controlapi/controlapi_test.go`、`harness_test.go` | +| 命令行测试:连接模拟的控制服务 | 表格和 JSON 输出;`--browser` 优先于 `SCTL_BROWSER`;每种结果对应的退出码 0、2、3 | `internal/cli/cli_test.go` | +| MCP 服务测试 | 浏览器工具已注册,描述是静态文本,并且会转发可选的 `browser` 参数 | `internal/client/mcpserver/mcpserver_test.go` | +| 协议生成检查 | Go 和 TypeScript 生成代码(包括扩展用的)与 `protocol.json` 一致 | `make protocol-check`、`internal/protocolgen/generate_test.go` | +| 扩展单元测试(Vitest,mock `chrome.*`) | 标签页和窗口方法的处理逻辑;握手状态和退避的状态转换;设置的持久化;配对码草稿的恢复;弹窗的每个状态都能根据连接状态正确渲染,中英文都要覆盖 | 本仓库暂无,参照 ScriptCat 的 Vitest 测试 | +| 一次性真机验证,放在 `e2e/scratch/`,不提交 | 真实 Chrome 同时加载解压的扩展和 ScriptCat,两者都配对到同一个 `sctl serve`;依次执行配对、改名、`tabs open`、`tabs list`、`tabs close`、忘记;整个过程中 ScriptCat 的 `sctl get` 一直正常 | [verification.md](../verification.md) | + +弹窗的视觉效果不写自动化测试,收尾时截图检查。截图要覆盖:两种外观、两种语言、焦点框、减少动态效果。 + +## 未决问题 + + From 8c6b4190e1c94fd6d8d83d2cc79f077331fb5622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 17:51:16 +0800 Subject: [PATCH 02/25] docs: generate protocol bindings per peer in browser foundation spec --- docs/specs/2026-09-28-browser-foundation.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/2026-09-28-browser-foundation.md b/docs/specs/2026-09-28-browser-foundation.md index cdc1f9b..b8c0a8e 100644 --- a/docs/specs/2026-09-28-browser-foundation.md +++ b/docs/specs/2026-09-28-browser-foundation.md @@ -47,6 +47,7 @@ | 11 | 扩展打包成 zip 附在每个 GitHub Release 上,用户通过「加载已解压的扩展程序」安装。 | 设计评审时已确认。本期不上架 Chrome 应用商店。 | | 12 | 弹窗采用「同门」风格:ScriptCat 的天蓝色和 ScriptCat 的 logo,加一条「这个浏览器 ↔ daemon」的连线,用线型表示连接状态。工具栏图标直接用 ScriptCat 的 logo,不做修改。 | 用户从三个方向(铭牌、同门、终端)中选定。用户接受两个扩展的工具栏图标看起来完全一样。 | | 13 | 弹窗提供外观设置(跟随系统、浅色、深色)和语言设置(跟随浏览器、中文、English),两项默认都跟随系统和浏览器。 | 用户要求。因为语言可以手动切换,弹窗文案要用扩展自带的词典,不能用 `chrome.i18n`,后者无法在运行时切换语言。清单里的扩展名称和描述仍然走 `_locales`,跟随浏览器语言。 | +| 14 | `protocol.json` 仍是唯一的协议权威,但其中每个方法、错误码和握手常量都标注归属(ScriptCat 或浏览器),并按对端分别生成代码:Go 侧拿到全部定义;给 ScriptCat 的 TS 文件只含属于 ScriptCat 的部分,和现在逐字节一致;浏览器扩展另生成一套自己的 TS。`schemaVersion` 保持不变。 | 用户决定。现状是只生成一套 TS(`internal/protocolgen/generate.go:55-58`),CI 要求它和固定版本的 ScriptCat 副本逐字节一致(`.github/workflows/test.yaml:57,81-82`),而 ScriptCat 会把生成文件里的所有方法都声明为自己的能力,一致性测试还要求方法和错误码列表完全相等。在同一套输出里加入浏览器方法,会让 CI 失败,或者让 ScriptCat 声称支持它并不支持的方法。daemon 要求 `schemaVersion` 完全相等(`internal/daemon/bridge/conn.go:256`),改版本号会断开现有的 ScriptCat。被否:跨仓库修改 ScriptCat 并移动 CI 固定版本,违反「不动 ScriptCat」,还会多出跨仓库依赖;给浏览器单独建一份协议文件,会出现两个权威文件。 | ## 浏览器实例的身份与配对 @@ -134,7 +135,7 @@ **`sctl status`:** 额外列出各浏览器实例及其在线状态。现有的 ScriptCat 字段和退出行为不变。 -**协议:** 新方法先加进 `protocol.json`,再从它生成 Go 和 TypeScript 代码。扩展通过现有的 `$session.capabilities` 协商,声明自己实现了哪些浏览器方法。 +**协议:** 新方法、新错误码和新的握手常量先加进 `protocol.json` 并标注归属为浏览器,再按对端分别生成代码(见决策 14)。给 ScriptCat 的生成文件保持逐字节不变,CI 里对固定版本 ScriptCat 的比对继续通过。浏览器扩展使用自己那套生成文件,并通过现有的 `$session.capabilities` 协商,声明自己实现了哪些浏览器方法。 ## 扩展弹窗 @@ -244,7 +245,7 @@ | 控制 API 测试 | 目标选择表的每一行、三种目标相关错误、列表类调用的汇总 | `internal/daemon/controlapi/controlapi_test.go`、`harness_test.go` | | 命令行测试:连接模拟的控制服务 | 表格和 JSON 输出;`--browser` 优先于 `SCTL_BROWSER`;每种结果对应的退出码 0、2、3 | `internal/cli/cli_test.go` | | MCP 服务测试 | 浏览器工具已注册,描述是静态文本,并且会转发可选的 `browser` 参数 | `internal/client/mcpserver/mcpserver_test.go` | -| 协议生成检查 | Go 和 TypeScript 生成代码(包括扩展用的)与 `protocol.json` 一致 | `make protocol-check`、`internal/protocolgen/generate_test.go` | +| 协议生成检查 | Go、ScriptCat 用的 TS、浏览器扩展用的 TS 三套生成代码都与 `protocol.json` 一致;ScriptCat 那套只含 ScriptCat 的方法、错误码和常量,并与生成器改造前逐字节相同 | `make protocol-check`、`internal/protocolgen/generate_test.go`、CI 的 `protocol-schema` 任务 | | 扩展单元测试(Vitest,mock `chrome.*`) | 标签页和窗口方法的处理逻辑;握手状态和退避的状态转换;设置的持久化;配对码草稿的恢复;弹窗的每个状态都能根据连接状态正确渲染,中英文都要覆盖 | 本仓库暂无,参照 ScriptCat 的 Vitest 测试 | | 一次性真机验证,放在 `e2e/scratch/`,不提交 | 真实 Chrome 同时加载解压的扩展和 ScriptCat,两者都配对到同一个 `sctl serve`;依次执行配对、改名、`tabs open`、`tabs list`、`tabs close`、忘记;整个过程中 ScriptCat 的 `sctl get` 一直正常 | [verification.md](../verification.md) | From 90e429a516162adf4779ae3a5cc72a4d53be9af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 20:57:48 +0800 Subject: [PATCH 03/25] feat: annotate protocol ownership and generate bindings per peer Add browser tab and window methods, browser target error codes and browser handshake contexts to protocol.json; generate Go for every peer, ScriptCat TypeScript byte-identical to the paired copy, and browser TypeScript under extension/src/protocol/generated. --- Makefile | 10 +- cmd/protocolgen/main.go | 5 +- docs/development.md | 18 +- docs/protocol.md | 64 +++- .../protocol/generated/protocol.generated.ts | 140 ++++++++ .../generated/validators.generated.ts | 67 ++++ .../protocol/generated/protocol.generated.go | 114 ++++++- internal/pkg/protocol/protocol.go | 48 ++- internal/pkg/protocol/protocol.json | 303 ++++++++++++++-- internal/pkg/protocol/protocol_test.go | 71 +++- internal/pkg/protocolschema/validate_test.go | 34 ++ internal/protocolgen/generate.go | 323 +++++++++++++++--- internal/protocolgen/generate_test.go | 197 ++++++++++- 13 files changed, 1277 insertions(+), 117 deletions(-) create mode 100644 extension/src/protocol/generated/protocol.generated.ts create mode 100644 extension/src/protocol/generated/validators.generated.ts diff --git a/Makefile b/Makefile index 5435cfe..9ebf938 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ BUILD_DIR ?= bin BINARY := $(BUILD_DIR)/sctl VERSION_PACKAGE := github.com/scriptscat/sctl/internal/cli SCRIPTCAT_DIR ?= ../scriptcat +BROWSER_PROTOCOL_DIR := extension/src/protocol/generated +PROTOCOL_GENERATED_DIRS := internal/pkg/protocol/generated $(BROWSER_PROTOCOL_DIR) .PHONY: help build test lint dev protocol-generate protocol-sync-scriptcat protocol-check clean @@ -28,8 +30,8 @@ dev: ## 构建并启动本地 daemon(DEV_VERSION=0.1.0) $(GO) build -ldflags "-X $(VERSION_PACKAGE).Version=$(DEV_VERSION)" -o $(BINARY) ./cmd/sctl $(BINARY) serve -protocol-generate: ## 从权威 schema 生成 Go、TypeScript 与 TypeScript 校验器 - $(GO) run ./cmd/protocolgen -schema internal/pkg/protocol -out internal/pkg/protocol/generated +protocol-generate: ## 从权威 schema 生成 Go、ScriptCat 与浏览器扩展的 TypeScript 及校验器 + $(GO) run ./cmd/protocolgen -schema internal/pkg/protocol -out internal/pkg/protocol/generated -browser-out $(BROWSER_PROTOCOL_DIR) protocol-sync-scriptcat: protocol-generate ## 更新相邻 ScriptCat 仓库的生成产物 mkdir -p $(SCRIPTCAT_DIR)/src/app/service/service_worker/external_access/generated @@ -40,7 +42,9 @@ protocol-sync-scriptcat: protocol-generate ## 更新相邻 ScriptCat 仓库的 $(SCRIPTCAT_DIR)/src/app/service/service_worker/external_access/generated/validators.generated.ts protocol-check: protocol-generate ## 检查生成物已提交且可复现 - git diff --exit-code -- internal/pkg/protocol/generated + git diff --exit-code -- $(PROTOCOL_GENERATED_DIRS) + @untracked="$$(git ls-files --others --exclude-standard -- $(PROTOCOL_GENERATED_DIRS))"; \ + if [ -n "$$untracked" ]; then echo "未提交的生成物:"; echo "$$untracked"; exit 1; fi clean: ## 删除本地构建产物 rm -rf $(BUILD_DIR) diff --git a/cmd/protocolgen/main.go b/cmd/protocolgen/main.go index b02bb67..d237b84 100644 --- a/cmd/protocolgen/main.go +++ b/cmd/protocolgen/main.go @@ -10,9 +10,10 @@ import ( func main() { schema := flag.String("schema", "internal/pkg/protocol", "protocol source directory") - out := flag.String("out", "internal/pkg/protocol/generated", "generated output directory") + out := flag.String("out", "internal/pkg/protocol/generated", "output directory for the Go bindings and the ScriptCat TypeScript") + browserOut := flag.String("browser-out", "extension/src/protocol/generated", "output directory for the browser extension TypeScript") flag.Parse() - if err := protocolgen.Generate(*schema, *out); err != nil { + if err := protocolgen.Generate(*schema, *out, *browserOut); err != nil { fail(err) } } diff --git a/docs/development.md b/docs/development.md index 559b19c..3321d1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -148,10 +148,14 @@ Cross-compilation, packaging, checksums, and the GitHub Release upload all happe ## Protocol source and generation `internal/pkg/protocol/protocol.json` is the only maintained source for the extension-facing RPC contract. Run -`make protocol-generate` after changing it. The Go and TypeScript bindings, native TypeScript validators, and -ScriptCat copies are generated artifacts and must be updated in the same cross-repository change. -`make protocol-sync-scriptcat` updates the adjacent checkout; override `SCRIPTCAT_DIR` when it lives elsewhere. - -`make protocol-check` verifies that every checked-in artifact is reproducible from the source. The generator also -validates the protocol definition before emitting code, so invalid method bindings and unsupported schema shapes -fail generation. +`make protocol-generate` after changing it. It writes the Go bindings (every peer) and the ScriptCat TypeScript +bindings and validators to `internal/pkg/protocol/generated/`, and the sctl Browser TypeScript bindings and +validators to `extension/src/protocol/generated/`; which definitions each TypeScript set receives is described in +[protocol.md](./protocol.md#6-generation-and-conformance). A change to ScriptCat-owned definitions alters the +ScriptCat copies and must be updated in the same cross-repository change. `make protocol-sync-scriptcat` updates +the adjacent checkout; override `SCRIPTCAT_DIR` when it lives elsewhere. + +`make protocol-check` verifies that all three generated sets are checked in and reproducible from the source. +`go test ./internal/protocolgen` additionally pins the ScriptCat TypeScript to the bytes of the paired ScriptCat +copy. The generator also validates the protocol definition before emitting code, so invalid method bindings, +peer annotations, merge fields, unreferenced types, and unsupported schema shapes fail generation. diff --git a/docs/protocol.md b/docs/protocol.md index e07f540..9fad449 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -150,23 +150,34 @@ A successful call returns the generated result type: } ``` -The current methods are: - -| Method | Effect | Blocking behavior | -|---|---|---| -| `scripts.list` | read script summaries | none | -| `scripts.metadata.get` | read metadata | none | -| `scripts.source.get` | read source | disclosure confirmation | -| `scripts.source.grep` | search source | disclosure confirmation | -| `scripts.install.request` | install a script | write approval | -| `scripts.toggle.request` | enable or disable a script | write approval | -| `scripts.delete.request` | delete a script | write approval | -| `scripts.edit.request` | edit a script | write approval | +Every method is owned by exactly one peer (`peer` in `protocol.json`): ScriptCat implements the `scripts.*` +methods and the sctl Browser extension implements the tab and window methods. The current methods are: + +| Method | Peer | Effect | Blocking behavior | +|---|---|---|---| +| `scripts.list` | ScriptCat | read script summaries | none | +| `scripts.metadata.get` | ScriptCat | read metadata | none | +| `scripts.source.get` | ScriptCat | read source | disclosure confirmation | +| `scripts.source.grep` | ScriptCat | search source | disclosure confirmation | +| `scripts.install.request` | ScriptCat | install a script | write approval | +| `scripts.toggle.request` | ScriptCat | enable or disable a script | write approval | +| `scripts.delete.request` | ScriptCat | delete a script | write approval | +| `scripts.edit.request` | ScriptCat | edit a script | write approval | +| `tabs.list` | browser | list tabs, optionally in one window | none | +| `tabs.open` | browser | open a URL in a new tab and return its tab ID | none | +| `tabs.close` | browser | close one or more tabs | none | +| `tabs.activate` | browser | activate a tab and focus its window | none | +| `windows.list` | browser | list windows | none | Source and metadata returned by these methods are untrusted user-script content. Consumers must not execute it, render it as HTML, interpret it as instructions, or include credentials in logs. Source results carry a SHA-256 digest. Edit approval rechecks the staged digest and target identity before applying changes. +Tab titles and URLs are controlled by web pages; `tabs.list` marks its result with +`contentTrust: "untrusted-page-content"` and the same handling rules apply. A list method declares a +`mergeField`: the required array property in its result that holds the listed items, so results from several +browser instances combine by concatenating that array. Methods without `mergeField` are never combined. + `scripts.source.get` accepts an optional `maxBytes` budget for a whole-file response. When the UTF-8 source is larger, the extension returns `PAYLOAD_TOO_LARGE` before placing the source in a WebSocket frame; callers should use `scripts.source.grep` and then request a `startLine`/`endLine` window. The budget does not apply when a line @@ -196,6 +207,15 @@ put the stable domain code in `error.data.code`: The generated `errorCodes` list is authoritative for domain codes. Messages are human-readable diagnostics; callers branch on the numeric JSON-RPC code and `data.code`, not on message text. +These codes are reserved for browser target selection failures: + +| Code | Meaning | +|---|---| +| `NO_BROWSER_CONNECTED` | a browser method was called while no browser instance is online | +| `BROWSER_OFFLINE` | the target names a paired browser instance that is not connected | +| `BROWSER_NOT_FOUND` | the target matches no paired browser instance | +| `BROWSER_AMBIGUOUS` | the target instance-ID prefix matches more than one paired instance | + ## 5. Cancellation and approval Write and source-disclosure requests remain pending until the user decides. If the requester disconnects, @@ -216,7 +236,23 @@ the JSON-RPC response through the offscreen WebSocket owner. ## 6. Generation and conformance +`protocol.json` annotates ownership: each method has one `peer` (`scriptcat` or `browser`), and each +`errorCodes` entry and each `crypto.context` entry lists the `peers` that use it. The `browser*` context strings +are reserved for browser-instance handshakes, so a MAC computed for one peer kind never verifies as the other; the +pairing KDF strings are shared. The generator emits one set of bindings per peer: + +| Output | Contents | +|---|---| +| `internal/pkg/protocol/generated/protocol.generated.go` | every method, type, error code, and context key, for the daemon and CLI | +| `internal/pkg/protocol/generated/*.ts` | ScriptCat's methods, the types they reference, and the codes and contexts listing `scriptcat` | +| `extension/src/protocol/generated/*.ts` | the same selection for `browser` | + +The ScriptCat TypeScript must stay byte-identical to the copy in the paired ScriptCat revision, because ScriptCat +declares every generated method as a capability and its conformance test compares the method and error-code +lists exactly. Adding browser-owned definitions therefore never changes ScriptCat's files or `schemaVersion`. + Run `make protocol-generate` after editing `protocol.json`, and `make protocol-sync-scriptcat` to update the adjacent ScriptCat checkout. `make protocol-check` regenerates all artifacts and fails if the checked-in output -differs. Both peers parse the JSON-RPC structure directly. ScriptCat validates business parameters with generated -native TypeScript type guards, so extension startup does not compile schemas at runtime. +differs or a generated file is untracked. Both peers parse the JSON-RPC structure directly. ScriptCat validates +business parameters with generated native TypeScript type guards, so extension startup does not compile schemas +at runtime. diff --git a/extension/src/protocol/generated/protocol.generated.ts b/extension/src/protocol/generated/protocol.generated.ts new file mode 100644 index 0000000..d928b57 --- /dev/null +++ b/extension/src/protocol/generated/protocol.generated.ts @@ -0,0 +1,140 @@ +// Code generated by protocolgen; DO NOT EDIT. +export const SCHEMA_VERSION = "1.0.0" as const; +export const JSONRPC_VERSION = "2.0" as const; +export const TRANSPORT = { + "defaultUrl": "ws://127.0.0.1:8643", + "defaultPort": 8643, + "frame": "json-text" +} as const; +export const SESSION_METHODS = [ + "$session.authenticate", + "$session.authenticated", + "$session.hello", + "$session.capabilities", + "$session.ping", + "$session.shutdown", + "$/cancelRequest" +] as const; +export const ERROR_CODES = [ + "INVALID_REQUEST", + "METHOD_NOT_FOUND", + "OPERATION_EXPIRED", + "CONFLICT", + "NOT_FOUND", + "INTERNAL_ERROR", + "NO_BROWSER_CONNECTED", + "BROWSER_OFFLINE", + "BROWSER_NOT_FOUND", + "BROWSER_AMBIGUOUS" +] as const; +export const CRYPTO = { + "mac": "HMAC-SHA-256", + "kdf": "HKDF-SHA-256", + "aead": "AES-256-GCM", + "nonceBytes": 32, + "encoding": "lowercase-hex", + "context": { + "pairKdfSalt": "scriptcat-rpc-v1/pair-salt", + "pairKdfInfoMac": "scriptcat-rpc-v1/pair-mac", + "pairKdfInfoEnc": "scriptcat-rpc-v1/pair-enc", + "browserSessionExt": "sctl-browser-rpc-v1/ext", + "browserSessionDaemon": "sctl-browser-rpc-v1/daemon", + "browserPairExt": "sctl-browser-rpc-v1/pair-ext", + "browserPairDaemon": "sctl-browser-rpc-v1/pair-daemon" + } +} as const; +export const LIMITS = { + "authTimeoutMs": 5000, + "writeDecisionTtlMs": 300000, + "extPairingCodeTtlMs": 120000, + "maxSourceBytes": 2097152, + "maxFrameBytes": 4194304, + "pingIntervalMs": 30000 +} as const; +export const PAIRING_CODE = { + "alphabet": "crockford-base32", + "length": 8, + "display": "XXXX-XXXX" +} as const; +export interface TabsActivateParams { + tabId: number; +} +export interface TabsActivateResult { + tabId: number; + windowId: number; +} +export interface TabsCloseParams { + tabIds: Array; +} +export interface TabsCloseResult { + tabIds: Array; +} +export interface TabsListParams { + windowId?: number; +} +export interface TabsListResult { + contentTrust: "untrusted-page-content"; + tabs: Array<{ active: boolean; pinned: boolean; tabId: number; title: string; url: string; windowId: number; }>; +} +export interface TabsOpenParams { + background?: boolean; + url: string; + windowId?: number; +} +export interface TabsOpenResult { + tabId: number; +} +export type WindowsListParams = Record; +export interface WindowsListResult { + windows: Array<{ focused: boolean; state: "normal" | "minimized" | "maximized" | "fullscreen" | "locked-fullscreen"; tabCount: number; windowId: number; }>; +} +export interface RpcMethodMap { + "tabs.activate": { params: TabsActivateParams; result: TabsActivateResult }; + "tabs.close": { params: TabsCloseParams; result: TabsCloseResult }; + "tabs.list": { params: TabsListParams; result: TabsListResult }; + "tabs.open": { params: TabsOpenParams; result: TabsOpenResult }; + "windows.list": { params: WindowsListParams; result: WindowsListResult }; +} +export type RpcMethod = keyof RpcMethodMap; +export type RpcParams = RpcMethodMap[M]["params"]; +export type RpcResult = RpcMethodMap[M]["result"]; +export const RPC_METHODS = { + "tabs.activate": { + params: "TabsActivateParams", + result: "TabsActivateResult", + scope: "tabs:activate", + effect: "write", + blocking: "none", + }, + "tabs.close": { + params: "TabsCloseParams", + result: "TabsCloseResult", + scope: "tabs:close", + effect: "write", + blocking: "none", + }, + "tabs.list": { + params: "TabsListParams", + result: "TabsListResult", + scope: "tabs:list", + effect: "read", + blocking: "none", + }, + "tabs.open": { + params: "TabsOpenParams", + result: "TabsOpenResult", + scope: "tabs:open", + effect: "write", + blocking: "none", + }, + "windows.list": { + params: "WindowsListParams", + result: "WindowsListResult", + scope: "windows:list", + effect: "read", + blocking: "none", + }, +} as const satisfies Record< + RpcMethod, + { params: string; result: string; scope: string; effect: string; blocking: string } +>; diff --git a/extension/src/protocol/generated/validators.generated.ts b/extension/src/protocol/generated/validators.generated.ts new file mode 100644 index 0000000..51c2014 --- /dev/null +++ b/extension/src/protocol/generated/validators.generated.ts @@ -0,0 +1,67 @@ +// Code generated by protocolgen; DO NOT EDIT. +import type * as Protocol from "./protocol.generated"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const allowedKeys = new Set(allowed); + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +export function validateTabsActivateParams(value: unknown): value is Protocol.TabsActivateParams { + return (isRecord(value) && hasOnlyKeys(value, ["tabId"]) && typeof value["tabId"] === "number" && Number.isInteger(value["tabId"]) && value["tabId"] >= 0); +} + +export function validateTabsActivateResult(value: unknown): value is Protocol.TabsActivateResult { + return (isRecord(value) && hasOnlyKeys(value, ["tabId", "windowId"]) && typeof value["tabId"] === "number" && Number.isInteger(value["tabId"]) && typeof value["windowId"] === "number" && Number.isInteger(value["windowId"])); +} + +export function validateTabsCloseParams(value: unknown): value is Protocol.TabsCloseParams { + return (isRecord(value) && hasOnlyKeys(value, ["tabIds"]) && Array.isArray(value["tabIds"]) && value["tabIds"].length >= 1 && value["tabIds"].every((item) => typeof item === "number" && Number.isInteger(item) && item >= 0)); +} + +export function validateTabsCloseResult(value: unknown): value is Protocol.TabsCloseResult { + return (isRecord(value) && hasOnlyKeys(value, ["tabIds"]) && Array.isArray(value["tabIds"]) && value["tabIds"].every((item) => typeof item === "number" && Number.isInteger(item))); +} + +export function validateTabsListParams(value: unknown): value is Protocol.TabsListParams { + return (isRecord(value) && hasOnlyKeys(value, ["windowId"]) && (value["windowId"] === undefined || (typeof value["windowId"] === "number" && Number.isInteger(value["windowId"]) && value["windowId"] >= 0))); +} + +export function validateTabsListResult(value: unknown): value is Protocol.TabsListResult { + return (isRecord(value) && hasOnlyKeys(value, ["contentTrust", "tabs"]) && value["contentTrust"] === "untrusted-page-content" && Array.isArray(value["tabs"]) && value["tabs"].every((item) => (isRecord(item) && hasOnlyKeys(item, ["active", "pinned", "tabId", "title", "url", "windowId"]) && typeof item["active"] === "boolean" && typeof item["pinned"] === "boolean" && typeof item["tabId"] === "number" && Number.isInteger(item["tabId"]) && typeof item["title"] === "string" && typeof item["url"] === "string" && typeof item["windowId"] === "number" && Number.isInteger(item["windowId"])))); +} + +export function validateTabsOpenParams(value: unknown): value is Protocol.TabsOpenParams { + return (isRecord(value) && hasOnlyKeys(value, ["background", "url", "windowId"]) && (value["background"] === undefined || (typeof value["background"] === "boolean")) && typeof value["url"] === "string" && (value["windowId"] === undefined || (typeof value["windowId"] === "number" && Number.isInteger(value["windowId"]) && value["windowId"] >= 0))); +} + +export function validateTabsOpenResult(value: unknown): value is Protocol.TabsOpenResult { + return (isRecord(value) && hasOnlyKeys(value, ["tabId"]) && typeof value["tabId"] === "number" && Number.isInteger(value["tabId"])); +} + +export function validateWindowsListParams(value: unknown): value is Protocol.WindowsListParams { + return (isRecord(value) && hasOnlyKeys(value, [])); +} + +export function validateWindowsListResult(value: unknown): value is Protocol.WindowsListResult { + return (isRecord(value) && hasOnlyKeys(value, ["windows"]) && Array.isArray(value["windows"]) && value["windows"].every((item) => (isRecord(item) && hasOnlyKeys(item, ["focused", "state", "tabCount", "windowId"]) && typeof item["focused"] === "boolean" && (item["state"] === "normal" || item["state"] === "minimized" || item["state"] === "maximized" || item["state"] === "fullscreen" || item["state"] === "locked-fullscreen") && typeof item["tabCount"] === "number" && Number.isInteger(item["tabCount"]) && typeof item["windowId"] === "number" && Number.isInteger(item["windowId"])))); +} + +export const RPC_PARAM_VALIDATORS = { + "tabs.activate": validateTabsActivateParams, + "tabs.close": validateTabsCloseParams, + "tabs.list": validateTabsListParams, + "tabs.open": validateTabsOpenParams, + "windows.list": validateWindowsListParams, +} as const; + +export const RPC_RESULT_VALIDATORS = { + "tabs.activate": validateTabsActivateResult, + "tabs.close": validateTabsCloseResult, + "tabs.list": validateTabsListResult, + "tabs.open": validateTabsOpenResult, + "windows.list": validateWindowsListResult, +} as const; diff --git a/internal/pkg/protocol/generated/protocol.generated.go b/internal/pkg/protocol/generated/protocol.generated.go index 5b18007..0fe7c8e 100644 --- a/internal/pkg/protocol/generated/protocol.generated.go +++ b/internal/pkg/protocol/generated/protocol.generated.go @@ -131,6 +131,61 @@ type ScriptsToggleResult struct { UUID string `json:"uuid"` } +type TabsActivateParams struct { + TabId int `json:"tabId"` +} + +type TabsActivateResult struct { + TabId int `json:"tabId"` + WindowId int `json:"windowId"` +} + +type TabsCloseParams struct { + TabIds []int `json:"tabIds"` +} + +type TabsCloseResult struct { + TabIds []int `json:"tabIds"` +} + +type TabsListParams struct { + WindowId *int `json:"windowId,omitempty"` +} + +type TabsListResult struct { + ContentTrust string `json:"contentTrust"` + Tabs []struct { + Active bool `json:"active"` + Pinned bool `json:"pinned"` + TabId int `json:"tabId"` + Title string `json:"title"` + URL string `json:"url"` + WindowId int `json:"windowId"` + } `json:"tabs"` +} + +type TabsOpenParams struct { + Background *bool `json:"background,omitempty"` + URL string `json:"url"` + WindowId *int `json:"windowId,omitempty"` +} + +type TabsOpenResult struct { + TabId int `json:"tabId"` +} + +type WindowsListParams struct { +} + +type WindowsListResult struct { + Windows []struct { + Focused bool `json:"focused"` + State string `json:"state"` + TabCount int `json:"tabCount"` + WindowId int `json:"windowId"` + } `json:"windows"` +} + type Method string const ( @@ -142,17 +197,58 @@ const ( MethodScriptsSourceGet Method = "scripts.source.get" MethodScriptsSourceGrep Method = "scripts.source.grep" MethodScriptsToggleRequest Method = "scripts.toggle.request" + MethodTabsActivate Method = "tabs.activate" + MethodTabsClose Method = "tabs.close" + MethodTabsList Method = "tabs.list" + MethodTabsOpen Method = "tabs.open" + MethodWindowsList Method = "windows.list" ) -type MethodMetadata struct{ Params, Result, Scope, Effect, Blocking string } +type MethodMetadata struct{ Params, Result, Scope, Effect, Blocking, Peer, MergeField string } var Methods = map[string]MethodMetadata{ - "scripts.delete.request": {Params: "ScriptUUIDParams", Result: "ScriptsDeleteResult", Scope: "scripts:delete:request", Effect: "write", Blocking: "approval"}, - "scripts.edit.request": {Params: "ScriptsEditParams", Result: "ScriptMutationResult", Scope: "scripts:edit:request", Effect: "write", Blocking: "approval"}, - "scripts.install.request": {Params: "ScriptsInstallParams", Result: "ScriptMutationResult", Scope: "scripts:install:request", Effect: "write", Blocking: "approval"}, - "scripts.list": {Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none"}, - "scripts.metadata.get": {Params: "ScriptUUIDParams", Result: "ScriptMetadata", Scope: "scripts:metadata:read", Effect: "read", Blocking: "none"}, - "scripts.source.get": {Params: "ScriptsSourceGetParams", Result: "ScriptSource", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure"}, - "scripts.source.grep": {Params: "ScriptsSourceGrepParams", Result: "ScriptSourceGrepResult", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure"}, - "scripts.toggle.request": {Params: "ScriptsToggleParams", Result: "ScriptsToggleResult", Scope: "scripts:toggle:request", Effect: "write", Blocking: "approval"}, + "scripts.delete.request": {Params: "ScriptUUIDParams", Result: "ScriptsDeleteResult", Scope: "scripts:delete:request", Effect: "write", Blocking: "approval", Peer: "scriptcat", MergeField: ""}, + "scripts.edit.request": {Params: "ScriptsEditParams", Result: "ScriptMutationResult", Scope: "scripts:edit:request", Effect: "write", Blocking: "approval", Peer: "scriptcat", MergeField: ""}, + "scripts.install.request": {Params: "ScriptsInstallParams", Result: "ScriptMutationResult", Scope: "scripts:install:request", Effect: "write", Blocking: "approval", Peer: "scriptcat", MergeField: ""}, + "scripts.list": {Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none", Peer: "scriptcat", MergeField: ""}, + "scripts.metadata.get": {Params: "ScriptUUIDParams", Result: "ScriptMetadata", Scope: "scripts:metadata:read", Effect: "read", Blocking: "none", Peer: "scriptcat", MergeField: ""}, + "scripts.source.get": {Params: "ScriptsSourceGetParams", Result: "ScriptSource", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure", Peer: "scriptcat", MergeField: ""}, + "scripts.source.grep": {Params: "ScriptsSourceGrepParams", Result: "ScriptSourceGrepResult", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure", Peer: "scriptcat", MergeField: ""}, + "scripts.toggle.request": {Params: "ScriptsToggleParams", Result: "ScriptsToggleResult", Scope: "scripts:toggle:request", Effect: "write", Blocking: "approval", Peer: "scriptcat", MergeField: ""}, + "tabs.activate": {Params: "TabsActivateParams", Result: "TabsActivateResult", Scope: "tabs:activate", Effect: "write", Blocking: "none", Peer: "browser", MergeField: ""}, + "tabs.close": {Params: "TabsCloseParams", Result: "TabsCloseResult", Scope: "tabs:close", Effect: "write", Blocking: "none", Peer: "browser", MergeField: ""}, + "tabs.list": {Params: "TabsListParams", Result: "TabsListResult", Scope: "tabs:list", Effect: "read", Blocking: "none", Peer: "browser", MergeField: "tabs"}, + "tabs.open": {Params: "TabsOpenParams", Result: "TabsOpenResult", Scope: "tabs:open", Effect: "write", Blocking: "none", Peer: "browser", MergeField: ""}, + "windows.list": {Params: "WindowsListParams", Result: "WindowsListResult", Scope: "windows:list", Effect: "read", Blocking: "none", Peer: "browser", MergeField: "windows"}, } + +const ( + ErrorCodeInvalidRequest = "INVALID_REQUEST" + ErrorCodeMethodNotFound = "METHOD_NOT_FOUND" + ErrorCodeUserRejected = "USER_REJECTED" + ErrorCodeOperationExpired = "OPERATION_EXPIRED" + ErrorCodeConflict = "CONFLICT" + ErrorCodeNotFound = "NOT_FOUND" + ErrorCodeRateLimited = "RATE_LIMITED" + ErrorCodePayloadTooLarge = "PAYLOAD_TOO_LARGE" + ErrorCodeInternalError = "INTERNAL_ERROR" + ErrorCodeNoBrowserConnected = "NO_BROWSER_CONNECTED" + ErrorCodeBrowserOffline = "BROWSER_OFFLINE" + ErrorCodeBrowserNotFound = "BROWSER_NOT_FOUND" + ErrorCodeBrowserAmbiguous = "BROWSER_AMBIGUOUS" +) + +// crypto.context 的键,用于查 protocol.Crypto.Context。 +const ( + CryptoContextBrowserPairDaemon = "browserPairDaemon" + CryptoContextBrowserPairExt = "browserPairExt" + CryptoContextBrowserSessionDaemon = "browserSessionDaemon" + CryptoContextBrowserSessionExt = "browserSessionExt" + CryptoContextPairDaemon = "pairDaemon" + CryptoContextPairExt = "pairExt" + CryptoContextPairKdfInfoEnc = "pairKdfInfoEnc" + CryptoContextPairKdfInfoMac = "pairKdfInfoMac" + CryptoContextPairKdfSalt = "pairKdfSalt" + CryptoContextSessionDaemon = "sessionDaemon" + CryptoContextSessionExt = "sessionExt" +) diff --git a/internal/pkg/protocol/protocol.go b/internal/pkg/protocol/protocol.go index 64c927b..758749b 100644 --- a/internal/pkg/protocol/protocol.go +++ b/internal/pkg/protocol/protocol.go @@ -21,7 +21,7 @@ type Protocol struct { SessionMethods []string `json:"sessionMethods"` Scopes []string `json:"scopes"` Actions map[string]Action `json:"methods"` - ErrorCodes []string `json:"errorCodes"` + ErrorCodes []ErrorCode `json:"errorCodes"` Crypto Crypto `json:"crypto"` Limits Limits `json:"limits"` PairingCode PairingCode `json:"pairingCode"` @@ -40,15 +40,49 @@ type Action struct { Blocking string `json:"blocking,omitempty"` Params string `json:"params"` Result string `json:"result"` + Peer Peer `json:"peer"` + // MergeField 是结果中的数组字段名:多个浏览器同时在线时 daemon 按它合并各实例的列表;非列表方法为空。 + MergeField string `json:"mergeField,omitempty"` +} + +// Peer 是协议定义的归属对端:方法由哪种扩展实现,错误码与握手常量由哪些扩展使用。 +type Peer string + +const ( + PeerScriptCat Peer = "scriptcat" + PeerBrowser Peer = "browser" +) + +type ErrorCode struct { + Code string `json:"code"` + Peers []Peer `json:"peers"` +} + +// CryptoContext 把 context 键映射到握手字符串;protocol.json 中每项另带归属标注,只在生成各对端代码时使用。 +type CryptoContext map[string]string + +func (c *CryptoContext) UnmarshalJSON(data []byte) error { + var entries map[string]struct { + Value string `json:"value"` + } + if err := json.Unmarshal(data, &entries); err != nil { + return err + } + values := make(CryptoContext, len(entries)) + for key, entry := range entries { + values[key] = entry.Value + } + *c = values + return nil } type Crypto struct { - MAC string `json:"mac"` - KDF string `json:"kdf"` - AEAD string `json:"aead"` - NonceBytes int `json:"nonceBytes"` - Encoding string `json:"encoding"` - Context map[string]string `json:"context"` + MAC string `json:"mac"` + KDF string `json:"kdf"` + AEAD string `json:"aead"` + NonceBytes int `json:"nonceBytes"` + Encoding string `json:"encoding"` + Context CryptoContext `json:"context"` } type Limits struct { diff --git a/internal/pkg/protocol/protocol.json b/internal/pkg/protocol/protocol.json index c09ac9c..c7beaff 100644 --- a/internal/pkg/protocol/protocol.json +++ b/internal/pkg/protocol/protocol.json @@ -21,56 +21,106 @@ "result": "ScriptsListResult", "scope": "scripts:list", "effect": "read", - "blocking": "none" + "blocking": "none", + "peer": "scriptcat" }, "scripts.metadata.get": { "params": "ScriptUUIDParams", "result": "ScriptMetadata", "scope": "scripts:metadata:read", "effect": "read", - "blocking": "none" + "blocking": "none", + "peer": "scriptcat" }, "scripts.source.get": { "params": "ScriptsSourceGetParams", "result": "ScriptSource", "scope": "scripts:source:read", "effect": "read", - "blocking": "disclosure" + "blocking": "disclosure", + "peer": "scriptcat" }, "scripts.source.grep": { "params": "ScriptsSourceGrepParams", "result": "ScriptSourceGrepResult", "scope": "scripts:source:read", "effect": "read", - "blocking": "disclosure" + "blocking": "disclosure", + "peer": "scriptcat" }, "scripts.install.request": { "params": "ScriptsInstallParams", "result": "ScriptMutationResult", "scope": "scripts:install:request", "effect": "write", - "blocking": "approval" + "blocking": "approval", + "peer": "scriptcat" }, "scripts.toggle.request": { "params": "ScriptsToggleParams", "result": "ScriptsToggleResult", "scope": "scripts:toggle:request", "effect": "write", - "blocking": "approval" + "blocking": "approval", + "peer": "scriptcat" }, "scripts.delete.request": { "params": "ScriptUUIDParams", "result": "ScriptsDeleteResult", "scope": "scripts:delete:request", "effect": "write", - "blocking": "approval" + "blocking": "approval", + "peer": "scriptcat" }, "scripts.edit.request": { "params": "ScriptsEditParams", "result": "ScriptMutationResult", "scope": "scripts:edit:request", "effect": "write", - "blocking": "approval" + "blocking": "approval", + "peer": "scriptcat" + }, + "tabs.list": { + "params": "TabsListParams", + "result": "TabsListResult", + "scope": "tabs:list", + "effect": "read", + "blocking": "none", + "peer": "browser", + "mergeField": "tabs" + }, + "tabs.open": { + "params": "TabsOpenParams", + "result": "TabsOpenResult", + "scope": "tabs:open", + "effect": "write", + "blocking": "none", + "peer": "browser" + }, + "tabs.close": { + "params": "TabsCloseParams", + "result": "TabsCloseResult", + "scope": "tabs:close", + "effect": "write", + "blocking": "none", + "peer": "browser" + }, + "tabs.activate": { + "params": "TabsActivateParams", + "result": "TabsActivateResult", + "scope": "tabs:activate", + "effect": "write", + "blocking": "none", + "peer": "browser" + }, + "windows.list": { + "params": "WindowsListParams", + "result": "WindowsListResult", + "scope": "windows:list", + "effect": "read", + "blocking": "none", + "peer": "browser", + "mergeField": "windows" } }, "types": { @@ -559,18 +609,219 @@ "uuid", "deleted" ] + }, + "TabsListParams": { + "type": "object", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "TabsListResult": { + "type": "object", + "properties": { + "tabs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tabId": { + "type": "integer" + }, + "windowId": { + "type": "integer" + }, + "active": { + "type": "boolean" + }, + "pinned": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "tabId", + "windowId", + "active", + "pinned", + "title", + "url" + ], + "additionalProperties": false + } + }, + "contentTrust": { + "const": "untrusted-page-content" + } + }, + "required": [ + "tabs", + "contentTrust" + ], + "additionalProperties": false + }, + "TabsOpenParams": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "windowId": { + "type": "integer", + "minimum": 0 + }, + "background": { + "type": "boolean" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "TabsOpenResult": { + "type": "object", + "properties": { + "tabId": { + "type": "integer" + } + }, + "required": [ + "tabId" + ], + "additionalProperties": false + }, + "TabsCloseParams": { + "type": "object", + "properties": { + "tabIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0 + } + } + }, + "required": [ + "tabIds" + ], + "additionalProperties": false + }, + "TabsCloseResult": { + "type": "object", + "properties": { + "tabIds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "tabIds" + ], + "additionalProperties": false + }, + "TabsActivateParams": { + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "tabId" + ], + "additionalProperties": false + }, + "TabsActivateResult": { + "type": "object", + "properties": { + "tabId": { + "type": "integer" + }, + "windowId": { + "type": "integer" + } + }, + "required": [ + "tabId", + "windowId" + ], + "additionalProperties": false + }, + "WindowsListParams": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "WindowsListResult": { + "type": "object", + "properties": { + "windows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "windowId": { + "type": "integer" + }, + "focused": { + "type": "boolean" + }, + "state": { + "enum": [ + "normal", + "minimized", + "maximized", + "fullscreen", + "locked-fullscreen" + ] + }, + "tabCount": { + "type": "integer" + } + }, + "required": [ + "windowId", + "focused", + "state", + "tabCount" + ], + "additionalProperties": false + } + } + }, + "required": [ + "windows" + ], + "additionalProperties": false } }, "errorCodes": [ - "INVALID_REQUEST", - "METHOD_NOT_FOUND", - "USER_REJECTED", - "OPERATION_EXPIRED", - "CONFLICT", - "NOT_FOUND", - "RATE_LIMITED", - "PAYLOAD_TOO_LARGE", - "INTERNAL_ERROR" + { "code": "INVALID_REQUEST", "peers": ["scriptcat", "browser"] }, + { "code": "METHOD_NOT_FOUND", "peers": ["scriptcat", "browser"] }, + { "code": "USER_REJECTED", "peers": ["scriptcat"] }, + { "code": "OPERATION_EXPIRED", "peers": ["scriptcat", "browser"] }, + { "code": "CONFLICT", "peers": ["scriptcat", "browser"] }, + { "code": "NOT_FOUND", "peers": ["scriptcat", "browser"] }, + { "code": "RATE_LIMITED", "peers": ["scriptcat"] }, + { "code": "PAYLOAD_TOO_LARGE", "peers": ["scriptcat"] }, + { "code": "INTERNAL_ERROR", "peers": ["scriptcat", "browser"] }, + { "code": "NO_BROWSER_CONNECTED", "peers": ["browser"] }, + { "code": "BROWSER_OFFLINE", "peers": ["browser"] }, + { "code": "BROWSER_NOT_FOUND", "peers": ["browser"] }, + { "code": "BROWSER_AMBIGUOUS", "peers": ["browser"] } ], "crypto": { "mac": "HMAC-SHA-256", @@ -579,13 +830,17 @@ "nonceBytes": 32, "encoding": "lowercase-hex", "context": { - "sessionExt": "scriptcat-rpc-v1/ext", - "sessionDaemon": "scriptcat-rpc-v1/daemon", - "pairExt": "scriptcat-rpc-v1/pair-ext", - "pairDaemon": "scriptcat-rpc-v1/pair-daemon", - "pairKdfSalt": "scriptcat-rpc-v1/pair-salt", - "pairKdfInfoMac": "scriptcat-rpc-v1/pair-mac", - "pairKdfInfoEnc": "scriptcat-rpc-v1/pair-enc" + "sessionExt": { "value": "scriptcat-rpc-v1/ext", "peers": ["scriptcat"] }, + "sessionDaemon": { "value": "scriptcat-rpc-v1/daemon", "peers": ["scriptcat"] }, + "pairExt": { "value": "scriptcat-rpc-v1/pair-ext", "peers": ["scriptcat"] }, + "pairDaemon": { "value": "scriptcat-rpc-v1/pair-daemon", "peers": ["scriptcat"] }, + "pairKdfSalt": { "value": "scriptcat-rpc-v1/pair-salt", "peers": ["scriptcat", "browser"] }, + "pairKdfInfoMac": { "value": "scriptcat-rpc-v1/pair-mac", "peers": ["scriptcat", "browser"] }, + "pairKdfInfoEnc": { "value": "scriptcat-rpc-v1/pair-enc", "peers": ["scriptcat", "browser"] }, + "browserSessionExt": { "value": "sctl-browser-rpc-v1/ext", "peers": ["browser"] }, + "browserSessionDaemon": { "value": "sctl-browser-rpc-v1/daemon", "peers": ["browser"] }, + "browserPairExt": { "value": "sctl-browser-rpc-v1/pair-ext", "peers": ["browser"] }, + "browserPairDaemon": { "value": "sctl-browser-rpc-v1/pair-daemon", "peers": ["browser"] } } }, "limits": { diff --git a/internal/pkg/protocol/protocol_test.go b/internal/pkg/protocol/protocol_test.go index b56582f..7179da9 100644 --- a/internal/pkg/protocol/protocol_test.go +++ b/internal/pkg/protocol/protocol_test.go @@ -1,6 +1,10 @@ package protocol -import "testing" +import ( + "testing" + + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" +) func TestLoad(t *testing.T) { p, err := Load() @@ -32,3 +36,68 @@ func TestLoad(t *testing.T) { } }) } + +func TestLoadExposesMethodPeerAndListMergeField(t *testing.T) { + p, err := Load() + if err != nil { + t.Fatalf("load protocol: %v", err) + } + for name, want := range map[string]struct { + peer Peer + mergeField string + }{ + "scripts.list": {PeerScriptCat, ""}, + "tabs.list": {PeerBrowser, "tabs"}, + "windows.list": {PeerBrowser, "windows"}, + "tabs.open": {PeerBrowser, ""}, + "tabs.close": {PeerBrowser, ""}, + "tabs.activate": {PeerBrowser, ""}, + } { + action, ok := p.Actions[name] + if !ok { + t.Errorf("%s is not a protocol action", name) + continue + } + if action.Peer != want.peer || action.MergeField != want.mergeField { + t.Errorf("%s: peer=%q mergeField=%q, want peer=%q mergeField=%q", name, action.Peer, action.MergeField, want.peer, want.mergeField) + } + } +} + +func TestLoadKeepsBrowserHandshakeContextsDistinctFromScriptCat(t *testing.T) { + p, err := Load() + if err != nil { + t.Fatalf("load protocol: %v", err) + } + if got := p.Crypto.Context[generated.CryptoContextSessionExt]; got != "scriptcat-rpc-v1/ext" { + t.Fatalf("ScriptCat session context = %q, want the unchanged scriptcat-rpc-v1/ext", got) + } + scriptcat := map[string]bool{} + for _, key := range []string{generated.CryptoContextSessionExt, generated.CryptoContextSessionDaemon, generated.CryptoContextPairExt, generated.CryptoContextPairDaemon} { + scriptcat[p.Crypto.Context[key]] = true + } + seen := map[string]bool{} + for _, key := range []string{generated.CryptoContextBrowserSessionExt, generated.CryptoContextBrowserSessionDaemon, generated.CryptoContextBrowserPairExt, generated.CryptoContextBrowserPairDaemon} { + value := p.Crypto.Context[key] + if value == "" || scriptcat[value] || seen[value] { + t.Errorf("browser context %s = %q must be non-empty and unique across peers and directions", key, value) + } + seen[value] = true + } +} + +func TestLoadListsBrowserTargetErrorCodes(t *testing.T) { + p, err := Load() + if err != nil { + t.Fatalf("load protocol: %v", err) + } + codes := map[string]bool{} + for _, code := range p.ErrorCodes { + codes[code.Code] = true + } + for _, want := range []string{generated.ErrorCodeNoBrowserConnected, generated.ErrorCodeBrowserOffline, generated.ErrorCodeBrowserNotFound, generated.ErrorCodeBrowserAmbiguous} { + if want == "" || !codes[want] { + t.Errorf("error code %q is not listed in protocol.json errorCodes", want) + } + } +} diff --git a/internal/pkg/protocolschema/validate_test.go b/internal/pkg/protocolschema/validate_test.go index d7a1dca..2af2b5e 100644 --- a/internal/pkg/protocolschema/validate_test.go +++ b/internal/pkg/protocolschema/validate_test.go @@ -28,3 +28,37 @@ func TestValidateMethodResultUsesTheDeclaredResultSchema(t *testing.T) { t.Fatal("malformed result was accepted") } } + +func TestValidateWireFrameEnforcesBrowserTabSchemas(t *testing.T) { + t.Parallel() + const prefix = `{"jsonrpc":"2.0","id":"26d146ee-6d26-4bf6-9fe8-1e86d1582811","method":` + valid := []byte(prefix + `"tabs.open","params":{"input":{"url":"https://example.com","windowId":3,"background":true}}}`) + if err := protocolschema.ValidateWireFrame(valid); err != nil { + t.Fatalf("valid tabs.open frame rejected: %v", err) + } + for name, frame := range map[string]string{ + "tabs.open without url": prefix + `"tabs.open","params":{"input":{"windowId":3}}}`, + "tabs.close without any tab": prefix + `"tabs.close","params":{"input":{"tabIds":[]}}}`, + "tabs.activate with text id": prefix + `"tabs.activate","params":{"input":{"tabId":"7"}}}`, + } { + if err := protocolschema.ValidateWireFrame([]byte(frame)); err == nil { + t.Errorf("%s: malformed frame was accepted", name) + } + } +} + +func TestValidateMethodResultRejectsIncompleteBrowserListItems(t *testing.T) { + t.Parallel() + valid := `{"tabs":[{"tabId":7,"windowId":1,"active":true,"pinned":false,"title":"t","url":"https://example.com"}],"contentTrust":"untrusted-page-content"}` + if err := protocolschema.ValidateMethodResult("tabs.list", []byte(valid)); err != nil { + t.Fatalf("valid tabs.list result rejected: %v", err) + } + missingURL := `{"tabs":[{"tabId":7,"windowId":1,"active":true,"pinned":false,"title":"t"}],"contentTrust":"untrusted-page-content"}` + if err := protocolschema.ValidateMethodResult("tabs.list", []byte(missingURL)); err == nil { + t.Fatal("tabs.list result without a tab URL was accepted") + } + badState := `{"windows":[{"windowId":1,"focused":true,"state":"docked","tabCount":2}]}` + if err := protocolschema.ValidateMethodResult("windows.list", []byte(badState)); err == nil { + t.Fatal("windows.list result with an unknown window state was accepted") + } +} diff --git a/internal/protocolgen/generate.go b/internal/protocolgen/generate.go index e89c6a1..33dd19f 100644 --- a/internal/protocolgen/generate.go +++ b/internal/protocolgen/generate.go @@ -7,10 +7,13 @@ import ( "go/format" "os" "path/filepath" + "slices" "sort" "strconv" "strings" "unicode" + + "github.com/scriptscat/sctl/internal/pkg/protocol" ) type definition struct { @@ -20,21 +23,52 @@ type definition struct { SessionMethods []string `json:"sessionMethods"` Methods map[string]method `json:"methods"` Types map[string]json.RawMessage `json:"types"` - ErrorCodes []string `json:"errorCodes"` + ErrorCodes []errorCode `json:"errorCodes"` Crypto json.RawMessage `json:"crypto"` Limits json.RawMessage `json:"limits"` PairingCode json.RawMessage `json:"pairingCode"` + // CryptoContext 是 Crypto 中 context 的解析结果;Crypto 仍保留原始字节,因为 TypeScript 输出依赖其键序。 + CryptoContext map[string]contextEntry `json:"-"` } type method struct { - Params string `json:"params"` - Result string `json:"result"` - Scope string `json:"scope"` - Effect string `json:"effect"` - Blocking string `json:"blocking"` + Params string `json:"params"` + Result string `json:"result"` + Scope string `json:"scope"` + Effect string `json:"effect"` + Blocking string `json:"blocking"` + Peer protocol.Peer `json:"peer"` + MergeField string `json:"mergeField"` +} + +type errorCode struct { + Code string `json:"code"` + Peers []protocol.Peer `json:"peers"` } -func Generate(schemaDir, outDir string) error { +type contextEntry struct { + Value string `json:"value"` + Peers []protocol.Peer `json:"peers"` +} + +// peerContract 是某一对端 TypeScript 输出所见的协议子集:只含该对端实现的方法、它们引用的类型, +// 以及标注了该对端的错误码与握手常量。 +type peerContract struct { + SchemaVersion string + JSONRPC string + Transport json.RawMessage + SessionMethods []string + Methods map[string]method + Types map[string]json.RawMessage + ErrorCodes []string + Crypto json.RawMessage + Limits json.RawMessage + PairingCode json.RawMessage +} + +// Generate 从 schemaDir/protocol.json 生成代码:outDir 得到含全部定义的 Go 绑定与 ScriptCat 的 TypeScript, +// browserOutDir 得到浏览器扩展的 TypeScript。 +func Generate(schemaDir, outDir, browserOutDir string) error { raw, err := os.ReadFile(filepath.Join(schemaDir, "protocol.json")) if err != nil { return err @@ -43,6 +77,13 @@ func Generate(schemaDir, outDir string) error { if err := json.Unmarshal(raw, &def); err != nil { return fmt.Errorf("parse protocol: %w", err) } + var crypto struct { + Context map[string]contextEntry `json:"context"` + } + if err := json.Unmarshal(def.Crypto, &crypto); err != nil { + return fmt.Errorf("parse crypto: %w", err) + } + def.CryptoContext = crypto.Context if err := validateDefinition(def); err != nil { return err } @@ -52,20 +93,166 @@ func Generate(schemaDir, outDir string) error { if err := writeGo(filepath.Join(outDir, "protocol.generated.go"), def); err != nil { return err } - if err := os.WriteFile(filepath.Join(outDir, "protocol.generated.ts"), renderTS(def), 0o644); err != nil { + for peer, dir := range map[protocol.Peer]string{protocol.PeerScriptCat: outDir, protocol.PeerBrowser: browserOutDir} { + if err := writeTS(dir, def, peer); err != nil { + return err + } + } + return nil +} + +func writeTS(dir string, def definition, peer protocol.Peer) error { + contract, err := contractFor(def, peer) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "protocol.generated.ts"), renderTS(contract), 0o644); err != nil { return err } - return os.WriteFile(filepath.Join(outDir, "validators.generated.ts"), renderTSValidators(def), 0o644) + return os.WriteFile(filepath.Join(dir, "validators.generated.ts"), renderTSValidators(contract), 0o644) +} + +func contractFor(def definition, peer protocol.Peer) (peerContract, error) { + contract := peerContract{ + SchemaVersion: def.SchemaVersion, + JSONRPC: def.JSONRPC, + Transport: def.Transport, + SessionMethods: def.SessionMethods, + Methods: map[string]method{}, + Types: map[string]json.RawMessage{}, + ErrorCodes: []string{}, + Limits: def.Limits, + PairingCode: def.PairingCode, + } + for name, m := range def.Methods { + if m.Peer != peer { + continue + } + contract.Methods[name] = m + contract.Types[m.Params] = def.Types[m.Params] + contract.Types[m.Result] = def.Types[m.Result] + } + for _, code := range def.ErrorCodes { + if slices.Contains(code.Peers, peer) { + contract.ErrorCodes = append(contract.ErrorCodes, code.Code) + } + } + crypto, err := cryptoFor(def.Crypto, peer) + if err != nil { + return peerContract{}, err + } + contract.Crypto = crypto + return contract, nil +} + +// cryptoFor 按 protocol.json 中的键序重建 crypto 对象,context 只保留标注了 peer 的项并还原为字符串值; +// 键序决定 ScriptCat 输出的字节,所以不能经过 Go map。 +func cryptoFor(raw json.RawMessage, peer protocol.Peer) (json.RawMessage, error) { + members, err := orderedObject(raw) + if err != nil { + return nil, fmt.Errorf("crypto: %w", err) + } + for i, member := range members { + if member.Key != "context" { + continue + } + entries, err := orderedObject(member.Value) + if err != nil { + return nil, fmt.Errorf("crypto.context: %w", err) + } + kept := make([]objectMember, 0, len(entries)) + for _, entry := range entries { + var parsed contextEntry + if err := json.Unmarshal(entry.Value, &parsed); err != nil { + return nil, fmt.Errorf("crypto.context.%s: %w", entry.Key, err) + } + if slices.Contains(parsed.Peers, peer) { + kept = append(kept, objectMember{Key: entry.Key, Value: mustJSON(parsed.Value)}) + } + } + members[i].Value = encodeObject(kept) + } + return encodeObject(members), nil +} + +type objectMember struct { + Key string + Value json.RawMessage +} + +func orderedObject(raw json.RawMessage) ([]objectMember, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return nil, fmt.Errorf("expected a JSON object") + } + var members []objectMember + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := token.(string) + if !ok { + return nil, fmt.Errorf("expected an object key, got %v", token) + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + members = append(members, objectMember{Key: key, Value: value}) + } + return members, nil +} + +func encodeObject(members []objectMember) json.RawMessage { + var b bytes.Buffer + b.WriteByte('{') + for i, member := range members { + if i > 0 { + b.WriteByte(',') + } + b.Write(mustJSON(member.Key)) + b.WriteByte(':') + b.Write(member.Value) + } + b.WriteByte('}') + return b.Bytes() +} + +func validatePeers(peers []protocol.Peer, owner string) error { + if len(peers) == 0 { + return fmt.Errorf("%s has no peers", owner) + } + seen := map[protocol.Peer]bool{} + for _, peer := range peers { + if peer != protocol.PeerScriptCat && peer != protocol.PeerBrowser { + return fmt.Errorf("%s has invalid peer %q", owner, peer) + } + if seen[peer] { + return fmt.Errorf("%s lists peer %q twice", owner, peer) + } + seen[peer] = true + } + return nil } func validateDefinition(def definition) error { if def.SchemaVersion == "" || def.JSONRPC != "2.0" || len(def.SessionMethods) == 0 || len(def.Methods) == 0 { return fmt.Errorf("incomplete protocol definition") } + referenced := map[string]bool{} for name, method := range def.Methods { if method.Params == "" || method.Result == "" || method.Scope == "" { return fmt.Errorf("rpc %q has an incomplete contract", name) } + if err := validatePeers([]protocol.Peer{method.Peer}, fmt.Sprintf("rpc %q", name)); err != nil { + return err + } + referenced[method.Params] = true + referenced[method.Result] = true if _, ok := def.Types[method.Params]; !ok { return fmt.Errorf("rpc %q references unknown params type %q", name, method.Params) } @@ -80,12 +267,49 @@ func validateDefinition(def definition) error { default: return fmt.Errorf("rpc %q has invalid blocking mode %q", name, method.Blocking) } + if method.MergeField != "" { + if err := validateMergeField(def.Types[method.Result], method.MergeField); err != nil { + return fmt.Errorf("rpc %q: %w", name, err) + } + } } for name, raw := range def.Types { + // 各对端的 TypeScript 只收录其方法引用的类型,未被引用的类型会从所有 TypeScript 输出中消失。 + if !referenced[name] { + return fmt.Errorf("type %q is not used by any rpc", name) + } if err := validateCodegenSchema(raw, name); err != nil { return err } } + codes := map[string]bool{} + for _, code := range def.ErrorCodes { + if code.Code == "" || codes[code.Code] { + return fmt.Errorf("error code %q is empty or duplicated", code.Code) + } + codes[code.Code] = true + if err := validatePeers(code.Peers, fmt.Sprintf("error code %q", code.Code)); err != nil { + return err + } + } + for key, entry := range def.CryptoContext { + if entry.Value == "" { + return fmt.Errorf("crypto context %q has no value", key) + } + if err := validatePeers(entry.Peers, fmt.Sprintf("crypto context %q", key)); err != nil { + return err + } + } + return nil +} + +// validateMergeField 要求合并字段是结果类型中必填的数组属性,daemon 才能无条件地拼接各实例的列表。 +func validateMergeField(result json.RawMessage, field string) error { + schema := parseSchema(result) + property, ok := schema.Properties[field] + if !ok || !requiredSet(schema)[field] || parseSchema(property).Type != "array" { + return fmt.Errorf("mergeField %q is not a required array property of the result", field) + } return nil } @@ -114,15 +338,6 @@ func validateCodegenSchema(raw json.RawMessage, path string) error { } } -func sortedMethods(def definition) []string { - names := make([]string, 0, len(def.Methods)) - for name := range def.Methods { - names = append(names, name) - } - sort.Strings(names) - return names -} - type schemaProperty struct { Type string `json:"type"` Format string `json:"format"` @@ -199,7 +414,7 @@ func goType(raw json.RawMessage, optional bool) string { var b strings.Builder b.WriteString("struct { ") required := requiredSet(schema) - keys := sortedRawKeys(schema.Properties) + keys := sortedKeys(schema.Properties) for _, name := range keys { fieldOptional := !required[name] fmt.Fprintf(&b, "%s %s `json:%q`; ", exportedName(name), goType(schema.Properties[name], fieldOptional), jsonTag(name, fieldOptional)) @@ -239,7 +454,7 @@ func jsonTag(name string, optional bool) string { return name } -func sortedRawKeys(values map[string]json.RawMessage) []string { +func sortedKeys[V any](values map[string]V) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) @@ -252,27 +467,36 @@ func writeGo(path string, def definition) error { var b bytes.Buffer b.WriteString("// Code generated by protocolgen; DO NOT EDIT.\npackage generated\n\n") fmt.Fprintf(&b, "const SchemaVersion = %q\nconst JSONRPCVersion = %q\n\n", def.SchemaVersion, def.JSONRPC) - for _, name := range sortedRawKeys(def.Types) { + for _, name := range sortedKeys(def.Types) { schema := parseSchema(def.Types[name]) fmt.Fprintf(&b, "type %s struct {\n", name) required := requiredSet(schema) - for _, property := range sortedRawKeys(schema.Properties) { + for _, property := range sortedKeys(schema.Properties) { optional := !required[property] fmt.Fprintf(&b, "%s %s `json:%q`\n", exportedName(property), goType(schema.Properties[property], optional), jsonTag(property, optional)) } b.WriteString("}\n\n") } b.WriteString("type Method string\n\nconst (\n") - for _, name := range sortedMethods(def) { + for _, name := range sortedKeys(def.Methods) { fmt.Fprintf(&b, "Method%s Method = %q\n", exportedName(name), name) } b.WriteString(")\n\n") - b.WriteString("type MethodMetadata struct { Params, Result, Scope, Effect, Blocking string }\n\nvar Methods = map[string]MethodMetadata{\n") - for _, name := range sortedMethods(def) { + b.WriteString("type MethodMetadata struct { Params, Result, Scope, Effect, Blocking, Peer, MergeField string }\n\nvar Methods = map[string]MethodMetadata{\n") + for _, name := range sortedKeys(def.Methods) { m := def.Methods[name] - fmt.Fprintf(&b, "%q: {Params:%q, Result:%q, Scope:%q, Effect:%q, Blocking:%q},\n", name, m.Params, m.Result, m.Scope, m.Effect, m.Blocking) + fmt.Fprintf(&b, "%q: {Params:%q, Result:%q, Scope:%q, Effect:%q, Blocking:%q, Peer:%q, MergeField:%q},\n", name, m.Params, m.Result, m.Scope, m.Effect, m.Blocking, m.Peer, m.MergeField) + } + b.WriteString("}\n\nconst (\n") + for _, code := range def.ErrorCodes { + fmt.Fprintf(&b, "ErrorCode%s = %q\n", exportedName(strings.ToLower(code.Code)), code.Code) } - b.WriteString("}\n") + b.WriteString(")\n\n") + b.WriteString("// crypto.context 的键,用于查 protocol.Crypto.Context。\nconst (\n") + for _, key := range sortedKeys(def.CryptoContext) { + fmt.Fprintf(&b, "CryptoContext%s = %q\n", exportedName(key), key) + } + b.WriteString(")\n") formatted, err := format.Source(b.Bytes()) if err != nil { return err @@ -311,7 +535,7 @@ func tsType(raw json.RawMessage) string { required := requiredSet(schema) var b strings.Builder b.WriteString("{ ") - for _, name := range sortedRawKeys(schema.Properties) { + for _, name := range sortedKeys(schema.Properties) { optional := "" if !required[name] { optional = "?" @@ -325,7 +549,7 @@ func tsType(raw json.RawMessage) string { } } -func renderTS(def definition) []byte { +func renderTS(def peerContract) []byte { var b bytes.Buffer b.WriteString("// Code generated by protocolgen; DO NOT EDIT.\n") fmt.Fprintf(&b, "export const SCHEMA_VERSION = %s as const;\nexport const JSONRPC_VERSION = %s as const;\n", strconv.Quote(def.SchemaVersion), strconv.Quote(def.JSONRPC)) @@ -335,7 +559,7 @@ func renderTS(def definition) []byte { writeTSConst(&b, "CRYPTO", def.Crypto) writeTSConst(&b, "LIMITS", def.Limits) writeTSConst(&b, "PAIRING_CODE", def.PairingCode) - for _, name := range sortedRawKeys(def.Types) { + for _, name := range sortedKeys(def.Types) { schema := parseSchema(def.Types[name]) if len(schema.Properties) == 0 { fmt.Fprintf(&b, "export type %s = Record;\n", name) @@ -343,7 +567,7 @@ func renderTS(def definition) []byte { } fmt.Fprintf(&b, "export interface %s {\n", name) required := requiredSet(schema) - for _, property := range sortedRawKeys(schema.Properties) { + for _, property := range sortedKeys(schema.Properties) { optional := "" if !required[property] { optional = "?" @@ -353,13 +577,13 @@ func renderTS(def definition) []byte { b.WriteString("}\n") } b.WriteString("export interface RpcMethodMap {\n") - for _, name := range sortedMethods(def) { + for _, name := range sortedKeys(def.Methods) { m := def.Methods[name] fmt.Fprintf(&b, " %s: { params: %s; result: %s };\n", strconv.Quote(name), m.Params, m.Result) } b.WriteString("}\nexport type RpcMethod = keyof RpcMethodMap;\nexport type RpcParams = RpcMethodMap[M][\"params\"];\nexport type RpcResult = RpcMethodMap[M][\"result\"];\n") b.WriteString("export const RPC_METHODS = {\n") - for _, name := range sortedMethods(def) { + for _, name := range sortedKeys(def.Methods) { m := def.Methods[name] fmt.Fprintf(&b, " %s: {\n params: %s,\n result: %s,\n scope: %s,\n effect: %s,\n blocking: %s,\n },\n", strconv.Quote(name), strconv.Quote(m.Params), strconv.Quote(m.Result), strconv.Quote(m.Scope), strconv.Quote(m.Effect), strconv.Quote(m.Blocking)) } @@ -387,22 +611,33 @@ func mustJSON(value any) []byte { return raw } -func renderTSValidators(def definition) []byte { +var tsValidatorHelpers = []struct{ name, source string }{ + {"isRecord", "function isRecord(value: unknown): value is Record {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n"}, + {"hasOnlyKeys", "function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean {\n const allowedKeys = new Set(allowed);\n return Object.keys(value).every((key) => allowedKeys.has(key));\n}\n"}, + {"isUUID", "function isUUID(value: string): boolean {\n return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);\n}\n"}, +} + +func renderTSValidators(def peerContract) []byte { var b bytes.Buffer b.WriteString("// Code generated by protocolgen; DO NOT EDIT.\n") - b.WriteString("import type * as Protocol from \"./protocol.generated\";\n\n") - b.WriteString("function isRecord(value: unknown): value is Record {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n") - b.WriteString("function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean {\n const allowedKeys = new Set(allowed);\n return Object.keys(value).every((key) => allowedKeys.has(key));\n}\n\n") - b.WriteString("function isUUID(value: string): boolean {\n return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);\n}\n") - for _, name := range sortedRawKeys(def.Types) { - fmt.Fprintf(&b, "\nexport function validate%s(value: unknown): value is Protocol.%s {\n return %s;\n}\n", name, name, tsValidationExpression(def.Types[name], "value")) + b.WriteString("import type * as Protocol from \"./protocol.generated\";\n") + var validators strings.Builder + for _, name := range sortedKeys(def.Types) { + fmt.Fprintf(&validators, "\nexport function validate%s(value: unknown): value is Protocol.%s {\n return %s;\n}\n", name, name, tsValidationExpression(def.Types[name], "value")) + } + // 只输出被引用的辅助函数:未使用的本地函数会让开启 noUnusedLocals 的扩展工程类型检查失败。 + for _, helper := range tsValidatorHelpers { + if strings.Contains(validators.String(), helper.name+"(") { + b.WriteString("\n" + helper.source) + } } + b.WriteString(validators.String()) b.WriteString("\nexport const RPC_PARAM_VALIDATORS = {\n") - for _, name := range sortedMethods(def) { + for _, name := range sortedKeys(def.Methods) { fmt.Fprintf(&b, " %s: validate%s,\n", strconv.Quote(name), def.Methods[name].Params) } b.WriteString("} as const;\n\nexport const RPC_RESULT_VALIDATORS = {\n") - for _, name := range sortedMethods(def) { + for _, name := range sortedKeys(def.Methods) { fmt.Fprintf(&b, " %s: validate%s,\n", strconv.Quote(name), def.Methods[name].Result) } b.WriteString("} as const;\n") @@ -457,7 +692,7 @@ func tsValidationExpression(raw json.RawMessage, value string) string { case "object": parts := []string{"isRecord(" + value + ")"} if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { - keys := sortedRawKeys(schema.Properties) + keys := sortedKeys(schema.Properties) quoted := make([]string, 0, len(keys)) for _, key := range keys { quoted = append(quoted, strconv.Quote(key)) @@ -465,7 +700,7 @@ func tsValidationExpression(raw json.RawMessage, value string) string { parts = append(parts, "hasOnlyKeys("+value+", ["+strings.Join(quoted, ", ")+"])") } required := requiredSet(schema) - for _, property := range sortedRawKeys(schema.Properties) { + for _, property := range sortedKeys(schema.Properties) { propertyValue := value + "[" + strconv.Quote(property) + "]" check := tsValidationExpression(schema.Properties[property], propertyValue) if !required[property] { diff --git a/internal/protocolgen/generate_test.go b/internal/protocolgen/generate_test.go index 02bacf7..01ae041 100644 --- a/internal/protocolgen/generate_test.go +++ b/internal/protocolgen/generate_test.go @@ -1,6 +1,9 @@ package protocolgen_test import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" @@ -14,22 +17,29 @@ func TestGenerateProducesDeterministicGoAndTypeScriptContracts(t *testing.T) { root := filepath.Join("..", "pkg", "protocol") out := t.TempDir() - if err := protocolgen.Generate(root, out); err != nil { + browserOut := t.TempDir() + if err := protocolgen.Generate(root, out, browserOut); err != nil { t.Fatalf("generate protocol contracts: %v", err) } - for _, name := range []string{"protocol.generated.go", "protocol.generated.ts", "validators.generated.ts"} { - first, err := os.ReadFile(filepath.Join(out, name)) + for _, name := range []string{ + filepath.Join(out, "protocol.generated.go"), + filepath.Join(out, "protocol.generated.ts"), + filepath.Join(out, "validators.generated.ts"), + filepath.Join(browserOut, "protocol.generated.ts"), + filepath.Join(browserOut, "validators.generated.ts"), + } { + first, err := os.ReadFile(name) if err != nil { t.Fatalf("read generated %s: %v", name, err) } if len(first) == 0 { t.Fatalf("generated %s is empty", name) } - if err := protocolgen.Generate(root, out); err != nil { + if err := protocolgen.Generate(root, out, browserOut); err != nil { t.Fatalf("regenerate protocol contracts: %v", err) } - second, err := os.ReadFile(filepath.Join(out, name)) + second, err := os.ReadFile(name) if err != nil { t.Fatalf("read regenerated %s: %v", name, err) } @@ -59,7 +69,7 @@ func TestGenerateProducesStronglyTypedRPCContracts(t *testing.T) { t.Parallel() out := t.TempDir() - if err := protocolgen.Generate(filepath.Join("..", "pkg", "protocol"), out); err != nil { + if err := protocolgen.Generate(filepath.Join("..", "pkg", "protocol"), out, t.TempDir()); err != nil { t.Fatalf("generate protocol contracts: %v", err) } @@ -153,3 +163,178 @@ func TestGenerateProducesStronglyTypedRPCContracts(t *testing.T) { } } } + +// 这两个摘要是 ScriptCat 固定版本(CI 的 SCRIPTCAT_REF)所用生成文件的逐字节身份; +// 只有 ScriptCat 协议真的变化并同步到 ScriptCat 时才允许改动。 +const ( + scriptCatProtocolSHA256 = "df47fd341242836501b2497b665a406f98152ce1baa9d6955981e003527117e8" + scriptCatValidatorsSHA256 = "2b55453161a593907759c4be7d9a2caa09f71aed7155b3c840f93d33d04896bb" +) + +func TestGenerateKeepsScriptCatTypeScriptByteIdenticalWhenBrowserMethodsExist(t *testing.T) { + t.Parallel() + + out := t.TempDir() + if err := protocolgen.Generate(filepath.Join("..", "pkg", "protocol"), out, t.TempDir()); err != nil { + t.Fatalf("generate protocol contracts: %v", err) + } + for name, want := range map[string]string{ + "protocol.generated.ts": scriptCatProtocolSHA256, + "validators.generated.ts": scriptCatValidatorsSHA256, + } { + content := readFile(t, filepath.Join(out, name)) + for _, browserOnly := range []string{"tabs.list", "TabsListParams", "BROWSER_OFFLINE", "browserSessionExt"} { + if strings.Contains(content, browserOnly) { + t.Errorf("ScriptCat %s contains browser-owned %q", name, browserOnly) + } + } + sum := sha256.Sum256([]byte(content)) + if got := hex.EncodeToString(sum[:]); got != want { + t.Errorf("ScriptCat %s sha256 = %s, want the paired ScriptCat copy %s", name, got, want) + } + } +} + +func TestGenerateWritesBrowserTypeScriptWithOnlyBrowserContract(t *testing.T) { + t.Parallel() + + browserOut := t.TempDir() + if err := protocolgen.Generate(filepath.Join("..", "pkg", "protocol"), t.TempDir(), browserOut); err != nil { + t.Fatalf("generate protocol contracts: %v", err) + } + typescript := readFile(t, filepath.Join(browserOut, "protocol.generated.ts")) + for _, want := range []string{ + `"tabs.list": { params: TabsListParams; result: TabsListResult };`, + `"windows.list": { params: WindowsListParams; result: WindowsListResult };`, + `"BROWSER_OFFLINE"`, + `"NOT_FOUND"`, + `"browserSessionExt": "sctl-browser-rpc-v1/ext"`, + `"pairKdfSalt": "scriptcat-rpc-v1/pair-salt"`, + `export const SCHEMA_VERSION = "1.0.0" as const;`, + } { + if !strings.Contains(typescript, want) { + t.Errorf("browser TypeScript does not contain %q", want) + } + } + for _, unwanted := range []string{"scripts.list", "ScriptsListParams", `"USER_REJECTED"`, `"sessionExt"`, "unknown"} { + if strings.Contains(typescript, unwanted) { + t.Errorf("browser TypeScript contains ScriptCat-only or unresolved %q", unwanted) + } + } + + validators := readFile(t, filepath.Join(browserOut, "validators.generated.ts")) + for _, want := range []string{ + "export function validateTabsOpenParams", + `value["tabIds"].length >= 1`, + `"tabs.close": validateTabsCloseParams,`, + } { + if !strings.Contains(validators, want) { + t.Errorf("browser validators do not contain %q", want) + } + } + // 浏览器扩展开启 noUnusedLocals 时,未被引用的辅助函数会让类型检查失败。 + for _, unwanted := range []string{"validateScriptsToggleParams", "isUUID"} { + if strings.Contains(validators, unwanted) { + t.Errorf("browser validators contain unused ScriptCat code %q", unwanted) + } + } +} + +func TestGenerateGoBindingsCarryEveryPeer(t *testing.T) { + t.Parallel() + + out := t.TempDir() + if err := protocolgen.Generate(filepath.Join("..", "pkg", "protocol"), out, t.TempDir()); err != nil { + t.Fatalf("generate protocol contracts: %v", err) + } + goSource := readFile(t, filepath.Join(out, "protocol.generated.go")) + for _, want := range []string{ + `MethodScriptsList `, + `MethodTabsList `, + `Peer: "browser", MergeField: "tabs"`, + `Peer: "scriptcat", MergeField: ""`, + `ErrorCodeInvalidRequest `, + `ErrorCodeBrowserOffline `, + `= "BROWSER_OFFLINE"`, + `CryptoContextSessionExt `, + `CryptoContextBrowserPairDaemon `, + `= "browserPairDaemon"`, + } { + if !strings.Contains(goSource, want) { + t.Errorf("generated Go does not contain %q", want) + } + } +} + +func TestGenerateRejectsInvalidPeerAnnotations(t *testing.T) { + t.Parallel() + + for name, mutate := range map[string]func(def map[string]any){ + "unchanged definition": func(map[string]any) {}, + "method with unknown peer": func(def map[string]any) { + method(def, "tabs.list")["peer"] = "firefox" + }, + "method without peer": func(def map[string]any) { + delete(method(def, "scripts.list"), "peer") + }, + "merge field that is not an array": func(def map[string]any) { + method(def, "tabs.list")["mergeField"] = "contentTrust" + }, + "merge field missing from result": func(def map[string]any) { + method(def, "windows.list")["mergeField"] = "tabs" + }, + "error code without peers": func(def map[string]any) { + def["errorCodes"].([]any)[0].(map[string]any)["peers"] = []any{} + }, + "error code with unknown peer": func(def map[string]any) { + def["errorCodes"].([]any)[0].(map[string]any)["peers"] = []any{"firefox"} + }, + "handshake context without peers": func(def map[string]any) { + context := def["crypto"].(map[string]any)["context"].(map[string]any) + context["sessionExt"].(map[string]any)["peers"] = []any{} + }, + "type that no method uses": func(def map[string]any) { + def["types"].(map[string]any)["Orphan"] = map[string]any{"type": "object", "properties": map[string]any{}} + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + var def map[string]any + if err := json.Unmarshal([]byte(readFile(t, filepath.Join("..", "pkg", "protocol", "protocol.json"))), &def); err != nil { + t.Fatalf("parse protocol.json: %v", err) + } + mutate(def) + raw, err := json.Marshal(def) + if err != nil { + t.Fatalf("encode definition: %v", err) + } + schemaDir := t.TempDir() + if err := os.WriteFile(filepath.Join(schemaDir, "protocol.json"), raw, 0o600); err != nil { + t.Fatalf("write definition: %v", err) + } + err = protocolgen.Generate(schemaDir, t.TempDir(), t.TempDir()) + if name == "unchanged definition" { + if err != nil { + t.Fatalf("valid definition rejected: %v", err) + } + return + } + if err == nil { + t.Fatal("invalid definition was accepted") + } + }) + } +} + +func method(def map[string]any, name string) map[string]any { + return def["methods"].(map[string]any)[name].(map[string]any) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) +} From 0fe1702697a40ab4e35adf41629fa435b8a87d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:16:23 +0800 Subject: [PATCH 04/25] feat: hold ScriptCat and several browser instances in the daemon Browser instances authenticate with their own per-instance key, bound to peer kind and instance ID in the handshake MAC, and are recorded in a 0600 registry (browsers.json) written through fsutil.WriteFileAtomic. Names are unique across paired instances; a taken name is answered with CONFLICT on $session.capabilities. Connections are registered before the capabilities reply, fixing the race where an immediate Call got ErrNotConnected. ScriptCat keeps pairing.key and its one-connection-replaces-the-previous behaviour. --- docs/protocol.md | 55 ++- docs/threat-model.md | 31 +- internal/daemon/auth/crypto.go | 38 ++ internal/daemon/auth/crypto_test.go | 47 +++ internal/daemon/bridge/browsers_test.go | 463 ++++++++++++++++++++++++ internal/daemon/bridge/call.go | 12 +- internal/daemon/bridge/conn.go | 125 ++++++- internal/daemon/bridge/envelope.go | 39 +- internal/daemon/bridge/instances.go | 158 ++++++++ internal/daemon/bridge/server.go | 112 ++++-- internal/daemon/bridge/server_test.go | 102 ++++-- internal/daemon/component.go | 7 +- internal/daemon/store/browsers.go | 197 ++++++++++ internal/daemon/store/browsers_test.go | 128 +++++++ internal/pkg/audit/audit.go | 2 + internal/pkg/paths/paths.go | 5 + 16 files changed, 1427 insertions(+), 94 deletions(-) create mode 100644 internal/daemon/bridge/browsers_test.go create mode 100644 internal/daemon/bridge/instances.go create mode 100644 internal/daemon/store/browsers.go create mode 100644 internal/daemon/store/browsers_test.go diff --git a/docs/protocol.md b/docs/protocol.md index 9fad449..f3ec4d6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -7,7 +7,8 @@ express. See [threat-model.md](./threat-model.md) for the security boundary. ## 1. Transport and message model -The ScriptCat extension opens one WebSocket connection to the sctl daemon. Every text frame is exactly one +Each peer extension — ScriptCat, and every paired sctl Browser instance — opens its own WebSocket connection to +the sctl daemon. Every text frame is exactly one JSON-RPC 2.0 message and includes `"jsonrpc": "2.0"`. Requests and responses correlate through `id`; notifications omit `id`. Batch requests are not supported. @@ -86,6 +87,30 @@ within `limits.authTimeoutMs`. Nonces are fresh for every connection. The extension stores the session key in extension-local storage. The daemon stores its copy in a user-only file. Disabling External Access deletes the extension copy and closes the connection, requiring enrollment again. +#### Browser instances + +An sctl Browser instance identifies itself in the authentication response; a response without `peer` is +ScriptCat. The instance ID is 32 lowercase hex digits, generated randomly when the extension is installed: + +```json +{ + "mode": "session", + "nonceE": "", + "hmac": "HMAC(K_instance, context.browserSessionExt || instanceId || nonceD || nonceE)", + "peer": { "kind": "browser", "instanceId": "<32 lowercase hex>" } +} +``` + +The daemon answers with `HMAC(K_instance, context.browserSessionDaemon || instanceId || nonceE || nonceD)`. +Pairing uses `context.browserPairExt` / `context.browserPairDaemon` with the same `instanceId` term, keyed by the +pairing-code MAC key; key derivation and delivery are the same as for ScriptCat. The `browser*` contexts bind the +peer kind and the `instanceId` term binds the instance, so a MAC recorded for one instance or for ScriptCat never +verifies as another. An unknown `kind` or a malformed `instanceId` fails the handshake. + +Each instance has its own session key, issued by its pairing; pairing a browser never touches ScriptCat's key. +The daemon selects the key by `instanceId`, so a session handshake from an instance that is not paired — or that +has been forgotten on the daemon side — fails like any other handshake failure. + ### 2.2 Hello and capabilities After authentication the daemon announces its product version for diagnostics. The extension does not use it @@ -113,7 +138,33 @@ The extension then declares the generated schema it uses and the business method } ``` -The daemon answers with an empty result and marks the connection usable only after accepting this request. +The daemon registers the connection before it answers with an empty result, so business requests may follow that +result immediately. A connection is usable only after this request is accepted. +A new ScriptCat connection replaces the previous ScriptCat connection; a browser instance's new connection +replaces only that instance's previous connection. + +A browser instance also declares its name and self-reported product details: + +```json +"params": { + "schemaVersion": "1.0.0", + "methods": ["tabs.list", "tabs.open"], + "peer": { + "name": "chrome-3f2a", + "product": "Chrome", + "productVersion": "129.0.6668.58", + "extensionVersion": "0.1.0" + } +} +``` + +`name` is 1–32 lowercase letters, digits, and `-`; the other fields are optional printable text of at most 64 +bytes. A missing or invalid `peer` closes the connection. On its first pairing an instance proposes its default +name, the browser brand followed by `-` and the first four hex digits of its instance ID; renaming means +reconnecting with the new name. The daemon's registry is authoritative: names are unique across every paired +instance, online or offline. When another paired instance holds the name, the daemon answers the capabilities +request with application error `CONFLICT`, keeps the instance's previous name, and closes the connection; a +first pairing refused this way is not persisted. ### 2.3 Liveness and shutdown diff --git a/docs/threat-model.md b/docs/threat-model.md index d82c19e..b0fa8cb 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -11,11 +11,13 @@ sctl exposes a WebSocket listener protected by a mutual authentication handshake permissions and a Native Messaging host installer while preserving single-binary distribution. **There are only two trust anchors:** -- the **long-term shared key K** between the extension and the daemon, established once by **enrollment**: the - daemon prints a one-time code in the terminal (never over the wire), the user types it into the extension's - 「外部接入 / External Access」 page, and K is derived and delivered under that code. Trust is **flat** — after - enrollment the CLI and every MCP agent inherit K through the extension ↔ daemon channel and never enroll - again; there is no per-client pairing, token, scope, or revocation. +- the **long-term shared keys** between each peer extension and the daemon, each established once by + **enrollment**: the daemon prints a one-time code in the terminal (never over the wire), the user types it into + the extension (for ScriptCat, its 「外部接入 / External Access」 page), and the key is derived and delivered + under that code. ScriptCat holds one key K; every sctl Browser instance holds its own + instance key, so pairing, renaming, or forgetting a browser never affects ScriptCat's K. Trust is **flat** — + after enrollment the CLI and every MCP agent inherit trust through the extension ↔ daemon channels and never + enroll again; there is no per-client pairing, token, scope, or revocation. - the **control token** between the local frontend (`sctl mcp` / CLI verbs) and the daemon (written to a user-only file once the daemon has bound its port: POSIX mode 0600 or a protected current-user DACL on Windows). @@ -32,7 +34,7 @@ press approve on the extension's confirmation page (unless the corresponding glo |---|---|---| | A web page connects straight to the daemon with `new WebSocket("ws://127.0.0.1:8643")` | An **Origin whitelist** rejects any connection whose `Origin` is present and not an extension origin (`chrome-extension://` etc.) — a cheap pre-filter that a browser page cannot get past (the browser stamps Origin, page JS cannot forge it). Beyond that, a connection must complete the mutual HMAC handshake before it can send or receive any business message; without credentials it fails the challenge-response and is disconnected on the 5s timeout **with no reason echoed back** (close 1008). A non-browser process can forge any Origin, so the handshake remains the real gate. Both rejections are recorded in the daemon-side audit (§6) | A page can probe that the port is open | | A web page impersonates the local frontend with `fetch("http://127.0.0.1:8643/control/…")` | Apart from `/control/health`, every control API requires an `X-Sctl-Control-Token` header, compared in constant time against the daemon's user-only token; a web page cannot read that file, so it gets a 401 and the action never runs at all | Port / health information can be probed (see below) | -| A local process grabs 8643 to impersonate the daemon, or connects in while impersonating the extension | **Mutual** HMAC-SHA-256 challenge-response between the extension and the daemon ([protocol.md](./protocol.md#21-authentication)); the long-term key K comes from a one-time enrollment code and never travels in plaintext; nonces are regenerated per connection, so replays are useless | See the "malicious same-user process" row | +| A local process grabs 8643 to impersonate the daemon, or connects in while impersonating the extension | **Mutual** HMAC-SHA-256 challenge-response between the extension and the daemon ([protocol.md](./protocol.md#21-authentication)); long-term keys come from a one-time enrollment code and never travel in plaintext; nonces are regenerated per connection, so replays are useless. A browser instance's MAC also binds its peer kind and instance ID, so a recorded MAC cannot be replayed as ScriptCat or as another instance | See the "malicious same-user process" row | | A process that reaches the daemon requests a privileged action | Flat trust deliberately grants any control-token holder full read/list and the ability to *request* writes; the gate is not per-client authorization but the **per-operation human gate**: writes need browser approval and source reads need disclosure approval, both keyed by script (extension session). There is no per-client scope or request-frequency limit | Any process that obtains the control token has the same capabilities; write requests remain browser-gated unless always-allow is enabled | | Write operations are abused (installing a malicious script / bulk deletion) | Two-phase confirmation plus a TOCTOU re-check at the moment of approval (staged `contentHash`, target `existingCodeHash`); calls are purely blocking, so a requester disconnect voids them. The install page's own enable toggle decides the enabled state (installs are usable immediately, like a normal install). "Always-allow" is an explicit security-downgrade switch (amber warning in the UI) | Under "always-allow" a write is no longer confirmed by a human — the user takes that risk | | Source code leaks | Source disclosure is gated by its own **source-read policy** (approval by default), applied to the CLI and MCP alike — the CLI is **not** exempt; reading is a privacy matter and is not covered by the write policy. Script-controlled text is always returned as structured data (`contentTrust: untrusted-user-script-source`) and must never be concatenated into a tool description | Under a "always-allow" source-read policy, reads are no longer confirmed — the user takes that risk | @@ -41,14 +43,15 @@ press approve on the extension's confirmation page (unless the corresponding glo ## 3. Explicit non-goals -- **A malicious local process with the user's full privileges**: it can read `pairing.key` / `control.token` - (both 0600) and can ptrace this user's processes. No purely local scheme can stop it; browser-side human +- **A malicious local process with the user's full privileges**: it can read `pairing.key` / `browsers.json` / + `control.token` (all 0600) and can ptrace this user's processes. No purely local scheme can stop it; browser-side human approval of write operations is the only mitigation still in effect (unless the user turned on "always-allow"). - **Per-client isolation**: flat trust intentionally drops per-agent tokens, scopes, and individual revocation. Any same-user process that holds the control token has the same capabilities (full read/list, - request writes). Revocation collapses to the global kill switch (discard K) or removing the server from an - agent's own MCP config. This is the deliberate trade for single-enrollment simplicity; the per-operation + request writes). Revocation collapses to per-peer switches — discarding ScriptCat's K, or forgetting a browser + instance on the daemon, which deletes its key and registry entry, disconnects it, and makes its next handshake + fail — or removing the server from an agent's own MCP config. This is the deliberate trade for single-enrollment simplicity; the per-operation human gates (write approval, source disclosure) are what still bound damage. - **`clientId` as an authorization input**: the label a request carries (`sctl-cli`, `scriptcat-`, or an MCP client's self-reported `clientInfo.name`) is unauthenticated and forgeable. It is used only for audit @@ -83,7 +86,8 @@ on the daemon's listener (same port as the extension WS surface, separate path). | File | Access | Contents | Impact if leaked | |---|---|---|---| -| `/pairing.key` | POSIX 0600; protected current-user DACL on Windows | The extension's long-term key K (hex), established by enrollment | Allows impersonating the extension when connecting to the daemon; still bound by write approval | +| `/pairing.key` | POSIX 0600; protected current-user DACL on Windows | ScriptCat's long-term key K (hex), established by enrollment | Allows impersonating ScriptCat when connecting to the daemon; still bound by write approval | +| `/browsers.json` | POSIX 0600; protected current-user DACL on Windows | The registry of paired sctl Browser instances: instance ID, unique name, each instance's long-term key (hex), and last self-reported product and versions | Allows impersonating any paired browser instance when connecting to the daemon | | `/control.token` | POSIX 0600; protected current-user DACL on Windows | The local control-channel token (regenerated on every serve start) | Allows impersonating the local frontend to call the control API; writes still need browser approval | Three iron rules: **a token's plaintext never goes over the wire, never enters a log, never enters a URL**; @@ -102,11 +106,14 @@ extension session was ever established and therefore leave no extension-side rec | Event | Trigger | |---|---| | `origin.rejected` | A WS connection was rejected by the Origin whitelist (present, non-extension Origin) | -| `handshake.failed` | Handshake HMAC verification failed / the 5s timeout elapsed / an invalid response to `$session.authenticate` was sent | +| `handshake.failed` | Handshake HMAC verification failed / the 5s timeout elapsed / an invalid response to `$session.authenticate` was sent / a browser instance that is not paired (or was forgotten) tried a session handshake | | `pairing.failed` | The enrollment handshake HMAC failed, or there was no valid enrollment code | | `pairing.rate_limited` | Enrollment attempts exceeded 5 per minute | | `handshake.ok` | A session was established | +Events for a connection that declared a well-formed browser instance identity carry `client: browser:`; +ScriptCat events leave `client` empty. + To view: `sctl status` prints one summary line aggregated by type, and `sctl status -o json` outputs the full events. diff --git a/internal/daemon/auth/crypto.go b/internal/daemon/auth/crypto.go index e364d76..2ed703b 100644 --- a/internal/daemon/auth/crypto.go +++ b/internal/daemon/auth/crypto.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/scriptscat/sctl/internal/pkg/protocol" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" ) // Mode 区分握手所用的上下文与密钥来源。 @@ -74,6 +75,43 @@ func (c *Crypto) DaemonHMAC(mode Mode, key []byte, nonceD, nonceE string) string return handshakeHMAC(key, c.daemonContext(mode), nonceE, nonceD) } +func (c *Crypto) browserExtContext(mode Mode) string { + if mode == ModePairing { + return c.ctx[generated.CryptoContextBrowserPairExt] + } + return c.ctx[generated.CryptoContextBrowserSessionExt] +} + +func (c *Crypto) browserDaemonContext(mode Mode) string { + if mode == ModePairing { + return c.ctx[generated.CryptoContextBrowserPairDaemon] + } + return c.ctx[generated.CryptoContextBrowserSessionDaemon] +} + +// BrowserExtHMAC 计算浏览器实例对 $session.authenticate 的应答 HMAC: +// context=browser*Ext(区分对端类型)|| instanceID || nonceD || nonceE。instanceID 与 nonce 都是定长 +// 小写 hex(由 bridge 在边界校验),拼接无歧义;绑定实例 ID 使截获的 MAC 无法冒充另一实例重放。 +func (c *Crypto) BrowserExtHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE string) string { + return handshakeHMAC(key, c.browserExtContext(mode)+instanceID, nonceD, nonceE) +} + +// BrowserDaemonHMAC 计算 daemon 对浏览器实例的 $session.authenticated HMAC: +// context=browser*Daemon || instanceID || nonceE || nonceD。 +func (c *Crypto) BrowserDaemonHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE string) string { + return handshakeHMAC(key, c.browserDaemonContext(mode)+instanceID, nonceE, nonceD) +} + +// VerifyBrowserExtHMAC 恒定时间校验浏览器实例应答的 HMAC。 +func (c *Crypto) VerifyBrowserExtHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE, got string) bool { + return constantTimeHexEqual(c.BrowserExtHMAC(mode, instanceID, key, nonceD, nonceE), got) +} + +// VerifyBrowserDaemonHMAC 恒定时间校验 daemon 对浏览器实例的 HMAC(供扩展/测试使用)。 +func (c *Crypto) VerifyBrowserDaemonHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE, got string) bool { + return constantTimeHexEqual(c.BrowserDaemonHMAC(mode, instanceID, key, nonceD, nonceE), got) +} + // VerifyExtHMAC 恒定时间校验扩展应答的 HMAC。 func (c *Crypto) VerifyExtHMAC(mode Mode, key []byte, nonceD, nonceE, got string) bool { return constantTimeHexEqual(c.ExtHMAC(mode, key, nonceD, nonceE), got) diff --git a/internal/daemon/auth/crypto_test.go b/internal/daemon/auth/crypto_test.go index 02701de..3a80983 100644 --- a/internal/daemon/auth/crypto_test.go +++ b/internal/daemon/auth/crypto_test.go @@ -1,7 +1,9 @@ package auth import ( + "crypto/hmac" "crypto/rand" + "crypto/sha256" "encoding/hex" "strings" "testing" @@ -67,6 +69,51 @@ func TestSessionHandshake(t *testing.T) { }) } +func TestBrowserHandshakeBindsInstanceIdentity(t *testing.T) { + p, err := protocol.Load() + if err != nil { + t.Fatalf("加载协议失败: %v", err) + } + cfg := NewCrypto(p) + + Convey("浏览器实例握手 MAC 绑定对端类型与实例 ID", t, func() { + key := make([]byte, 32) + _, _ = rand.Read(key) + nonceD, _ := RandomNonceHex(p.Crypto.NonceBytes) + nonceE, _ := RandomNonceHex(p.Crypto.NonceBytes) + const idA = "0123456789abcdef0123456789abcdef" + const idB = "fedcba9876543210fedcba9876543210" + + Convey("两个方向的 MAC 按线上公式计算:浏览器专用 context || instanceId || 两个 nonce", func() { + want := func(context, first, second string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(context + idA + first + second)) + return hex.EncodeToString(mac.Sum(nil)) + } + So(cfg.BrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE), ShouldEqual, want("sctl-browser-rpc-v1/ext", nonceD, nonceE)) + So(cfg.BrowserDaemonHMAC(ModeSession, idA, key, nonceD, nonceE), ShouldEqual, want("sctl-browser-rpc-v1/daemon", nonceE, nonceD)) + So(cfg.BrowserExtHMAC(ModePairing, idA, key, nonceD, nonceE), ShouldEqual, want("sctl-browser-rpc-v1/pair-ext", nonceD, nonceE)) + So(cfg.BrowserDaemonHMAC(ModePairing, idA, key, nonceD, nonceE), ShouldEqual, want("sctl-browser-rpc-v1/pair-daemon", nonceE, nonceD)) + }) + + Convey("为实例 A 计算的 MAC 不能冒充实例 B 通过校验", func() { + macA := cfg.BrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyBrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE, macA), ShouldBeTrue) + So(cfg.VerifyBrowserExtHMAC(ModeSession, idB, key, nonceD, nonceE, macA), ShouldBeFalse) + okA := cfg.BrowserDaemonHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyBrowserDaemonHMAC(ModeSession, idA, key, nonceD, nonceE, okA), ShouldBeTrue) + So(cfg.VerifyBrowserDaemonHMAC(ModeSession, idB, key, nonceD, nonceE, okA), ShouldBeFalse) + }) + + Convey("ScriptCat 的 MAC 与浏览器实例的 MAC 互不通用", func() { + scriptCatMAC := cfg.ExtHMAC(ModeSession, key, nonceD, nonceE) + So(cfg.VerifyBrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE, scriptCatMAC), ShouldBeFalse) + browserMAC := cfg.BrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyExtHMAC(ModeSession, key, nonceD, nonceE, browserMAC), ShouldBeFalse) + }) + }) +} + func TestPairingKeyDelivery(t *testing.T) { p, _ := protocol.Load() cfg := NewCrypto(p) diff --git a/internal/daemon/bridge/browsers_test.go b/internal/daemon/bridge/browsers_test.go new file mode 100644 index 0000000..6b54934 --- /dev/null +++ b/internal/daemon/bridge/browsers_test.go @@ -0,0 +1,463 @@ +package bridge + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/google/uuid" + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/daemon/auth" + "github.com/scriptscat/sctl/internal/daemon/store" + "github.com/scriptscat/sctl/internal/pkg/audit" +) + +const ( + instanceA = "0123456789abcdef0123456789abcdef" + instanceB = "fedcba9876543210fedcba9876543210" +) + +func browserPeer(id string) *authPeer { + return &authPeer{Kind: peerKindBrowser, InstanceID: id} +} + +func browserCapabilities(name string) capabilitiesParams { + return capabilitiesParams{ + SchemaVersion: "1.0.0", + Methods: []string{"tabs.list", "tabs.open", "tabs.close", "tabs.activate", "windows.list"}, + Peer: &capabilitiesPeer{Name: name, Product: "Chrome", ProductVersion: "129.0", ExtensionVersion: "0.1.0"}, + } +} + +// challenge 拨号并读取 $session.authenticate 的 nonceD。 +func (h *testHarness) challenge() (*extClient, Message, string) { + e := dial(h.url) + msg := e.read() + So(msg.Method, ShouldEqual, methodAuthenticate) + var cp authChallengeParams + So(json.Unmarshal(msg.Params, &cp), ShouldBeNil) + return e, msg, cp.NonceD +} + +// authenticateBrowser 以实例密钥完成浏览器实例的会话认证并消费 hello;能力声明留给调用方。 +func (h *testHarness) authenticateBrowser(id string, key []byte) *extClient { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModeSession, id, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: browserPeer(id)}) + + ok := e.read() + So(ok.Method, ShouldEqual, methodAuthenticated) + var okp authenticatedParams + So(json.Unmarshal(ok.Params, &okp), ShouldBeNil) + So(h.crypto.VerifyBrowserDaemonHMAC(auth.ModeSession, id, key, nonceD, nonceE, okp.HMAC), ShouldBeTrue) + So(e.read().Method, ShouldEqual, methodHello) + return e +} + +// declare 发出能力声明并返回 daemon 的回复。 +func (e *extClient) declare(params capabilitiesParams) Message { + id := uuid.NewString() + e.writeRequest(methodCapabilities, id, params) + reply := e.read() + So(reply.ID, ShouldEqual, id) + return reply +} + +// connectBrowser 以实例密钥和名称连接一个已配对的浏览器实例,要求 daemon 接受。 +func (h *testHarness) connectBrowser(id string, key []byte, name string) *extClient { + e := h.authenticateBrowser(id, key) + So(e.declare(browserCapabilities(name)).Error, ShouldBeNil) + return e +} + +// pairBrowser 用 sctl connect 打开的配对码为实例 id 完成首次配对,以 name 声明能力,返回连接与下发的实例密钥。 +func (h *testHarness) pairBrowser(id, name string) (*extClient, []byte) { + display, err := h.srv.BeginEnrollment() + So(err, ShouldBeNil) + kpMac, kpEnc, err := h.crypto.DerivePairingKeys(display) + So(err, ShouldBeNil) + + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModePairing, id, kpMac, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modePairing, NonceE: nonceE, HMAC: mac, Peer: browserPeer(id)}) + + ok := e.read() + So(ok.Method, ShouldEqual, methodAuthenticated) + var okp authenticatedParams + So(json.Unmarshal(ok.Params, &okp), ShouldBeNil) + So(okp.Key, ShouldNotBeNil) + So(h.crypto.VerifyBrowserDaemonHMAC(auth.ModePairing, id, kpMac, nonceD, nonceE, okp.HMAC), ShouldBeTrue) + key, err := h.crypto.OpenKey(kpEnc, okp.Key.Ciphertext, okp.Key.IV) + So(err, ShouldBeNil) + So(e.read().Method, ShouldEqual, methodHello) + So(e.declare(browserCapabilities(name)).Error, ShouldBeNil) + return e, key +} + +// registerBrowser 直接在登记表里放一个已配对实例,返回其密钥。 +func (h *testHarness) registerBrowser(id, name string) []byte { + key, _ := auth.NewLongTermKey() + So(h.browsers.Pair(store.BrowserInstance{ID: id, Name: name, Key: key}), ShouldBeNil) + return key +} + +// connectScriptCat 以 pairing.key 中的密钥接入 ScriptCat。 +func (h *testHarness) connectScriptCat() *extClient { + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + return h.doSessionHandshake(key) +} + +// scriptsListWorks 经 Call 发起 scripts.list,由 ScriptCat 连接应答,断言调用成功。 +func (h *testHarness) scriptsListWorks(sc *extClient) { + result := make(chan error, 1) + go func() { + resp, err := h.srv.Call(context.Background(), Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + if err == nil && !resp.OK { + err = resp.Error + } + result <- err + }() + req := sc.read() + So(req.Method, ShouldEqual, "scripts.list") + sc.writeResult(req.ID, json.RawMessage(`{"scripts":[],"contentTrust":"untrusted-user-script-metadata"}`)) + So(<-result, ShouldBeNil) +} + +// alive 用对端发起的 $session.ping 证明连接仍在服务。 +func (e *extClient) alive() { + id := uuid.NewString() + e.writeRequest(methodPing, id, struct{}{}) + So(e.read().ID, ShouldEqual, id) +} + +// closedByDaemon 断言 daemon 已关闭这条连接。 +func (e *extClient) closedByDaemon() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _, err := e.ws.Read(ctx) + So(err, ShouldNotBeNil) +} + +func (h *testHarness) instance(ref string) (InstanceInfo, bool) { + for _, info := range h.srv.Instances() { + if info.ID == ref || info.Name == ref { + return info, true + } + } + return InstanceInfo{}, false +} + +func TestBrowserPairing(t *testing.T) { + Convey("配对浏览器实例签发实例密钥并以默认名称登记,ScriptCat 的密钥与连接不受影响", t, func() { + h := startTestServer(t) + sc := h.connectScriptCat() + pairingKeyBefore, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + + e, key := h.pairBrowser(instanceA, "chrome-0123") + + registered, ok := h.browsers.Get(instanceA) + So(ok, ShouldBeTrue) + So(registered.Name, ShouldEqual, "chrome-0123") + So(registered.Key, ShouldResemble, key) + So(registered.Product, ShouldEqual, "Chrome") + + pairingKeyAfter, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + So(pairingKeyAfter, ShouldResemble, pairingKeyBefore) + So(h.srv.ExtConnected(), ShouldBeTrue) + h.scriptsListWorks(sc) + + info, ok := h.instance(instanceA) + So(ok, ShouldBeTrue) + So(info.Online, ShouldBeTrue) + So(info.Name, ShouldEqual, "chrome-0123") + So(info.ProductVersion, ShouldEqual, "129.0") + So(info.ExtensionVersion, ShouldEqual, "0.1.0") + So(info.ConnectedAt.IsZero(), ShouldBeFalse) + + Convey("下发的实例密钥可用于之后的会话握手", func() { + e.ws.CloseNow() + again := h.connectBrowser(instanceA, key, "chrome-0123") + again.alive() + }) + }) +} + +func TestBrowserInstancesCoexist(t *testing.T) { + Convey("ScriptCat 与两个浏览器实例同时在线;同一实例重连只替换它自己", t, func() { + h := startTestServer(t) + sc := h.connectScriptCat() + keyA := h.registerBrowser(instanceA, "chrome-0123") + keyB := h.registerBrowser(instanceB, "edge-fedc") + a1 := h.connectBrowser(instanceA, keyA, "chrome-0123") + b := h.connectBrowser(instanceB, keyB, "edge-fedc") + + infoA, _ := h.instance(instanceA) + infoB, _ := h.instance(instanceB) + So(infoA.Online, ShouldBeTrue) + So(infoB.Online, ShouldBeTrue) + So(h.srv.ExtConnected(), ShouldBeTrue) + h.scriptsListWorks(sc) + a1.alive() + b.alive() + + a2 := h.connectBrowser(instanceA, keyA, "chrome-0123") + a1.closedByDaemon() + a2.alive() + b.alive() + h.scriptsListWorks(sc) + So(h.srv.Instances(), ShouldHaveLength, 2) + + Convey("新的 ScriptCat 连接只替换旧的 ScriptCat 连接", func() { + key, _, err := h.keys.Load() + So(err, ShouldBeNil) + sc2 := h.doSessionHandshake(key) + sc.closedByDaemon() + a2.alive() + b.alive() + h.scriptsListWorks(sc2) + }) + + Convey("浏览器实例断开后仍列为已配对但离线", func() { + b.ws.CloseNow() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if info, _ := h.instance(instanceB); !info.Online { + break + } + time.Sleep(10 * time.Millisecond) + } + info, ok := h.instance(instanceB) + So(ok, ShouldBeTrue) + So(info.Online, ShouldBeFalse) + So(info.ConnectedAt.IsZero(), ShouldBeTrue) + }) + }) +} + +func TestBrowserIsRegisteredBeforeCapabilitiesReply(t *testing.T) { + Convey("浏览器实例收到 capabilities 回复时已登记为在线", t, func() { + h := startTestServer(t) + key := h.registerBrowser(instanceA, "chrome-0123") + e := h.authenticateBrowser(instanceA, key) + + early, reply := h.declareWithRegistrationFrozen(e, browserCapabilities("chrome-0123"), + func() bool { return h.srv.online[instanceA] != nil }) + So(early, ShouldBeFalse) + So(reply.Error, ShouldBeNil) + info, _ := h.instance(instanceA) + So(info.Online, ShouldBeTrue) + }) +} + +func TestBrowserResponsesCannotAnswerScriptCatCalls(t *testing.T) { + Convey("浏览器实例不能冒名应答发给 ScriptCat 的调用", t, func() { + h := startTestServer(t) + sc := h.connectScriptCat() + keyA := h.registerBrowser(instanceA, "chrome-0123") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + + result := make(chan Response, 1) + go func() { + resp, _ := h.srv.Call(context.Background(), Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + result <- resp + }() + req := sc.read() + a.writeResult(req.ID, json.RawMessage(`{"scripts":[{"forged":true}],"contentTrust":"untrusted-user-script-metadata"}`)) + a.alive() + sc.writeResult(req.ID, json.RawMessage(`{"scripts":[],"contentTrust":"untrusted-user-script-metadata"}`)) + So(string((<-result).Result), ShouldEqual, `{"scripts":[],"contentTrust":"untrusted-user-script-metadata"}`) + }) +} + +func TestBrowserHandshakeBindsInstanceIdentity(t *testing.T) { + Convey("握手 MAC 绑定对端类型与实例 ID", t, func() { + h := startTestServer(t) + // 两个实例刻意共用同一把密钥:只有身份参与 MAC 计算时,冒充才会失败。 + key := h.registerBrowser(instanceA, "chrome-0123") + So(h.browsers.Pair(store.BrowserInstance{ID: instanceB, Name: "edge-fedc", Key: key}), ShouldBeNil) + + Convey("为实例 A 计算的 MAC 声明为实例 B 时握手失败并进入审计", func() { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + macA := h.crypto.BrowserExtHMAC(auth.ModeSession, instanceA, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: macA, Peer: browserPeer(instanceB)}) + e.closedByDaemon() + + ev := waitForAuditEvent(h, audit.TypeHandshakeFailed) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + So(ev.Client, ShouldEqual, "browser:"+instanceB) + info, _ := h.instance(instanceB) + So(info.Online, ShouldBeFalse) + }) + + Convey("ScriptCat 公式的 MAC 不能以浏览器实例身份通过", func() { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.ExtHMAC(auth.ModeSession, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: browserPeer(instanceA)}) + e.closedByDaemon() + }) + + Convey("浏览器实例的 MAC 去掉身份声明后不能以 ScriptCat 身份通过", func() { + So(h.keys.Save(key), ShouldBeNil) + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModeSession, instanceA, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac}) + e.closedByDaemon() + So(h.srv.ExtConnected(), ShouldBeFalse) + }) + }) +} + +func TestBrowserRename(t *testing.T) { + Convey("重命名通过以新名称重连完成,名称在已配对实例中唯一", t, func() { + h := startTestServer(t) + keyA := h.registerBrowser(instanceA, "chrome-0123") + h.registerBrowser(instanceB, "edge-fedc") + + Convey("新名称未被占用:登记表更新", func() { + a := h.connectBrowser(instanceA, keyA, "work") + a.alive() + got, _ := h.browsers.Get(instanceA) + So(got.Name, ShouldEqual, "work") + info, _ := h.instance("work") + So(info.ID, ShouldEqual, instanceA) + }) + + Convey("名称被另一个已配对(即便离线)的实例占用:回复 CONFLICT、断开并保留原名称", func() { + e := h.authenticateBrowser(instanceA, keyA) + reply := e.declare(browserCapabilities("edge-fedc")) + So(reply.Error, ShouldNotBeNil) + So(reply.Error.Code, ShouldEqual, -32000) + So(reply.Error.Data, ShouldNotBeNil) + So(reply.Error.Data.Code, ShouldEqual, "CONFLICT") + e.closedByDaemon() + + got, _ := h.browsers.Get(instanceA) + So(got.Name, ShouldEqual, "chrome-0123") + info, _ := h.instance(instanceA) + So(info.Online, ShouldBeFalse) + }) + + Convey("名称不符合规则时连接不进入可用状态", func() { + for _, name := range []string{"", "Chrome", "has space", "under_score", "a23456789012345678901234567890123"} { + e := h.authenticateBrowser(instanceA, keyA) + e.writeRequest(methodCapabilities, uuid.NewString(), browserCapabilities(name)) + e.closedByDaemon() + } + got, _ := h.browsers.Get(instanceA) + So(got.Name, ShouldEqual, "chrome-0123") + }) + + Convey("浏览器实例不声明 peer 时连接不进入可用状态", func() { + e := h.authenticateBrowser(instanceA, keyA) + e.writeRequest(methodCapabilities, uuid.NewString(), capabilitiesParams{SchemaVersion: "1.0.0", Methods: []string{"tabs.list"}}) + e.closedByDaemon() + info, _ := h.instance(instanceA) + So(info.Online, ShouldBeFalse) + }) + }) +} + +func TestForgetInstance(t *testing.T) { + Convey("从 daemon 侧忘记浏览器实例", t, func() { + h := startTestServer(t) + sc := h.connectScriptCat() + keyA := h.registerBrowser(instanceA, "chrome-0123") + h.registerBrowser(instanceB, "edge-fedc") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + + Convey("按名称忘记在线实例:删除密钥与登记、断开连接,之后握手失败;ScriptCat 不受影响", func() { + So(h.srv.ForgetInstance("chrome-0123"), ShouldBeNil) + a.closedByDaemon() + _, ok := h.browsers.Get(instanceA) + So(ok, ShouldBeFalse) + _, listed := h.instance(instanceA) + So(listed, ShouldBeFalse) + + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModeSession, instanceA, keyA, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: browserPeer(instanceA)}) + e.closedByDaemon() + ev := waitForAuditEvent(h, audit.TypeHandshakeFailed) + So(ev.Reason, ShouldEqual, audit.ReasonUnknownInstance) + So(ev.Client, ShouldEqual, "browser:"+instanceA) + + h.scriptsListWorks(sc) + }) + + Convey("按实例 ID 忘记离线实例", func() { + So(h.srv.ForgetInstance(instanceB), ShouldBeNil) + _, ok := h.browsers.Get(instanceB) + So(ok, ShouldBeFalse) + a.alive() + }) + + Convey("不匹配任何已配对实例时返回 ErrInstanceNotFound", func() { + So(h.srv.ForgetInstance("nope"), ShouldEqual, ErrInstanceNotFound) + So(h.srv.Instances(), ShouldHaveLength, 2) + }) + }) +} + +func TestBrowserPeerValidation(t *testing.T) { + Convey("认证响应里的实例身份在边界校验", t, func() { + h := startTestServer(t) + key := h.registerBrowser(instanceA, "chrome-0123") + + for _, peer := range []*authPeer{ + {Kind: peerKindBrowser, InstanceID: "0123456789ABCDEF0123456789ABCDEF"}, + {Kind: peerKindBrowser, InstanceID: "0123"}, + {Kind: "scriptcat", InstanceID: instanceA}, + } { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModeSession, peer.InstanceID, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: peer}) + e.closedByDaemon() + } + ev := waitForAuditEvent(h, audit.TypeHandshakeFailed) + So(ev.Reason, ShouldEqual, audit.ReasonProtocol) + So(ev.Client, ShouldBeBlank) + }) +} + +func TestBrowserAudit(t *testing.T) { + Convey("浏览器实例的配对与握手进入 daemon 审计,并标注实例", t, func() { + h := startTestServer(t) + + Convey("配对码错误记录为 pairing.failed", func() { + _, err := h.srv.BeginEnrollment() + So(err, ShouldBeNil) + wrongMac, _, _ := h.crypto.DerivePairingKeys("WRONGCODE") + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModePairing, instanceA, wrongMac, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modePairing, NonceE: nonceE, HMAC: mac, Peer: browserPeer(instanceA)}) + e.closedByDaemon() + + ev := waitForAuditEvent(h, audit.TypePairingFailed) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + So(ev.Client, ShouldEqual, "browser:"+instanceA) + _, ok := h.browsers.Get(instanceA) + So(ok, ShouldBeFalse) + }) + + Convey("成功握手记录为 handshake.ok", func() { + key := h.registerBrowser(instanceA, "chrome-0123") + h.connectBrowser(instanceA, key, "chrome-0123") + ev := waitForAuditEvent(h, audit.TypeHandshakeOK) + So(ev.Client, ShouldEqual, "browser:"+instanceA) + }) + }) +} diff --git a/internal/daemon/bridge/call.go b/internal/daemon/bridge/call.go index 4c256fb..3cac9c7 100644 --- a/internal/daemon/bridge/call.go +++ b/internal/daemon/bridge/call.go @@ -12,7 +12,9 @@ import ( ) // pendingCall 是一条挂起的 JSON-RPC 请求:阻塞直到应答/取消/断开/超时。 +// 只接受发往的那条连接给出的应答,其他对端不能冒名应答。 type pendingCall struct { + conn *conn clientID string method string respCh chan Response @@ -24,10 +26,9 @@ type pendingCall struct { // - 扩展断开:隐式作废全部在途请求,返回 ErrDisconnected。 func (s *Server) Call(ctx context.Context, req Request) (Response, error) { requestID := uuid.NewString() - pc := &pendingCall{clientID: req.ClientID, method: req.Action, respCh: make(chan Response, 1)} s.mu.Lock() - active := s.active + active := s.scriptCat if active == nil { s.mu.Unlock() return Response{}, ErrNotConnected @@ -36,6 +37,7 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { s.mu.Unlock() return Response{}, &Error{Code: "METHOD_NOT_FOUND", Message: "extension does not support " + req.Action} } + pc := &pendingCall{conn: active, clientID: req.ClientID, method: req.Action, respCh: make(chan Response, 1)} s.pending[requestID] = pc s.mu.Unlock() @@ -74,12 +76,12 @@ func (s *Server) cancelToExt(c *conn, requestID string) { } } -// handleRPCResponse delivers a JSON-RPC response to its pending call. -func (s *Server) handleRPCResponse(message Message) { +// handleRPCResponse delivers a JSON-RPC response from c to its pending call. +func (s *Server) handleRPCResponse(c *conn, message Message) { s.mu.Lock() pc := s.pending[message.ID] s.mu.Unlock() - if pc == nil { + if pc == nil || pc.conn != c { return } if message.Error == nil { diff --git a/internal/daemon/bridge/conn.go b/internal/daemon/bridge/conn.go index 3c7d0f1..276aa95 100644 --- a/internal/daemon/bridge/conn.go +++ b/internal/daemon/bridge/conn.go @@ -15,6 +15,7 @@ import ( "github.com/scriptscat/sctl/internal/daemon/auth" "github.com/scriptscat/sctl/internal/pkg/audit" + "github.com/scriptscat/sctl/internal/pkg/protocol" "github.com/scriptscat/sctl/internal/pkg/protocolschema" ) @@ -36,8 +37,16 @@ type conn struct { pingID string pingAck chan struct{} - key []byte // 握手确立的长期共享密钥 K(会话建立后) + key []byte // 握手确立的长期共享密钥(ScriptCat 的 K 或浏览器实例密钥) capabilities map[string]struct{} + + // kind 默认是 ScriptCat;认证响应声明浏览器身份并通过校验后才改为浏览器。 + kind protocol.Peer + instanceID string + // paired 表示浏览器实例在本连接完成了首次配对,其登记在能力声明被接受时才落盘。 + paired bool + peer capabilitiesPeer + connectedAt time.Time } // authError 是被守卫判定为安全信号的握手失败,携带审计分类。handleWS 据此统一记录一次, @@ -84,6 +93,11 @@ func (c *conn) handshake() error { if err := json.Unmarshal(env.Result, &resp); err != nil { return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "parse authentication response: %w", err) } + if resp.Peer != nil { + if err := c.claimBrowser(*resp.Peer); err != nil { + return err + } + } switch resp.Mode { case modeSession: @@ -95,20 +109,74 @@ func (c *conn) handshake() error { } } -// handshakeSession 用已配对长期密钥 K 完成会话握手。 -func (c *conn) handshakeSession(ctx context.Context, nonceD string, resp authResponseResult) error { - s := c.srv - key, ok, err := s.keys.Load() +// claimBrowser 在边界校验认证响应里声明的浏览器身份;通过后本连接按该实例选用密钥与 MAC 上下文。 +func (c *conn) claimBrowser(peer authPeer) error { + if c.srv.browsers == nil { + return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "browser instances are not accepted by this daemon") + } + if peer.Kind != peerKindBrowser { + return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "unknown peer kind") + } + if !instanceIDPattern.MatchString(peer.InstanceID) { + return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "malformed browser instance ID") + } + c.kind = protocol.PeerBrowser + c.instanceID = peer.InstanceID + return nil +} + +// auditClient 是审计事件的客户端标注:浏览器实例标注为 browser:<实例 ID>,ScriptCat 留空。 +func (c *conn) auditClient() string { + if c.kind == protocol.PeerBrowser { + return "browser:" + c.instanceID + } + return "" +} + +// extMACValid 按对端类型校验扩展应答的 HMAC;浏览器实例的 MAC 绑定其实例 ID。 +func (c *conn) extMACValid(mode auth.Mode, key []byte, nonceD string, resp authResponseResult) bool { + if c.kind == protocol.PeerBrowser { + return c.srv.crypto.VerifyBrowserExtHMAC(mode, c.instanceID, key, nonceD, resp.NonceE, resp.HMAC) + } + return c.srv.crypto.VerifyExtHMAC(mode, key, nonceD, resp.NonceE, resp.HMAC) +} + +func (c *conn) daemonMAC(mode auth.Mode, key []byte, nonceD, nonceE string) string { + if c.kind == protocol.PeerBrowser { + return c.srv.crypto.BrowserDaemonHMAC(mode, c.instanceID, key, nonceD, nonceE) + } + return c.srv.crypto.DaemonHMAC(mode, key, nonceD, nonceE) +} + +// sessionKey 取会话握手所用的长期密钥:ScriptCat 用 pairing.key,浏览器实例用登记表里的实例密钥。 +func (c *conn) sessionKey() ([]byte, error) { + if c.kind == protocol.PeerBrowser { + inst, ok := c.srv.browsers.Get(c.instanceID) + if !ok { + return nil, authFail(audit.TypeHandshakeFailed, audit.ReasonUnknownInstance, "browser instance is not paired") + } + return inst.Key, nil + } + key, ok, err := c.srv.keys.Load() if err != nil { - return fmt.Errorf("load long-term key: %w", err) + return nil, fmt.Errorf("load long-term key: %w", err) } if !ok { - return errors.New("not paired yet, no long-term key") + return nil, errors.New("not paired yet, no long-term key") + } + return key, nil +} + +// handshakeSession 用已配对长期密钥完成会话握手。 +func (c *conn) handshakeSession(ctx context.Context, nonceD string, resp authResponseResult) error { + key, err := c.sessionKey() + if err != nil { + return err } - if !s.crypto.VerifyExtHMAC(auth.ModeSession, key, nonceD, resp.NonceE, resp.HMAC) { + if !c.extMACValid(auth.ModeSession, key, nonceD, resp) { return authFail(audit.TypeHandshakeFailed, audit.ReasonHMACMismatch, "session handshake HMAC verification failed") } - okMAC := s.crypto.DaemonHMAC(auth.ModeSession, key, nonceD, resp.NonceE) + okMAC := c.daemonMAC(auth.ModeSession, key, nonceD, resp.NonceE) if err := c.sendNotificationCtx(ctx, methodAuthenticated, authenticatedParams{HMAC: okMAC}); err != nil { return fmt.Errorf("send authenticated notification: %w", err) } @@ -131,7 +199,7 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes if err != nil { return fmt.Errorf("derive enrollment keys: %w", err) } - if !s.crypto.VerifyExtHMAC(auth.ModePairing, kpMac, nonceD, resp.NonceE, resp.HMAC) { + if !c.extMACValid(auth.ModePairing, kpMac, nonceD, resp) { s.failEnrollmentAttempt() return authFail(audit.TypePairingFailed, audit.ReasonHMACMismatch, "enrollment handshake HMAC verification failed") } @@ -144,12 +212,15 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes if err != nil { return fmt.Errorf("seal long-term key: %w", err) } - okMAC := s.crypto.DaemonHMAC(auth.ModePairing, kpMac, nonceD, resp.NonceE) + okMAC := c.daemonMAC(auth.ModePairing, kpMac, nonceD, resp.NonceE) if err := c.sendNotificationCtx(ctx, methodAuthenticated, authenticatedParams{HMAC: okMAC, Key: &keyDelivery{Ciphertext: ct, IV: iv}}); err != nil { return fmt.Errorf("send authenticated notification: %w", err) } - // 下发成功后再落盘,避免下发失败却持久化了扩展拿不到的密钥。 - if err := s.keys.Save(k); err != nil { + // 下发成功后再落盘,避免下发失败却持久化了扩展拿不到的密钥。浏览器实例的密钥连同名称 + // 一起写入登记表,名称随能力声明才到达,因此推迟到登记时;pairing.key 只属于 ScriptCat。 + if c.kind == protocol.PeerBrowser { + c.paired = true + } else if err := s.keys.Save(k); err != nil { return fmt.Errorf("persist long-term key: %w", err) } s.clearEnrollment() @@ -169,7 +240,7 @@ func (c *conn) readLoop() { if c.acknowledgePing(message) { continue } - c.srv.handleRPCResponse(message) + c.srv.handleRPCResponse(c, message) continue } switch message.Method { @@ -239,29 +310,43 @@ func (c *conn) acknowledgePing(message Message) bool { return true } -func (c *conn) receiveCapabilities() error { +// receiveCapabilities 读取并校验能力声明,返回请求 ID;回复由调用方在登记连接之后发出, +// 保证扩展收到回复时连接已可调用。 +func (c *conn) receiveCapabilities() (string, error) { ctx, cancel := context.WithTimeout(c.ctx, c.srv.authTimeout) defer cancel() message, err := c.readMessage(ctx) if err != nil { - return err + return "", err } if message.Method != methodCapabilities || message.ID == "" { - return fmt.Errorf("expected capabilities request") + return "", fmt.Errorf("expected capabilities request") } var payload capabilitiesParams if err := json.Unmarshal(message.Params, &payload); err != nil { - return err + return "", err } if payload.SchemaVersion != c.srv.proto.SchemaVersion { - return fmt.Errorf("incompatible protocol schema version %q", payload.SchemaVersion) + return "", fmt.Errorf("incompatible protocol schema version %q", payload.SchemaVersion) + } + if c.kind == protocol.PeerBrowser { + if !validCapabilitiesPeer(payload.Peer) { + return "", errors.New("browser instance declared a missing or invalid peer") + } + c.peer = *payload.Peer } capabilities := make(map[string]struct{}, len(payload.Methods)) for _, method := range payload.Methods { capabilities[method] = struct{}{} } c.capabilities = capabilities - return c.sendResultCtx(ctx, message.ID, struct{}{}) + return message.ID, nil +} + +func (c *conn) sendError(id, code, message string) error { + ctx, cancel := context.WithTimeout(c.ctx, writeTimeout) + defer cancel() + return c.writeMessage(ctx, newError(id, code, message)) } func (c *conn) readMessage(ctx context.Context) (Message, error) { diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go index 0c00077..a4374eb 100644 --- a/internal/daemon/bridge/envelope.go +++ b/internal/daemon/bridge/envelope.go @@ -3,6 +3,8 @@ package bridge import ( "encoding/json" "fmt" + + "github.com/scriptscat/sctl/internal/pkg/protocol" ) const jsonRPCVersion = "2.0" @@ -27,8 +29,12 @@ const ( CodeInternal = "INTERNAL_ERROR" CodeRateLimited = "RATE_LIMITED" CodeOperationExpired = "OPERATION_EXPIRED" + CodeConflict = "CONFLICT" ) +// rpcApplicationError 是应用层失败的 JSON-RPC 错误码,领域错误码放在 error.data.code(docs/protocol.md §4)。 +const rpcApplicationError = -32000 + // Message is one JSON-RPC 2.0 request, notification, success response, or error response. type Message struct { JSONRPC string `json:"jsonrpc"` @@ -55,9 +61,19 @@ type authChallengeParams struct { } type authResponseResult struct { - Mode string `json:"mode"` - NonceE string `json:"nonceE"` - HMAC string `json:"hmac"` + Mode string `json:"mode"` + NonceE string `json:"nonceE"` + HMAC string `json:"hmac"` + Peer *authPeer `json:"peer,omitempty"` +} + +// peerKindBrowser 是浏览器实例在认证响应里声明的对端类型;不声明 peer 的连接是 ScriptCat。 +const peerKindBrowser = string(protocol.PeerBrowser) + +// authPeer 是浏览器实例在认证响应里声明的身份,参与握手 MAC 计算并决定选用哪把实例密钥。 +type authPeer struct { + Kind string `json:"kind"` + InstanceID string `json:"instanceId"` } type keyDelivery struct { @@ -75,8 +91,17 @@ type helloParams struct { } type capabilitiesParams struct { - SchemaVersion string `json:"schemaVersion"` - Methods []string `json:"methods"` + SchemaVersion string `json:"schemaVersion"` + Methods []string `json:"methods"` + Peer *capabilitiesPeer `json:"peer,omitempty"` +} + +// capabilitiesPeer 是浏览器实例在能力声明里给出的名称与自报信息;名称以 daemon 登记为准。 +type capabilitiesPeer struct { + Name string `json:"name"` + Product string `json:"product,omitempty"` + ProductVersion string `json:"productVersion,omitempty"` + ExtensionVersion string `json:"extensionVersion,omitempty"` } type cancelParams struct { @@ -126,6 +151,10 @@ func newNotification(method string, params any) (Message, error) { return message, err } +func newError(id, code, message string) Message { + return Message{JSONRPC: jsonRPCVersion, ID: id, Error: &RPCError{Code: rpcApplicationError, Message: message, Data: &ErrorData{Code: code}}} +} + func newResult(id string, result any) (Message, error) { raw, err := json.Marshal(result) if err != nil { diff --git a/internal/daemon/bridge/instances.go b/internal/daemon/bridge/instances.go new file mode 100644 index 0000000..9e718f1 --- /dev/null +++ b/internal/daemon/bridge/instances.go @@ -0,0 +1,158 @@ +package bridge + +import ( + "regexp" + "time" + "unicode" + + "github.com/coder/websocket" + + "github.com/scriptscat/sctl/internal/daemon/store" +) + +// ErrInstanceNotFound 表示引用不匹配任何已配对的浏览器实例。 +var ErrInstanceNotFound = store.ErrInstanceNotFound + +var ( + // instanceIDPattern 限定实例 ID 为定长小写 hex:它直接拼进握手 MAC 输入,定长保证拼接无歧义。 + instanceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + namePattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) +) + +// maxPeerTextBytes 限制实例自报的产品与版本文本,它们会原样出现在 CLI 输出里。 +const maxPeerTextBytes = 64 + +// InstanceInfo 是一个已配对浏览器实例的当前视图。产品与版本取自最近一次连接;离线时 ConnectedAt 为零值。 +type InstanceInfo struct { + ID string + Name string + Online bool + Product string + ProductVersion string + ExtensionVersion string + ConnectedAt time.Time +} + +// Option 配置 Server 的可选依赖。 +type Option func(*Server) + +// WithBrowserRegistry 让 daemon 接纳浏览器实例,并把实例密钥与登记表持久化到 r。 +// 不提供时 daemon 只接纳 ScriptCat,声明为浏览器实例的握手一律失败。 +func WithBrowserRegistry(r *store.BrowserRegistry) Option { + return func(s *Server) { s.browsers = r } +} + +// Instances 列出所有已配对的浏览器实例(在线与离线),按名称排序。 +func (s *Server) Instances() []InstanceInfo { + if s.browsers == nil { + return nil + } + registered := s.browsers.List() + s.mu.Lock() + defer s.mu.Unlock() + out := make([]InstanceInfo, 0, len(registered)) + for _, inst := range registered { + info := InstanceInfo{ + ID: inst.ID, + Name: inst.Name, + Product: inst.Product, + ProductVersion: inst.ProductVersion, + ExtensionVersion: inst.ExtensionVersion, + } + if c := s.online[inst.ID]; c != nil { + info.Online = true + info.ConnectedAt = c.connectedAt + } + out = append(out, info) + } + return out +} + +// ForgetInstance 按完整实例 ID 或名称删除一个已配对浏览器实例的密钥与登记;实例在线时断开它, +// 之后它的握手会因实例未配对而失败。 +func (s *Server) ForgetInstance(ref string) error { + if s.browsers == nil { + return ErrInstanceNotFound + } + s.regMu.Lock() + defer s.regMu.Unlock() + id, ok := s.resolveRegistered(ref) + if !ok { + return ErrInstanceNotFound + } + if err := s.browsers.Delete(id); err != nil { + return err + } + s.mu.Lock() + c := s.online[id] + delete(s.online, id) + s.mu.Unlock() + if c != nil { + c.close(websocket.StatusPolicyViolation, "") + } + return nil +} + +func (s *Server) resolveRegistered(ref string) (string, bool) { + if _, ok := s.browsers.Get(ref); ok { + return ref, true + } + for _, inst := range s.browsers.List() { + if inst.Name == ref { + return inst.ID, true + } + } + return "", false +} + +// registerBrowser 把完成能力声明的浏览器实例写入登记表并标记在线,替换同一实例的旧连接。 +// 新配对在此首次落盘(名称随能力声明才到达);会话连接只能更新仍以同一密钥登记的实例, +// 与 ForgetInstance 由 regMu 串行,被忘记的实例无法借一条进行中的握手重新登记。 +func (s *Server) registerBrowser(c *conn) error { + s.regMu.Lock() + defer s.regMu.Unlock() + inst := store.BrowserInstance{ + ID: c.instanceID, + Name: c.peer.Name, + Key: c.key, + Product: c.peer.Product, + ProductVersion: c.peer.ProductVersion, + ExtensionVersion: c.peer.ExtensionVersion, + } + var err error + if c.paired { + err = s.browsers.Pair(inst) + } else { + err = s.browsers.Update(inst) + } + if err != nil { + return err + } + + c.connectedAt = time.Now() + s.mu.Lock() + old := s.online[c.instanceID] + s.online[c.instanceID] = c + s.mu.Unlock() + if old != nil && old != c { + old.close(websocket.StatusNormalClosure, "replaced by new connection") + } + return nil +} + +func validCapabilitiesPeer(p *capabilitiesPeer) bool { + return p != nil && namePattern.MatchString(p.Name) && + validPeerText(p.Product) && validPeerText(p.ProductVersion) && validPeerText(p.ExtensionVersion) +} + +func validPeerText(s string) bool { + if len(s) > maxPeerTextBytes { + return false + } + for _, r := range s { + if !unicode.IsPrint(r) { + return false + } + } + return true +} diff --git a/internal/daemon/bridge/server.go b/internal/daemon/bridge/server.go index 10cfb5d..26f1a57 100644 --- a/internal/daemon/bridge/server.go +++ b/internal/daemon/bridge/server.go @@ -30,16 +30,21 @@ var ( ) // Server 是桥接 daemon 的 WS 服务核心:accept、双向认证握手、envelope 路由与阻塞写模型。 -// v1 只允许一个扩展实例:新完成握手的连接替换旧连接。 +// ScriptCat 只保留一条连接,新完成握手的 ScriptCat 连接替换旧连接;浏览器实例按实例 ID +// 各保留一条连接,同一实例重连只替换它自己。 // -// 信任模型是扁平的:接入(enrollment)建立唯一长期密钥 K 即确立信任,CLI 与所有 MCP agent 都 -// 经这条可信通道继承信任,不再逐客户端配对/铸令牌/撤销(docs/threat-model.md)。 +// 信任模型是扁平的:接入(enrollment)为每个对端建立长期密钥即确立信任,CLI 与所有 MCP agent 都 +// 经这些可信通道继承信任,不再逐客户端配对/铸令牌/撤销(docs/threat-model.md)。 type Server struct { - version string - proto *protocol.Protocol - crypto *auth.Crypto - keys *store.KeyStore - log *zap.Logger + version string + proto *protocol.Protocol + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + log *zap.Logger + + // regMu 串行化浏览器实例的登记与忘记,使登记表与在线表的联合修改原子。 + regMu sync.Mutex audit *audit.Recorder @@ -53,7 +58,8 @@ type Server struct { mu sync.Mutex conns map[*conn]struct{} - active *conn + scriptCat *conn + online map[string]*conn pending map[string]*pendingCall enrollment *pendingEnrollment @@ -64,11 +70,11 @@ type Server struct { } // NewServer 用协议定义的常量与持久化后端构造服务。 -func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, log *zap.Logger) *Server { +func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, log *zap.Logger, opts ...Option) *Server { if log == nil { log = zap.NewNop() } - return &Server{ + s := &Server{ version: version, proto: p, crypto: auth.NewCrypto(p), @@ -82,8 +88,13 @@ func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, log * pingInterval: time.Duration(p.Limits.PingIntervalMs) * time.Millisecond, maxFrameBytes: int64(p.Limits.MaxFrameBytes), conns: make(map[*conn]struct{}), + online: make(map[string]*conn), pending: make(map[string]*pendingCall), } + for _, opt := range opts { + opt(s) + } + return s } // Version 返回注入的 daemon 版本(hello.daemonVersion 与控制 API 都读它)。 @@ -95,11 +106,11 @@ func (s *Server) Action(name string) (protocol.Action, bool) { return a, ok } -// ExtConnected 报告当前是否有完成握手的扩展连接。 +// ExtConnected 报告当前是否有完成握手的 ScriptCat 连接。 func (s *Server) ExtConnected() bool { s.mu.Lock() defer s.mu.Unlock() - return s.active != nil + return s.scriptCat != nil } // AuditSnapshot 返回守卫侧安全事件的快照。 @@ -131,19 +142,25 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener, mux *http.ServeMux) return err } -// shutdown 通知扩展、断开所有连接并关闭 HTTP server。幂等。 +// shutdown 通知所有已建立会话的扩展、断开所有连接并关闭 HTTP server。幂等。 func (s *Server) shutdown() { s.shutdownOnce.Do(func() { s.mu.Lock() - active := s.active + established := make([]*conn, 0, len(s.online)+1) + if s.scriptCat != nil { + established = append(established, s.scriptCat) + } + for _, c := range s.online { + established = append(established, c) + } conns := make([]*conn, 0, len(s.conns)) for c := range s.conns { conns = append(conns, c) } s.mu.Unlock() - if active != nil { - _ = active.sendNotification(methodShutdown, struct{}{}) + for _, c := range established { + _ = c.sendNotification(methodShutdown, struct{}{}) } for _, c := range conns { c.close(websocket.StatusGoingAway, "server shutdown") @@ -197,29 +214,64 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { // 只有被守卫判定为安全信号的失败才进审计;本地故障(密钥读写失败等)不混入。 var ae *authError if errors.As(err, &ae) { - s.audit.Record(audit.Event{Type: ae.evType, Reason: ae.reason}) + s.audit.Record(audit.Event{Type: ae.evType, Reason: ae.reason, Client: c.auditClient()}) } c.close(websocket.StatusPolicyViolation, "") return } - s.audit.Record(audit.Event{Type: audit.TypeHandshakeOK}) + s.audit.Record(audit.Event{Type: audit.TypeHandshakeOK, Client: c.auditClient()}) if err := c.sendNotification(methodHello, helloParams{DaemonVersion: s.version}); err != nil { s.log.Debug("failed to send hello", zap.Error(err)) c.close(websocket.StatusInternalError, "") return } - if err := c.receiveCapabilities(); err != nil { + capabilitiesID, err := c.receiveCapabilities() + if err != nil { s.log.Debug("failed to negotiate extension capabilities", zap.Error(err)) c.close(websocket.StatusProtocolError, "invalid capabilities") return } - s.setActive(c) - s.log.Info("extension completed the handshake and is connected") + // 先登记再回复:扩展一收到回复就可能发起调用,此时连接必须已可路由。 + if err := s.register(c); err != nil { + s.rejectRegistration(c, capabilitiesID, err) + return + } + if err := c.sendResult(capabilitiesID, struct{}{}); err != nil { + s.log.Debug("failed to reply to capabilities", zap.Error(err)) + return + } + s.log.Info("extension completed the handshake and is connected", zap.String("peer", string(c.kind))) go c.heartbeatLoop() c.readLoop() } +// register 使完成能力声明的连接可被调用。 +func (s *Server) register(c *conn) error { + if c.kind == protocol.PeerBrowser { + return s.registerBrowser(c) + } + s.setScriptCat(c) + return nil +} + +// rejectRegistration 处理登记失败:名称冲突在认证后的通道上回 CONFLICT(握手失败本身不回显原因), +// 其余情况直接断开。 +func (s *Server) rejectRegistration(c *conn, capabilitiesID string, err error) { + switch { + case errors.Is(err, store.ErrNameTaken): + if sendErr := c.sendError(capabilitiesID, CodeConflict, "name "+c.peer.Name+" is used by another browser"); sendErr != nil { + s.log.Debug("failed to reply to capabilities", zap.Error(sendErr)) + } + c.close(websocket.StatusNormalClosure, "") + case errors.Is(err, store.ErrInstanceNotFound): + c.close(websocket.StatusPolicyViolation, "") + default: + s.log.Warn("failed to register browser instance", zap.Error(err)) + c.close(websocket.StatusInternalError, "") + } +} + func (s *Server) newConn(ws *websocket.Conn) *conn { ctx, cancel := context.WithCancel(s.baseCtx) c := &conn{ @@ -229,6 +281,7 @@ func (s *Server) newConn(ws *websocket.Conn) *conn { closed: make(chan struct{}), ctx: ctx, cancel: cancel, + kind: protocol.PeerScriptCat, } s.mu.Lock() s.conns[c] = struct{}{} @@ -239,18 +292,21 @@ func (s *Server) newConn(ws *websocket.Conn) *conn { func (s *Server) removeConn(c *conn) { s.mu.Lock() delete(s.conns, c) - if s.active == c { - s.active = nil + if s.scriptCat == c { + s.scriptCat = nil + } + if s.online[c.instanceID] == c { + delete(s.online, c.instanceID) } s.mu.Unlock() c.close(websocket.StatusNormalClosure, "") } -// setActive 将 c 设为唯一活动连接,替换并断开旧连接(v1 单实例)。 -func (s *Server) setActive(c *conn) { +// setScriptCat 将 c 设为唯一的 ScriptCat 连接,替换并断开旧的 ScriptCat 连接。 +func (s *Server) setScriptCat(c *conn) { s.mu.Lock() - old := s.active - s.active = c + old := s.scriptCat + s.scriptCat = c s.mu.Unlock() if old != nil && old != c { old.close(websocket.StatusNormalClosure, "replaced by new connection") diff --git a/internal/daemon/bridge/server_test.go b/internal/daemon/bridge/server_test.go index d133c7a..9f0069d 100644 --- a/internal/daemon/bridge/server_test.go +++ b/internal/daemon/bridge/server_test.go @@ -23,11 +23,13 @@ import ( // testHarness 承载一个运行中的 daemon 与用于对拍的密码学助手/存储。 type testHarness struct { - srv *Server - crypto *auth.Crypto - keys *store.KeyStore - url string - proto *protocol.Protocol + srv *Server + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + keyPath string + url string + proto *protocol.Protocol } func startTestServer(t *testing.T) *testHarness { @@ -35,8 +37,11 @@ func startTestServer(t *testing.T) *testHarness { p, err := protocol.Load() So(err, ShouldBeNil) dir := t.TempDir() - keys := store.NewKeyStore(filepath.Join(dir, "pairing.key")) - srv := NewServer("0.1.0", p, keys, zap.NewNop()) + keyPath := filepath.Join(dir, "pairing.key") + keys := store.NewKeyStore(keyPath) + browsers, err := store.LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) + So(err, ShouldBeNil) + srv := NewServer("0.1.0", p, keys, zap.NewNop(), WithBrowserRegistry(browsers)) ln, err := net.Listen("tcp", "127.0.0.1:0") So(err, ShouldBeNil) @@ -45,11 +50,13 @@ func startTestServer(t *testing.T) *testHarness { go func() { _ = srv.Serve(ctx, ln, nil) }() return &testHarness{ - srv: srv, - crypto: auth.NewCrypto(p), - keys: keys, - url: "ws://" + ln.Addr().String() + "/", - proto: p, + srv: srv, + crypto: auth.NewCrypto(p), + keys: keys, + browsers: browsers, + keyPath: keyPath, + url: "ws://" + ln.Addr().String() + "/", + proto: p, } } @@ -92,8 +99,25 @@ func (e *extClient) writeResult(id string, result any) { So(wsjson.Write(ctx, e.ws, message), ShouldBeNil) } -// doSessionHandshake 以给定长期密钥完成会话握手,并消费 hello。 +// doSessionHandshake 以给定长期密钥完成会话握手与能力声明。 func (h *testHarness) doSessionHandshake(key []byte) *extClient { + e := h.authenticateSession(key) + capabilitiesID := uuid.NewString() + e.writeRequest(methodCapabilities, capabilitiesID, capabilitiesParams{SchemaVersion: "1.0.0", Methods: h.allMethods()}) + So(e.read().ID, ShouldEqual, capabilitiesID) + return e +} + +func (h *testHarness) allMethods() []string { + methods := make([]string, 0, len(h.proto.Actions)) + for method := range h.proto.Actions { + methods = append(methods, method) + } + return methods +} + +// authenticateSession 以给定长期密钥完成会话认证,并消费 hello;能力声明留给调用方。 +func (h *testHarness) authenticateSession(key []byte) *extClient { e := dial(h.url) challenge := e.read() So(challenge.Method, ShouldEqual, methodAuthenticate) @@ -112,16 +136,52 @@ func (h *testHarness) doSessionHandshake(key []byte) *extClient { hello := e.read() So(hello.Method, ShouldEqual, methodHello) - methods := make([]string, 0, len(h.proto.Actions)) - for method := range h.proto.Actions { - methods = append(methods, method) - } - capabilitiesID := uuid.NewString() - e.writeRequest(methodCapabilities, capabilitiesID, capabilitiesParams{SchemaVersion: "1.0.0", Methods: methods}) - So(e.read().ID, ShouldEqual, capabilitiesID) return e } +// declareWithRegistrationFrozen 在持有服务锁(冻结连接登记)期间发出能力声明,报告回复是否在 +// 连接登记之前就到达了扩展;registered 在锁内求值,只能直接读字段。返回值之外还返回最终收到的回复。 +func (h *testHarness) declareWithRegistrationFrozen(e *extClient, params capabilitiesParams, registered func() bool) (replyBeforeRegistration bool, reply Message) { + replies := make(chan Message, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var message Message + if _, data, err := e.ws.Read(ctx); err == nil { + _ = json.Unmarshal(data, &message) + } + replies <- message + }() + + h.srv.mu.Lock() + e.writeRequest(methodCapabilities, uuid.NewString(), params) + select { + case message := <-replies: + replyBeforeRegistration = !registered() + replies <- message + case <-time.After(200 * time.Millisecond): + } + h.srv.mu.Unlock() + return replyBeforeRegistration, <-replies +} + +func TestConnectionIsRegisteredBeforeCapabilitiesReply(t *testing.T) { + Convey("扩展收到 capabilities 回复时连接已登记,立即发起的调用不会得到 ErrNotConnected", t, func() { + h := startTestServer(t) + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + e := h.authenticateSession(key) + + early, reply := h.declareWithRegistrationFrozen(e, + capabilitiesParams{SchemaVersion: "1.0.0", Methods: h.allMethods()}, + func() bool { return h.srv.scriptCat != nil }) + So(early, ShouldBeFalse) + So(reply.Error, ShouldBeNil) + So(reply.ID, ShouldNotBeBlank) + So(h.srv.ExtConnected(), ShouldBeTrue) + }) +} + func TestSessionHandshakeFlow(t *testing.T) { Convey("会话模式握手(已配对)", t, func() { h := startTestServer(t) @@ -169,7 +229,7 @@ func TestSessionHandshakeFlow(t *testing.T) { _, _, err := e.ws.Read(ctx) So(err, ShouldNotBeNil) h.srv.mu.Lock() - active := h.srv.active + active := h.srv.scriptCat h.srv.mu.Unlock() So(active, ShouldBeNil) }) diff --git a/internal/daemon/component.go b/internal/daemon/component.go index eda89d9..134f8c2 100644 --- a/internal/daemon/component.go +++ b/internal/daemon/component.go @@ -52,7 +52,12 @@ func (b *daemonComponent) StartCancel(ctx context.Context, cancel context.Cancel b.address = defaultAddress(p.Transport.DefaultPort) } keys := store.NewKeyStore(paths.KeyFile()) - b.srv = bridge.NewServer(b.version, p, keys, logger.Ctx(ctx)) + browsers, err := store.LoadBrowserRegistry(paths.BrowsersFile()) + if err != nil { + logger.Ctx(ctx).Error("failed to load the browser instance registry", zap.Error(err)) + return err + } + b.srv = bridge.NewServer(b.version, p, keys, logger.Ctx(ctx), bridge.WithBrowserRegistry(browsers)) // 同步 net.Listen 使绑定失败在启动阶段即暴露(cago 会 panic,符合 fail-fast 约定)。 ln, err := net.Listen("tcp", b.address) diff --git a/internal/daemon/store/browsers.go b/internal/daemon/store/browsers.go new file mode 100644 index 0000000..8e6d2a5 --- /dev/null +++ b/internal/daemon/store/browsers.go @@ -0,0 +1,197 @@ +package store + +import ( + "bytes" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/scriptscat/sctl/internal/pkg/fsutil" +) + +var ( + // ErrNameTaken 表示名称已被另一个已配对的浏览器实例占用。 + ErrNameTaken = errors.New("browser instance name is already taken") + // ErrInstanceNotFound 表示实例未登记,或已不再以调用方持有的密钥登记。 + ErrInstanceNotFound = errors.New("browser instance not found") +) + +// BrowserInstance 是一个已配对的浏览器实例:每实例一把长期密钥,名称在所有已配对实例中唯一。 +// 产品与版本是最近一次连接时的自报信息,离线时仍可列出。 +type BrowserInstance struct { + ID string + Name string + Key []byte + Product string + ProductVersion string + ExtensionVersion string +} + +type browserRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + Product string `json:"product,omitempty"` + ProductVersion string `json:"productVersion,omitempty"` + ExtensionVersion string `json:"extensionVersion,omitempty"` +} + +type browserFile struct { + Instances []browserRecord `json:"instances"` +} + +// BrowserRegistry 以 0600 文件持久化已配对浏览器实例及其密钥。daemon 是该文件唯一的写者, +// 因此启动时加载一次、之后由内存副本应答读取;每次修改先原子落盘成功再更新内存。 +type BrowserRegistry struct { + path string + + mu sync.Mutex + instances map[string]BrowserInstance +} + +// LoadBrowserRegistry 加载 path 处的登记表;文件不存在视为空表,内容损坏则报错。 +func LoadBrowserRegistry(path string) (*BrowserRegistry, error) { + r := &BrowserRegistry{path: path, instances: make(map[string]BrowserInstance)} + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return r, nil + } + if err != nil { + return nil, fmt.Errorf("read browser registry: %w", err) + } + var file browserFile + if err := json.Unmarshal(raw, &file); err != nil { + return nil, fmt.Errorf("parse browser registry: %w", err) + } + for _, rec := range file.Instances { + key, err := hex.DecodeString(rec.Key) + if err != nil { + return nil, fmt.Errorf("parse key of browser instance %s: %w", rec.ID, err) + } + r.instances[rec.ID] = BrowserInstance{ + ID: rec.ID, + Name: rec.Name, + Key: key, + Product: rec.Product, + ProductVersion: rec.ProductVersion, + ExtensionVersion: rec.ExtensionVersion, + } + } + return r, nil +} + +// Get 返回已登记实例的副本。 +func (r *BrowserRegistry) Get(id string) (BrowserInstance, bool) { + r.mu.Lock() + defer r.mu.Unlock() + inst, ok := r.instances[id] + return cloneInstance(inst), ok +} + +// List 返回按名称排序的全部已登记实例副本。 +func (r *BrowserRegistry) List() []BrowserInstance { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]BrowserInstance, 0, len(r.instances)) + for _, inst := range r.instances { + out = append(out, cloneInstance(inst)) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Pair 登记一次新配对:实例不存在则新增,已存在(同一实例重新配对)则替换其密钥与信息。 +func (r *BrowserRegistry) Pair(inst BrowserInstance) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.nameTakenLocked(inst.ID, inst.Name) { + return ErrNameTaken + } + return r.commitLocked(inst.ID, &inst) +} + +// Update 更新仍以同一密钥登记的实例的名称与信息。实例在握手之后被删除或重新配对时返回 +// ErrInstanceNotFound,使这条已过期的连接无法把登记写回。 +func (r *BrowserRegistry) Update(inst BrowserInstance) error { + r.mu.Lock() + defer r.mu.Unlock() + current, ok := r.instances[inst.ID] + if !ok || subtle.ConstantTimeCompare(current.Key, inst.Key) != 1 { + return ErrInstanceNotFound + } + if r.nameTakenLocked(inst.ID, inst.Name) { + return ErrNameTaken + } + return r.commitLocked(inst.ID, &inst) +} + +// Delete 删除实例及其密钥。 +func (r *BrowserRegistry) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.instances[id]; !ok { + return ErrInstanceNotFound + } + return r.commitLocked(id, nil) +} + +func (r *BrowserRegistry) nameTakenLocked(id, name string) bool { + for otherID, other := range r.instances { + if otherID != id && other.Name == name { + return true + } + } + return false +} + +// commitLocked 以 inst 替换(nil 则删除)id 的登记,先原子落盘,成功后才改内存。 +func (r *BrowserRegistry) commitLocked(id string, inst *BrowserInstance) error { + next := make(map[string]BrowserInstance, len(r.instances)+1) + for otherID, other := range r.instances { + if otherID != id { + next[otherID] = other + } + } + if inst != nil { + next[id] = cloneInstance(*inst) + } + if err := r.write(next); err != nil { + return err + } + r.instances = next + return nil +} + +func (r *BrowserRegistry) write(instances map[string]BrowserInstance) error { + file := browserFile{Instances: make([]browserRecord, 0, len(instances))} + for _, inst := range instances { + file.Instances = append(file.Instances, browserRecord{ + ID: inst.ID, + Name: inst.Name, + Key: hex.EncodeToString(inst.Key), + Product: inst.Product, + ProductVersion: inst.ProductVersion, + ExtensionVersion: inst.ExtensionVersion, + }) + } + sort.Slice(file.Instances, func(i, j int) bool { return file.Instances[i].ID < file.Instances[j].ID }) + raw, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("encode browser registry: %w", err) + } + if err := os.MkdirAll(filepath.Dir(r.path), 0o700); err != nil { + return fmt.Errorf("create browser registry directory: %w", err) + } + return fsutil.WriteFileAtomic(r.path, raw, 0o600) +} + +func cloneInstance(inst BrowserInstance) BrowserInstance { + inst.Key = bytes.Clone(inst.Key) + return inst +} diff --git a/internal/daemon/store/browsers_test.go b/internal/daemon/store/browsers_test.go new file mode 100644 index 0000000..9f864a9 --- /dev/null +++ b/internal/daemon/store/browsers_test.go @@ -0,0 +1,128 @@ +package store + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/daemon/auth" +) + +func newInstance(id, name string) BrowserInstance { + key, _ := auth.NewLongTermKey() + return BrowserInstance{ID: id, Name: name, Key: key, Product: "Chrome", ProductVersion: "129.0", ExtensionVersion: "0.1.0"} +} + +func TestBrowserRegistry(t *testing.T) { + Convey("浏览器实例登记表", t, func() { + path := filepath.Join(t.TempDir(), "browsers.json") + reg, err := LoadBrowserRegistry(path) + So(err, ShouldBeNil) + + Convey("文件不存在时登记表为空", func() { + So(reg.List(), ShouldBeEmpty) + _, ok := reg.Get("0123456789abcdef0123456789abcdef") + So(ok, ShouldBeFalse) + }) + + Convey("配对登记后重新加载仍能读回实例与密钥,文件权限 0600", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + So(reg.Pair(a), ShouldBeNil) + + reloaded, err := LoadBrowserRegistry(path) + So(err, ShouldBeNil) + got, ok := reloaded.Get(a.ID) + So(ok, ShouldBeTrue) + So(got, ShouldResemble, a) + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + So(err, ShouldBeNil) + So(info.Mode().Perm(), ShouldEqual, os.FileMode(0o600)) + } + }) + + Convey("名称在所有已配对实例中唯一:占用他人名称返回 ErrNameTaken 且不改变登记", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + b := newInstance("fedcba9876543210fedcba9876543210", "edge-fedc") + So(reg.Pair(a), ShouldBeNil) + So(reg.Pair(b), ShouldBeNil) + + renamed := b + renamed.Name = "chrome-0123" + So(reg.Update(renamed), ShouldEqual, ErrNameTaken) + taken := newInstance("00000000000000000000000000000000", "chrome-0123") + So(reg.Pair(taken), ShouldEqual, ErrNameTaken) + + reloaded, err := LoadBrowserRegistry(path) + So(err, ShouldBeNil) + got, _ := reloaded.Get(b.ID) + So(got.Name, ShouldEqual, "edge-fedc") + So(reloaded.List(), ShouldHaveLength, 2) + }) + + Convey("实例可保留自己的名称并更新名称与版本信息", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + So(reg.Pair(a), ShouldBeNil) + a.ProductVersion = "130.0" + So(reg.Update(a), ShouldBeNil) + a.Name = "work" + So(reg.Update(a), ShouldBeNil) + + reloaded, err := LoadBrowserRegistry(path) + So(err, ShouldBeNil) + got, _ := reloaded.Get(a.ID) + So(got.Name, ShouldEqual, "work") + So(got.ProductVersion, ShouldEqual, "130.0") + }) + + Convey("更新要求实例仍以同一密钥登记:已删除或已重新配对的实例返回 ErrInstanceNotFound", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + So(reg.Update(a), ShouldEqual, ErrInstanceNotFound) + + So(reg.Pair(a), ShouldBeNil) + stale := a + stale.Key, _ = auth.NewLongTermKey() + So(reg.Update(stale), ShouldEqual, ErrInstanceNotFound) + }) + + Convey("同一实例重新配对时替换自己的密钥", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + So(reg.Pair(a), ShouldBeNil) + again := newInstance(a.ID, "chrome-0123") + So(reg.Pair(again), ShouldBeNil) + got, _ := reg.Get(a.ID) + So(got.Key, ShouldResemble, again.Key) + So(reg.List(), ShouldHaveLength, 1) + }) + + Convey("删除后实例与密钥从磁盘消失,名称可被重新使用", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "chrome-0123") + So(reg.Pair(a), ShouldBeNil) + So(reg.Delete(a.ID), ShouldBeNil) + So(reg.Delete(a.ID), ShouldEqual, ErrInstanceNotFound) + + reloaded, err := LoadBrowserRegistry(path) + So(err, ShouldBeNil) + So(reloaded.List(), ShouldBeEmpty) + So(reg.Pair(newInstance("fedcba9876543210fedcba9876543210", "chrome-0123")), ShouldBeNil) + }) + + Convey("按名称排序列出", func() { + So(reg.Pair(newInstance("fedcba9876543210fedcba9876543210", "zeta")), ShouldBeNil) + So(reg.Pair(newInstance("0123456789abcdef0123456789abcdef", "alpha")), ShouldBeNil) + list := reg.List() + So(list[0].Name, ShouldEqual, "alpha") + So(list[1].Name, ShouldEqual, "zeta") + }) + + Convey("损坏的登记文件在加载时报错,而不是当作空表", func() { + So(os.WriteFile(path, []byte("{not json"), 0o600), ShouldBeNil) + _, err := LoadBrowserRegistry(path) + So(err, ShouldNotBeNil) + }) + }) +} diff --git a/internal/pkg/audit/audit.go b/internal/pkg/audit/audit.go index 676c34e..df483e9 100644 --- a/internal/pkg/audit/audit.go +++ b/internal/pkg/audit/audit.go @@ -35,6 +35,8 @@ const ( ReasonProtocol Reason = "protocol_violation" ReasonPairExpired Reason = "pairing_expired" ReasonPairExhausted Reason = "pairing_attempts_exhausted" + // ReasonUnknownInstance 表示声明的浏览器实例未配对(或已被忘记)。 + ReasonUnknownInstance Reason = "unknown_instance" ) // Event 是一条安全事件。字段集合是封闭的(见包注释)。 diff --git a/internal/pkg/paths/paths.go b/internal/pkg/paths/paths.go index a07215d..437e572 100644 --- a/internal/pkg/paths/paths.go +++ b/internal/pkg/paths/paths.go @@ -48,6 +48,11 @@ func KeyFile() string { return filepath.Join(DataDir(), "pairing.key") } +// BrowsersFile 返回已配对浏览器实例登记表(含每实例密钥)的落盘路径(0600)。 +func BrowsersFile() string { + return filepath.Join(DataDir(), "browsers.json") +} + // ControlTokenFile 返回本机内部控制通道凭据的落盘路径(0600)。daemon 绑定端口后写入, // 同用户的前端(sctl mcp / CLI 动词)读取后作为控制 API 的鉴权凭据。 func ControlTokenFile() string { From e8f2e7c53478ab5fa91750e8408395a230348683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:29:13 +0800 Subject: [PATCH 05/25] feat: route calls by method ownership and resolve browser targets --- docs/protocol.md | 35 ++- internal/client/control/client.go | 12 +- internal/client/control/client_test.go | 42 ++++ internal/client/control/protocol.go | 8 +- internal/daemon/bridge/call.go | 222 +++++++++++++++-- internal/daemon/bridge/envelope.go | 8 +- internal/daemon/bridge/routing_test.go | 126 ++++++++++ internal/daemon/controlapi/controlapi.go | 1 + internal/daemon/controlapi/harness_test.go | 76 ++++-- internal/daemon/controlapi/routing_test.go | 264 +++++++++++++++++++++ 10 files changed, 752 insertions(+), 42 deletions(-) create mode 100644 internal/daemon/bridge/routing_test.go create mode 100644 internal/daemon/controlapi/routing_test.go diff --git a/docs/protocol.md b/docs/protocol.md index f3ec4d6..31e4cfc 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -235,6 +235,37 @@ use `scripts.source.grep` and then request a `startLine`/`endLine` window. The b window is present. `sctl mcp` supplies this budget for whole-file reads, while the CLI omits it so an operator can still redirect a complete source file. +### 3.1 Routing and target selection + +The daemon routes each call by the method's `peer`, not by which methods a connection declared: `scripts.*` +calls go to the ScriptCat connection, browser methods go to a browser instance. A connection must still have +declared the method, otherwise the call fails with `METHOD_NOT_FOUND`. + +A browser call carries an optional target, either an instance name or an instance-ID prefix; the daemon resolves +it because only the daemon knows which instances are online. A target is first matched against names exactly, and +only if no name matches is it treated as an instance-ID prefix over every paired instance, online or offline. + +| Target | Result | +|---|---| +| none, no instance online | `NO_BROWSER_CONNECTED` | +| none, exactly one instance online | that instance | +| none, several online, method with `mergeField` | every online instance; results are combined | +| none, several online, method without `mergeField` | `BROWSER_AMBIGUOUS`; the message lists the online instances | +| matches no paired instance | `BROWSER_NOT_FOUND` | +| ID prefix matches several paired instances | `BROWSER_AMBIGUOUS`; the message lists the matching instances | +| matches one paired instance that is not connected | `BROWSER_OFFLINE` | +| matches one online instance | that instance | + +A combined call is sent to every online instance at once. The result is the first instance's result with its +`mergeField` array replaced by the concatenation of every instance's array, in instance-name order, and each +item gains a `browser` object naming its source: `{"id": "", "name": ""}`. Any +failing instance fails the whole call; partial results are never returned. A call routed to a single instance +returns that instance's result unchanged. + +A target on a `scripts.*` call is rejected with `INVALID_REQUEST`; otherwise `scripts.*` routing and its errors do +not depend on browser instances. If the target connection closes while a call is in flight — for a combined +call, any of its instances — the call is voided and the requester receives `OPERATION_EXPIRED`, as for ScriptCat. + ## 4. Errors Protocol errors use the JSON-RPC standard codes. Application failures use the server-defined code `-32000` and @@ -258,14 +289,14 @@ put the stable domain code in `error.data.code`: The generated `errorCodes` list is authoritative for domain codes. Messages are human-readable diagnostics; callers branch on the numeric JSON-RPC code and `data.code`, not on message text. -These codes are reserved for browser target selection failures: +These codes are reserved for browser target selection failures ([§3.1](#31-routing-and-target-selection)): | Code | Meaning | |---|---| | `NO_BROWSER_CONNECTED` | a browser method was called while no browser instance is online | | `BROWSER_OFFLINE` | the target names a paired browser instance that is not connected | | `BROWSER_NOT_FOUND` | the target matches no paired browser instance | -| `BROWSER_AMBIGUOUS` | the target instance-ID prefix matches more than one paired instance | +| `BROWSER_AMBIGUOUS` | the target instance-ID prefix matches more than one paired instance, or a method without `mergeField` was called without a target while several instances are online | ## 5. Cancellation and approval diff --git a/internal/client/control/client.go b/internal/client/control/client.go index ed9ce7d..8d05452 100644 --- a/internal/client/control/client.go +++ b/internal/client/control/client.go @@ -119,13 +119,19 @@ func (c *Client) newRequest(ctx context.Context, method, path string, body []byt return req, nil } -// Call 转发一次 bridge action 调用并阻塞至应答/作废。ctx 取消(如 CLI Ctrl-C)会切断连接, -// daemon 侧据此发 $/cancelRequest 作废该操作;此时 Do 返回 context.Canceled。 +// Call 转发一次不指定目标浏览器的 bridge action 调用,见 CallBrowser。 func (c *Client) Call(ctx context.Context, action string, input json.RawMessage) (CallResult, error) { + return c.CallBrowser(ctx, action, "", input) +} + +// CallBrowser 转发一次 bridge action 调用并阻塞至应答/作废。browser 是浏览器方法的可选目标 +// (名称或实例 ID 前缀),空串表示由 daemon 按在线实例选择;scripts.* 必须传空串。 +// ctx 取消(如 CLI Ctrl-C)会切断连接,daemon 侧据此发 $/cancelRequest 作废该操作;此时 Do 返回 context.Canceled。 +func (c *Client) CallBrowser(ctx context.Context, action, browser string, input json.RawMessage) (CallResult, error) { if input == nil { input = json.RawMessage(`{}`) } - body, err := json.Marshal(CallRequest{Action: action, Input: input}) + body, err := json.Marshal(CallRequest{Action: action, Browser: browser, Input: input}) if err != nil { return CallResult{}, err } diff --git a/internal/client/control/client_test.go b/internal/client/control/client_test.go index 2b0922d..e2debd9 100644 --- a/internal/client/control/client_test.go +++ b/internal/client/control/client_test.go @@ -2,7 +2,10 @@ package control import ( "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "testing" . "github.com/smartystreets/goconvey/convey" @@ -37,3 +40,42 @@ func TestResolveBaseURL(t *testing.T) { }) }) } + +// captureCalls 起一个模拟控制服务,记录每次 /control/call 的原始请求体并回 ok。 +func captureCalls(t *testing.T) (*Client, *[]map[string]json.RawMessage) { + t.Helper() + var bodies []map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]json.RawMessage + _ = json.NewDecoder(r.Body).Decode(&body) + bodies = append(bodies, body) + _, _ = w.Write([]byte(`{"ok":true,"result":{"tabId":1}}`)) + })) + t.Cleanup(srv.Close) + return &Client{http: srv.Client(), base: srv.URL, controlToken: "t"}, &bodies +} + +func TestCallCarriesTargetBrowser(t *testing.T) { + Convey("控制客户端把目标浏览器随 /control/call 发给 daemon", t, func() { + c, bodies := captureCalls(t) + + Convey("指定目标时请求体带 browser", func() { + res, err := c.CallBrowser(context.Background(), "tabs.open", "edge-fedc", json.RawMessage(`{"url":"https://example.com/"}`)) + So(err, ShouldBeNil) + So(res.OK, ShouldBeTrue) + So(string(res.Result), ShouldEqual, `{"tabId":1}`) + So(*bodies, ShouldHaveLength, 1) + So(string((*bodies)[0]["action"]), ShouldEqual, `"tabs.open"`) + So(string((*bodies)[0]["browser"]), ShouldEqual, `"edge-fedc"`) + }) + + Convey("不指定目标时请求体不带 browser 字段", func() { + _, err := c.Call(context.Background(), "scripts.list", nil) + So(err, ShouldBeNil) + So(*bodies, ShouldHaveLength, 1) + _, has := (*bodies)[0]["browser"] + So(has, ShouldBeFalse) + So(string((*bodies)[0]["input"]), ShouldEqual, `{}`) + }) + }) +} diff --git a/internal/client/control/protocol.go b/internal/client/control/protocol.go index 8f84b5c..2fcb4ed 100644 --- a/internal/client/control/protocol.go +++ b/internal/client/control/protocol.go @@ -39,11 +39,15 @@ const CLIClientID = "sctl-cli" // CallRequest 是 /control/call 的请求体:转发一次 bridge action 调用。 type CallRequest struct { - Action string `json:"action"` - Input json.RawMessage `json:"input"` + Action string `json:"action"` + // Browser 是浏览器方法的可选目标:实例名称或实例 ID 前缀,由 daemon 解析(docs/protocol.md §3.1)。 + Browser string `json:"browser,omitempty"` + Input json.RawMessage `json:"input"` } // CallResult 是 /control/call 的响应体,映射桥接的 JSON-RPC result/error。 +// 列表类浏览器方法未指定目标且多个浏览器在线时,Result 是各实例结果按 mergeField 拼接后的汇总, +// 该数组的每一项多一个 "browser": {"id","name"} 字段;其余情况 Result 是单个对端的原始结果。 type CallResult struct { OK bool `json:"ok"` Result json.RawMessage `json:"result,omitempty"` diff --git a/internal/daemon/bridge/call.go b/internal/daemon/bridge/call.go index 3cac9c7..d1d86fe 100644 --- a/internal/daemon/bridge/call.go +++ b/internal/daemon/bridge/call.go @@ -2,12 +2,17 @@ package bridge import ( "context" + "encoding/json" "fmt" + "strings" + "sync" "time" "github.com/google/uuid" "go.uber.org/zap" + "github.com/scriptscat/sctl/internal/pkg/protocol" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" "github.com/scriptscat/sctl/internal/pkg/protocolschema" ) @@ -20,24 +25,50 @@ type pendingCall struct { respCh chan Response } -// Call 把一次 bridge action 转发给扩展并阻塞等待应答。write=true 的写调用可能挂起数分钟 -// (等待用户在浏览器审批)。调用方 ctx 取消 / 超过 writeDecisionTTL / 扩展断开时: -// - ctx 取消 / 超时:向扩展发 $/cancelRequest 作废该操作,返回相应错误; -// - 扩展断开:隐式作废全部在途请求,返回 ErrDisconnected。 +// Call 按方法归属把一次 bridge action 转发给对端并阻塞等待应答:scripts.* 发给 ScriptCat, +// 浏览器方法发给由 req.Browser 解析出的浏览器实例(见 resolveBrowsers)。write=true 的写调用可能挂起数分钟 +// (等待用户在浏览器审批)。调用方 ctx 取消 / 超过 writeDecisionTTL / 对端断开时: +// - ctx 取消 / 超时:向对端发 $/cancelRequest 作废该操作,返回相应错误; +// - 对端断开:隐式作废全部在途请求,返回 ErrDisconnected。 func (s *Server) Call(ctx context.Context, req Request) (Response, error) { - requestID := uuid.NewString() - - s.mu.Lock() - active := s.scriptCat - if active == nil { - s.mu.Unlock() - return Response{}, ErrNotConnected + action, ok := s.proto.Actions[req.Action] + if !ok { + return Response{}, &Error{Code: generated.ErrorCodeMethodNotFound, Message: "unknown method " + req.Action} } - if _, supported := active.capabilities[req.Action]; !supported { + switch action.Peer { + case protocol.PeerScriptCat: + if req.Browser != "" { + return Response{}, &Error{Code: CodeInvalidRequest, Message: "a target browser does not apply to " + req.Action} + } + s.mu.Lock() + active := s.scriptCat s.mu.Unlock() - return Response{}, &Error{Code: "METHOD_NOT_FOUND", Message: "extension does not support " + req.Action} + if active == nil { + return Response{}, ErrNotConnected + } + return s.callConn(ctx, active, req) + case protocol.PeerBrowser: + targets, err := s.resolveBrowsers(req.Browser, action.MergeField != "") + if err != nil { + return Response{}, err + } + if len(targets) == 1 { + return s.callConn(ctx, targets[0].conn, req) + } + return s.callMerged(ctx, targets, req, action.MergeField) + default: + panic("bridge: method " + req.Action + " has no owning peer " + string(action.Peer)) + } +} + +// callConn 把调用发给连接 c 并阻塞等待它的应答。 +func (s *Server) callConn(ctx context.Context, c *conn, req Request) (Response, error) { + if _, supported := c.capabilities[req.Action]; !supported { + return Response{}, &Error{Code: generated.ErrorCodeMethodNotFound, Message: "extension does not support " + req.Action} } - pc := &pendingCall{conn: active, clientID: req.ClientID, method: req.Action, respCh: make(chan Response, 1)} + requestID := uuid.NewString() + pc := &pendingCall{conn: c, clientID: req.ClientID, method: req.Action, respCh: make(chan Response, 1)} + s.mu.Lock() s.pending[requestID] = pc s.mu.Unlock() @@ -48,7 +79,7 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { }() params := businessParams{Input: req.Input, ClientID: req.ClientID} - if err := active.sendRequest(requestID, req.Action, params); err != nil { + if err := c.sendRequest(requestID, req.Action, params); err != nil { return Response{}, fmt.Errorf("send JSON-RPC request: %w", err) } @@ -59,12 +90,12 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { case resp := <-pc.respCh: return resp, nil case <-ctx.Done(): - s.cancelToExt(active, requestID) + s.cancelToExt(c, requestID) return Response{}, ctx.Err() case <-timer.C: - s.cancelToExt(active, requestID) + s.cancelToExt(c, requestID) return Response{}, &Error{Code: CodeOperationExpired, Message: "operation expired"} - case <-active.closed: + case <-c.closed: return Response{}, ErrDisconnected } } @@ -101,3 +132,158 @@ func (s *Server) handleRPCResponse(c *conn, message Message) { } pc.respCh <- Response{OK: false, Error: &Error{Code: domainCode, Message: message.Error.Message, OperationID: operationID}} } + +// browserTarget 是一次浏览器调用解析出的在线实例。 +type browserTarget struct { + id string + name string + conn *conn +} + +// browserRef 是汇总结果里每一项附带的来源浏览器。 +type browserRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// mergedItemBrowserField 是汇总结果里每一项新增的来源浏览器字段。 +const mergedItemBrowserField = "browser" + +// resolveBrowsers 在 daemon 侧解析浏览器调用的目标,只有这里知道哪些实例在线。 +// target 为空时按在线实例数选择:没有在线实例报 NO_BROWSER_CONNECTED;恰好一个就用它; +// 多个时列表类方法(mergeable)用全部在线实例,操作类方法报 BROWSER_AMBIGUOUS 并列出候选。 +// target 非空时先按名称精确匹配,再按实例 ID 前缀匹配。返回的目标按名称排序,汇总结果沿用这个顺序。 +func (s *Server) resolveBrowsers(target string, mergeable bool) ([]browserTarget, error) { + var registered []browserTarget + if s.browsers != nil { + for _, inst := range s.browsers.List() { + registered = append(registered, browserTarget{id: inst.ID, name: inst.Name}) + } + } + s.mu.Lock() + for i := range registered { + registered[i].conn = s.online[registered[i].id] + } + s.mu.Unlock() + + if target == "" { + var online []browserTarget + for _, t := range registered { + if t.conn != nil { + online = append(online, t) + } + } + switch { + case len(online) == 0: + return nil, &Error{Code: generated.ErrorCodeNoBrowserConnected, Message: "no browser connected"} + case len(online) == 1 || mergeable: + return online, nil + default: + return nil, &Error{Code: generated.ErrorCodeBrowserAmbiguous, Message: "several browsers are online, choose a target browser: " + describeTargets(online)} + } + } + + matched := matchTarget(registered, target) + switch len(matched) { + case 0: + return nil, &Error{Code: generated.ErrorCodeBrowserNotFound, Message: "no paired browser matches " + target} + case 1: + default: + return nil, &Error{Code: generated.ErrorCodeBrowserAmbiguous, Message: "browser ID prefix " + target + " matches several paired browsers: " + describeTargets(matched)} + } + if matched[0].conn == nil { + return nil, &Error{Code: generated.ErrorCodeBrowserOffline, Message: "browser " + describeTargets(matched) + " is not connected"} + } + return matched, nil +} + +// matchTarget 返回 target 指向的已配对实例:名称精确匹配优先(名称也可能形如 ID 前缀),否则取实例 ID 前缀匹配的全部实例。 +func matchTarget(registered []browserTarget, target string) []browserTarget { + for _, t := range registered { + if t.name == target { + return []browserTarget{t} + } + } + var byPrefix []browserTarget + for _, t := range registered { + if strings.HasPrefix(t.id, target) { + byPrefix = append(byPrefix, t) + } + } + return byPrefix +} + +func describeTargets(targets []browserTarget) string { + parts := make([]string, len(targets)) + for i, t := range targets { + parts[i] = t.name + " (" + t.id + ")" + } + return strings.Join(parts, ", ") +} + +// callMerged 把列表类调用并发发给每个目标,并把各结果的 mergeField 数组按目标顺序拼接。 +// 任一目标失败都使整次调用失败,不返回部分结果:按目标顺序取第一个传输错误,其次第一个对端错误。 +func (s *Server) callMerged(ctx context.Context, targets []browserTarget, req Request, mergeField string) (Response, error) { + responses := make([]Response, len(targets)) + errs := make([]error, len(targets)) + var wg sync.WaitGroup + for i, t := range targets { + wg.Add(1) + go func() { + defer wg.Done() + responses[i], errs[i] = s.callConn(ctx, t.conn, req) + }() + } + wg.Wait() + + for _, err := range errs { + if err != nil { + return Response{}, err + } + } + for _, resp := range responses { + if !resp.OK { + return resp, nil + } + } + merged, err := mergeResults(targets, responses, mergeField) + if err != nil { + return Response{}, err + } + return Response{OK: true, Result: merged}, nil +} + +// mergeResults 以第一个结果为底,把各结果 mergeField 数组的每一项标上来源浏览器后拼接。 +// 各项保持原始 JSON,不经数值往返。结果已按方法 schema 校验过,mergeField 必是对象数组; +// 解析失败说明协议定义与此处假设不符。 +func mergeResults(targets []browserTarget, responses []Response, mergeField string) (json.RawMessage, error) { + var base map[string]json.RawMessage + if err := json.Unmarshal(responses[0].Result, &base); err != nil { + return nil, fmt.Errorf("merge %s: %w", mergeField, err) + } + items := []map[string]json.RawMessage{} + for i, resp := range responses { + ref, err := json.Marshal(browserRef{ID: targets[i].id, Name: targets[i].name}) + if err != nil { + return nil, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(resp.Result, &fields); err != nil { + return nil, fmt.Errorf("merge %s: %w", mergeField, err) + } + var list []map[string]json.RawMessage + if err := json.Unmarshal(fields[mergeField], &list); err != nil { + return nil, fmt.Errorf("merge %s: %w", mergeField, err) + } + for _, item := range list { + item[mergedItemBrowserField] = ref + items = append(items, item) + } + } + raw, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("merge %s: %w", mergeField, err) + } + base[mergeField] = raw + return json.Marshal(base) +} diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go index a4374eb..d9031c0 100644 --- a/internal/daemon/bridge/envelope.go +++ b/internal/daemon/bridge/envelope.go @@ -114,9 +114,11 @@ type businessParams struct { } type Request struct { - ClientID string `json:"clientId"` - Action string `json:"action"` - Input json.RawMessage `json:"input"` + ClientID string `json:"clientId"` + Action string `json:"action"` + // Browser 是浏览器方法的目标实例:名称或实例 ID 前缀,空表示按在线实例自动选择。 + Browser string `json:"browser,omitempty"` + Input json.RawMessage `json:"input"` } type Response struct { diff --git a/internal/daemon/bridge/routing_test.go b/internal/daemon/bridge/routing_test.go new file mode 100644 index 0000000..ceab38e --- /dev/null +++ b/internal/daemon/bridge/routing_test.go @@ -0,0 +1,126 @@ +package bridge + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/coder/websocket/wsjson" + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" +) + +const tabsListResult = `{"tabs":[],"contentTrust":"untrusted-page-content"}` + +type callOutcome struct { + resp Response + err error +} + +// callTimeout 限定测试调用的等待:路由错时请求发给了不会应答的对端,测试应失败而不是挂起。 +const callTimeout = 3 * time.Second + +func (h *testHarness) call(req Request) (Response, error) { + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + return h.srv.Call(ctx, req) +} + +func (h *testHarness) goCall(req Request) <-chan callOutcome { + out := make(chan callOutcome, 1) + go func() { + resp, err := h.call(req) + out <- callOutcome{resp, err} + }() + return out +} + +func errorCode(err error) string { + var be *Error + if !errors.As(err, &be) { + return "" + } + return be.Code +} + +func TestCallsRouteByMethodOwnership(t *testing.T) { + Convey("调用按方法归属路由,与对端声明了哪些方法无关", t, func() { + h := startTestServer(t) + // ScriptCat 在测试里声明了协议全部方法(含浏览器方法),路由仍只看方法归属。 + sc := h.connectScriptCat() + + Convey("没有浏览器在线时,浏览器方法不会发给 ScriptCat,而是返回 NO_BROWSER_CONNECTED", func() { + _, err := h.call(Request{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeNoBrowserConnected) + sc.alive() + }) + + Convey("浏览器在线时,浏览器方法发给浏览器实例,scripts.* 仍发给 ScriptCat", func() { + keyA := h.registerBrowser(instanceA, "chrome-0123") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + + out := h.goCall(Request{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + req := a.read() + So(req.Method, ShouldEqual, "tabs.list") + a.writeResult(req.ID, json.RawMessage(tabsListResult)) + got := <-out + So(got.err, ShouldBeNil) + So(string(got.resp.Result), ShouldEqual, tabsListResult) + + h.scriptsListWorks(sc) + }) + + Convey("scripts.* 调用指定目标浏览器时被拒为 INVALID_REQUEST,不转发", func() { + keyA := h.registerBrowser(instanceA, "chrome-0123") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + _, err := h.call(Request{Action: "scripts.list", Browser: "chrome-0123", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, CodeInvalidRequest) + sc.alive() + a.alive() + }) + }) +} + +func TestBrowserTargetNamePrecedesIDPrefix(t *testing.T) { + Convey("目标先按名称精确匹配:形如 ID 前缀的名称指向该名称的实例", t, func() { + h := startTestServer(t) + keyA := h.registerBrowser(instanceA, "chrome-0123") + keyB := h.registerBrowser(instanceB, "0123") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + b := h.connectBrowser(instanceB, keyB, "0123") + + out := h.goCall(Request{Action: "tabs.open", Browser: "0123", Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + req := b.read() + So(req.Method, ShouldEqual, "tabs.open") + b.writeResult(req.ID, json.RawMessage(`{"tabId":1}`)) + So((<-out).err, ShouldBeNil) + a.alive() + }) +} + +func TestMergedCallFailsWhenAnyInstanceFails(t *testing.T) { + Convey("汇总调用中任一实例回错误时整次调用返回该错误,不返回部分结果", t, func() { + h := startTestServer(t) + keyA := h.registerBrowser(instanceA, "chrome-0123") + keyB := h.registerBrowser(instanceB, "edge-fedc") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + b := h.connectBrowser(instanceB, keyB, "edge-fedc") + + out := h.goCall(Request{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + reqA := a.read() + reqB := b.read() + a.writeResult(reqA.ID, json.RawMessage(tabsListResult)) + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + So(wsjson.Write(ctx, b.ws, newError(reqB.ID, CodeInternal, "tabs unavailable")), ShouldBeNil) + + got := <-out + So(got.err, ShouldBeNil) + So(got.resp.OK, ShouldBeFalse) + So(got.resp.Error.Code, ShouldEqual, CodeInternal) + So(got.resp.Result, ShouldBeNil) + }) +} diff --git a/internal/daemon/controlapi/controlapi.go b/internal/daemon/controlapi/controlapi.go index 0e6feb6..6756bb2 100644 --- a/internal/daemon/controlapi/controlapi.go +++ b/internal/daemon/controlapi/controlapi.go @@ -110,6 +110,7 @@ func (h *Handler) call(w http.ResponseWriter, r *http.Request) { resp, err := h.bridge.Call(r.Context(), bridge.Request{ ClientID: clientID, Action: req.Action, + Browser: req.Browser, Input: req.Input, }) switch { diff --git a/internal/daemon/controlapi/harness_test.go b/internal/daemon/controlapi/harness_test.go index 8ddca86..b8cb7de 100644 --- a/internal/daemon/controlapi/harness_test.go +++ b/internal/daemon/controlapi/harness_test.go @@ -45,9 +45,15 @@ type authChallengePayload struct { } type authResponsePayload struct { - Mode string `json:"mode"` - NonceE string `json:"nonceE"` - HMAC string `json:"hmac"` + Mode string `json:"mode"` + NonceE string `json:"nonceE"` + HMAC string `json:"hmac"` + Peer *authPeerPayload `json:"peer,omitempty"` +} + +type authPeerPayload struct { + Kind string `json:"kind"` + InstanceID string `json:"instanceId"` } type authOKPayload struct { @@ -65,11 +71,12 @@ type cancelParams struct { // testHarness 承载一个运行中的 daemon(WS 面 + 控制 API)与用于对拍的密码学助手/存储。 type testHarness struct { - srv *bridge.Server - crypto *auth.Crypto - keys *store.KeyStore - url string - proto *protocol.Protocol + srv *bridge.Server + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + url string + proto *protocol.Protocol } func startTestServer(t *testing.T) *testHarness { @@ -78,7 +85,9 @@ func startTestServer(t *testing.T) *testHarness { So(err, ShouldBeNil) dir := t.TempDir() keys := store.NewKeyStore(filepath.Join(dir, "pairing.key")) - srv := bridge.NewServer(testVersion, p, keys, zap.NewNop()) + browsers, err := store.LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) + So(err, ShouldBeNil) + srv := bridge.NewServer(testVersion, p, keys, zap.NewNop(), bridge.WithBrowserRegistry(browsers)) mux := http.NewServeMux() New(srv, testControlToken, zap.NewNop()).Register(mux) @@ -90,11 +99,12 @@ func startTestServer(t *testing.T) *testHarness { go func() { _ = srv.Serve(ctx, ln, mux) }() return &testHarness{ - srv: srv, - crypto: auth.NewCrypto(p), - keys: keys, - url: "ws://" + ln.Addr().String() + "/", - proto: p, + srv: srv, + crypto: auth.NewCrypto(p), + keys: keys, + browsers: browsers, + url: "ws://" + ln.Addr().String() + "/", + proto: p, } } @@ -185,6 +195,44 @@ func (h *testHarness) doSessionHandshake(key []byte) *extClient { return e } +// browserMethods 是浏览器实例在能力声明里给出的方法。 +var browserMethods = []string{"tabs.list", "tabs.open", "tabs.close", "tabs.activate", "windows.list"} + +// pairedBrowser 在登记表里放一个已配对实例并返回其密钥;不连接。 +func (h *testHarness) pairedBrowser(id, name string) []byte { + key, err := auth.NewLongTermKey() + So(err, ShouldBeNil) + So(h.browsers.Pair(store.BrowserInstance{ID: id, Name: name, Key: key}), ShouldBeNil) + return key +} + +// connectBrowser 配对并按线上协议连接一个浏览器实例,返回完成能力声明的连接。 +func (h *testHarness) connectBrowser(id, name string) *extClient { + key := h.pairedBrowser(id, name) + e := dial(h.url) + challenge := e.read() + So(challenge.Method, ShouldEqual, methodAuthenticate) + var cp authChallengePayload + So(json.Unmarshal(challenge.Params, &cp), ShouldBeNil) + + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModeSession, id, key, cp.NonceD, nonceE) + e.writeResult(challenge.ID, authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: &authPeerPayload{Kind: "browser", InstanceID: id}}) + So(e.read().Method, ShouldEqual, methodAuthenticated) + So(e.read().Method, ShouldEqual, methodHello) + + capabilitiesID := uuid.NewString() + e.writeRequest(methodCapabilities, capabilitiesID, map[string]any{ + "schemaVersion": "1.0.0", + "methods": browserMethods, + "peer": map[string]any{"name": name}, + }) + reply := e.read() + So(reply.ID, ShouldEqual, capabilitiesID) + So(reply.Error, ShouldBeNil) + return e +} + // postControl 发起一次控制 API 请求(可选控制令牌 / 客户端标签)。body 为 nil 时用 GET。 func postControl(ctx context.Context, base, path, controlToken, clientLabel string, body any) (*http.Response, error) { var r io.Reader diff --git a/internal/daemon/controlapi/routing_test.go b/internal/daemon/controlapi/routing_test.go new file mode 100644 index 0000000..d51b573 --- /dev/null +++ b/internal/daemon/controlapi/routing_test.go @@ -0,0 +1,264 @@ +package controlapi + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/client/control" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" +) + +const ( + instanceA = "0123456789abcdef0123456789abcdef" + instanceB = "fedcba9876543210fedcba9876543210" + // instanceC 与 instanceA 共享前缀 "0123",用于前缀歧义。 + instanceC = "0123aaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +// callTimeout 限定一次控制调用的等待:路由错时请求落到不会应答的对端,测试应失败而不是挂起。 +const callTimeout = 3 * time.Second + +func (h *testHarness) goCall(req control.CallRequest) <-chan control.CallResult { + out := make(chan control.CallResult, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + resp, err := postControl(ctx, h.httpBase(), control.PathCall, testControlToken, "", req) + if err != nil { + out <- control.CallResult{Error: &control.CallError{Code: "TEST_TRANSPORT", Message: err.Error()}} + return + } + defer resp.Body.Close() + var res control.CallResult + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + res = control.CallResult{Error: &control.CallError{Code: "TEST_DECODE", Message: err.Error()}} + } + out <- res + }() + return out +} + +func (h *testHarness) callControl(req control.CallRequest) control.CallResult { + return <-h.goCall(req) +} + +func tabsResult(tabID int) json.RawMessage { + return json.RawMessage(fmt.Sprintf(`{"tabs":[{"tabId":%d,"windowId":1,"active":true,"pinned":false,"title":"t","url":"https://example.com/"}],"contentTrust":"untrusted-page-content"}`, tabID)) +} + +// answer 读取发到 e 的下一条业务请求,断言方法后以 result 应答。 +func (e *extClient) answer(method string, result json.RawMessage) { + req := e.read() + So(req.Method, ShouldEqual, method) + e.writeResult(req.ID, result) +} + +// idle 证明 e 没有收到任何业务请求:对端自发 ping 的应答是它读到的下一条消息。 +func (e *extClient) idle() { + id := "idle-probe" + e.writeRequest("$session.ping", id, struct{}{}) + So(e.read().ID, ShouldEqual, id) +} + +func errCode(res control.CallResult) string { + if res.Error == nil { + return "" + } + return res.Error.Code +} + +type mergedTabs struct { + Tabs []struct { + TabID int `json:"tabId"` + Browser struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"browser"` + } `json:"tabs"` + ContentTrust string `json:"contentTrust"` +} + +func TestBrowserTargetSelection(t *testing.T) { + Convey("浏览器方法按在线实例数与目标选择路由", t, func() { + h := startTestServer(t) + key, err := newKeyAndSave(h) + So(err, ShouldBeNil) + // ScriptCat 声明了协议全部方法,浏览器方法也不会发给它。 + sc := h.doSessionHandshake(key) + h.pairedBrowser(instanceC, "work") + + Convey("没有浏览器在线:列表类与操作类都返回 NO_BROWSER_CONNECTED", func() { + So(errCode(h.callControl(control.CallRequest{Action: "tabs.list", Input: json.RawMessage(`{}`)})), ShouldEqual, generated.ErrorCodeNoBrowserConnected) + So(errCode(h.callControl(control.CallRequest{Action: "tabs.open", Input: json.RawMessage(`{"url":"https://example.com/"}`)})), ShouldEqual, generated.ErrorCodeNoBrowserConnected) + sc.idle() + }) + + Convey("恰好一个在线:列表类与操作类都使用它,结果原样返回", func() { + a := h.connectBrowser(instanceA, "chrome-0123") + + ch := h.goCall(control.CallRequest{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + a.answer("tabs.list", tabsResult(1)) + res := <-ch + So(res.OK, ShouldBeTrue) + So(string(res.Result), ShouldEqual, string(tabsResult(1))) + + ch = h.goCall(control.CallRequest{Action: "tabs.open", Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + a.answer("tabs.open", json.RawMessage(`{"tabId":7}`)) + res = <-ch + So(res.OK, ShouldBeTrue) + So(string(res.Result), ShouldEqual, `{"tabId":7}`) + sc.idle() + }) + + Convey("多个在线", func() { + a := h.connectBrowser(instanceA, "chrome-0123") + b := h.connectBrowser(instanceB, "edge-fedc") + + Convey("未指定目标的列表类调用汇总所有在线浏览器,每项带浏览器名称与 ID", func() { + ch := h.goCall(control.CallRequest{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + b.answer("tabs.list", tabsResult(2)) + a.answer("tabs.list", tabsResult(1)) + res := <-ch + So(res.OK, ShouldBeTrue) + var merged mergedTabs + So(json.Unmarshal(res.Result, &merged), ShouldBeNil) + So(merged.ContentTrust, ShouldEqual, "untrusted-page-content") + So(merged.Tabs, ShouldHaveLength, 2) + So(merged.Tabs[0].TabID, ShouldEqual, 1) + So(merged.Tabs[0].Browser.Name, ShouldEqual, "chrome-0123") + So(merged.Tabs[0].Browser.ID, ShouldEqual, instanceA) + So(merged.Tabs[1].TabID, ShouldEqual, 2) + So(merged.Tabs[1].Browser.Name, ShouldEqual, "edge-fedc") + So(merged.Tabs[1].Browser.ID, ShouldEqual, instanceB) + }) + + Convey("汇总按方法声明的合并字段进行,windows.list 同样汇总", func() { + ch := h.goCall(control.CallRequest{Action: "windows.list", Input: json.RawMessage(`{}`)}) + a.answer("windows.list", json.RawMessage(`{"windows":[{"windowId":1,"focused":true,"state":"normal","tabCount":3}]}`)) + b.answer("windows.list", json.RawMessage(`{"windows":[{"windowId":9,"focused":false,"state":"minimized","tabCount":1},{"windowId":10,"focused":false,"state":"normal","tabCount":2}]}`)) + res := <-ch + So(res.OK, ShouldBeTrue) + var merged struct { + Windows []struct { + WindowID int `json:"windowId"` + Browser struct { + Name string `json:"name"` + } `json:"browser"` + } `json:"windows"` + } + So(json.Unmarshal(res.Result, &merged), ShouldBeNil) + So(merged.Windows, ShouldHaveLength, 3) + So(merged.Windows[0].Browser.Name, ShouldEqual, "chrome-0123") + So(merged.Windows[2].WindowID, ShouldEqual, 10) + So(merged.Windows[2].Browser.Name, ShouldEqual, "edge-fedc") + }) + + Convey("未指定目标的操作类调用返回 BROWSER_AMBIGUOUS 并列出在线候选,不转发", func() { + res := h.callControl(control.CallRequest{Action: "tabs.open", Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + So(errCode(res), ShouldEqual, generated.ErrorCodeBrowserAmbiguous) + So(res.Error.Message, ShouldContainSubstring, "chrome-0123") + So(res.Error.Message, ShouldContainSubstring, instanceA) + So(res.Error.Message, ShouldContainSubstring, "edge-fedc") + So(res.Error.Message, ShouldContainSubstring, instanceB) + So(res.Error.Message, ShouldNotContainSubstring, "work") + a.idle() + b.idle() + }) + + Convey("按名称指定目标的列表类调用只发给目标,结果原样返回", func() { + ch := h.goCall(control.CallRequest{Action: "tabs.list", Browser: "edge-fedc", Input: json.RawMessage(`{}`)}) + b.answer("tabs.list", tabsResult(2)) + res := <-ch + So(res.OK, ShouldBeTrue) + So(string(res.Result), ShouldEqual, string(tabsResult(2))) + a.idle() + }) + + Convey("按实例 ID 前缀指定目标的操作类调用发给该实例", func() { + ch := h.goCall(control.CallRequest{Action: "tabs.open", Browser: "fed", Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + b.answer("tabs.open", json.RawMessage(`{"tabId":3}`)) + So((<-ch).OK, ShouldBeTrue) + a.idle() + }) + + Convey("完整实例 ID 指定目标", func() { + ch := h.goCall(control.CallRequest{Action: "tabs.open", Browser: instanceA, Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + a.answer("tabs.open", json.RawMessage(`{"tabId":4}`)) + So((<-ch).OK, ShouldBeTrue) + b.idle() + }) + + Convey("目标是已配对但离线的实例:BROWSER_OFFLINE", func() { + res := h.callControl(control.CallRequest{Action: "tabs.list", Browser: "work", Input: json.RawMessage(`{}`)}) + So(errCode(res), ShouldEqual, generated.ErrorCodeBrowserOffline) + So(res.Error.Message, ShouldContainSubstring, "work") + }) + + Convey("目标不对应任何已配对实例:BROWSER_NOT_FOUND", func() { + So(errCode(h.callControl(control.CallRequest{Action: "tabs.open", Browser: "nope", Input: json.RawMessage(`{"url":"https://example.com/"}`)})), ShouldEqual, generated.ErrorCodeBrowserNotFound) + a.idle() + b.idle() + }) + + Convey("实例 ID 前缀匹配多个已配对实例:BROWSER_AMBIGUOUS 并列出匹配的实例", func() { + res := h.callControl(control.CallRequest{Action: "tabs.list", Browser: "0123", Input: json.RawMessage(`{}`)}) + So(errCode(res), ShouldEqual, generated.ErrorCodeBrowserAmbiguous) + So(res.Error.Message, ShouldContainSubstring, instanceA) + So(res.Error.Message, ShouldContainSubstring, instanceC) + So(res.Error.Message, ShouldNotContainSubstring, instanceB) + a.idle() + }) + }) + }) +} + +func TestBrowserDisconnectVoidsCall(t *testing.T) { + Convey("调用进行中目标浏览器断开 → OPERATION_EXPIRED", t, func() { + h := startTestServer(t) + a := h.connectBrowser(instanceA, "chrome-0123") + + Convey("单个目标", func() { + ch := h.goCall(control.CallRequest{Action: "tabs.open", Input: json.RawMessage(`{"url":"https://example.com/"}`)}) + So(a.read().Method, ShouldEqual, "tabs.open") + a.ws.CloseNow() + So(errCode(<-ch), ShouldEqual, generated.ErrorCodeOperationExpired) + }) + + Convey("汇总中的一个实例断开时整次调用作废", func() { + b := h.connectBrowser(instanceB, "edge-fedc") + ch := h.goCall(control.CallRequest{Action: "tabs.list", Input: json.RawMessage(`{}`)}) + a.answer("tabs.list", tabsResult(1)) + So(b.read().Method, ShouldEqual, "tabs.list") + b.ws.CloseNow() + So(errCode(<-ch), ShouldEqual, generated.ErrorCodeOperationExpired) + }) + }) +} + +func TestScriptsRoutingUnchangedWithBrowsers(t *testing.T) { + Convey("scripts.* 路由与既有错误不受浏览器实例影响", t, func() { + h := startTestServer(t) + a := h.connectBrowser(instanceA, "chrome-0123") + + Convey("ScriptCat 未连接时 scripts.* 仍返回 INTERNAL_ERROR,不发给浏览器", func() { + res := h.callControl(control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + So(errCode(res), ShouldEqual, generated.ErrorCodeInternalError) + a.idle() + }) + + Convey("scripts.* 指定目标浏览器被拒为 INVALID_REQUEST", func() { + key, err := newKeyAndSave(h) + So(err, ShouldBeNil) + sc := h.doSessionHandshake(key) + res := h.callControl(control.CallRequest{Action: "scripts.list", Browser: "chrome-0123", Input: json.RawMessage(`{}`)}) + So(errCode(res), ShouldEqual, generated.ErrorCodeInvalidRequest) + sc.idle() + a.idle() + }) + }) +} From 4c6a147e071a341ff498f14b50a8b21d238feae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:40:48 +0800 Subject: [PATCH 06/25] feat: add sctl browsers commands and browser status --- internal/cli/browsers.go | 110 +++++++++++++ internal/cli/browsers_test.go | 149 ++++++++++++++++++ internal/cli/cli.go | 1 + internal/cli/cli_test.go | 68 ++++++++ internal/cli/connect.go | 9 +- internal/cli/status.go | 10 ++ internal/client/control/client.go | 53 +++++++ internal/client/control/client_test.go | 58 +++++++ internal/client/control/protocol.go | 36 ++++- internal/daemon/controlapi/controlapi.go | 48 ++++++ internal/daemon/controlapi/controlapi_test.go | 137 ++++++++++++++++ 11 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 internal/cli/browsers.go create mode 100644 internal/cli/browsers_test.go diff --git a/internal/cli/browsers.go b/internal/cli/browsers.go new file mode 100644 index 0000000..7f5510c --- /dev/null +++ b/internal/cli/browsers.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/scriptscat/sctl/internal/client/control" +) + +// newBrowsersCmd 构造 `sctl browsers`:裸调用等价于 `browsers list`,另挂 forget 子命令。 +func newBrowsersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "browsers [list]", + Short: "List paired sctl Browser instances (default), or forget one with the forget subcommand", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runBrowsersList(cmd) + }, + } + cmd.AddCommand(newBrowsersListCmd(), newBrowsersForgetCmd()) + return cmd +} + +func newBrowsersListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List paired sctl Browser instances", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runBrowsersList(cmd) + }, + } +} + +func runBrowsersList(cmd *cobra.Command) error { + ctx := cmd.Context() + client, err := control.Connect(ctx) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + list, err := client.Browsers(ctx) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + if outputFormat == outputJSON { + return printValueJSON(list) + } + return printBrowsersTable(list) +} + +// newBrowsersForgetCmd 构造 `sctl browsers forget <名称|ID>`:仅命令行,无 MCP 工具(见 spec 第 1 期 +// 命令表)。按精确名称或精确实例 ID 删除该实例的密钥与登记表项,在线时先断开连接。 +func newBrowsersForgetCmd() *cobra.Command { + return &cobra.Command{ + Use: "forget ", + Short: "Forget a paired browser instance: delete its key and registry entry, disconnecting it if online", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := control.Dial(ctx) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + ref := args[0] + if err := client.ForgetBrowser(ctx, ref); err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + if outputFormat == outputJSON { + return printValueJSON(map[string]string{"forgot": ref}) + } + fmt.Fprintf(os.Stdout, "forgot browser instance: %s\n", ref) + return nil + }, + } +} + +func printBrowsersTable(list []control.BrowserInfo) error { + if len(list) == 0 { + fmt.Fprintln(os.Stdout, "(no paired browser instances)") + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tID\tSTATUS\tPRODUCT\tEXTENSION\tCONNECTED SINCE") + for _, b := range list { + status := "offline" + connected := "-" + if b.Online { + status = "online" + connected = b.ConnectedAt.Local().Format(time.RFC3339) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.ID, status, productLabel(b), b.ExtensionVersion, connected) + } + return tw.Flush() +} + +// productLabel 拼出「品牌 版本」,任一为空时原样返回另一个(避免多余空格)。 +func productLabel(b control.BrowserInfo) string { + switch { + case b.Product == "": + return b.ProductVersion + case b.ProductVersion == "": + return b.Product + default: + return fmt.Sprintf("%s %s", b.Product, b.ProductVersion) + } +} diff --git a/internal/cli/browsers_test.go b/internal/cli/browsers_test.go new file mode 100644 index 0000000..03b2262 --- /dev/null +++ b/internal/cli/browsers_test.go @@ -0,0 +1,149 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/client/control" +) + +// stubDaemonBrowsers 起一个假 daemon:/control/browsers 恒返回给定的已配对实例列表。 +func stubDaemonBrowsers(t *testing.T, list []control.BrowserInfo) { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(control.PathBrowsers, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(control.BrowsersResult{Browsers: list}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://")) + dir := t.TempDir() + t.Setenv("SCTL_DATA_DIR", dir) + So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil) +} + +// stubDaemonForget 起一个假 daemon:/control/browsers/forget 恒返回给定结果,并记录收到的引用。 +func stubDaemonForget(t *testing.T, result control.CallResult) *control.ForgetBrowserRequest { + t.Helper() + captured := &control.ForgetBrowserRequest{} + mux := http.NewServeMux() + mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(control.PathBrowserForget, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(captured) + _ = json.NewEncoder(w).Encode(result) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://")) + dir := t.TempDir() + t.Setenv("SCTL_DATA_DIR", dir) + So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil) + return captured +} + +func TestBrowsersListTable(t *testing.T) { + Convey("sctl browsers 列出已配对浏览器实例的表格", t, func() { + Convey("在线与离线实例各一,表格含名称、ID、状态、品牌版本、扩展版本、连接时间", func() { + connectedAt := time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC) + stubDaemonBrowsers(t, []control.BrowserInfo{ + {ID: "0123456789abcdef0123456789abcdef", Name: "chrome-0123", Online: true, Product: "Chrome", ProductVersion: "128.0", ExtensionVersion: "0.1.0", ConnectedAt: connectedAt}, + {ID: "fedcba9876543210fedcba9876543210", Name: "edge-fedc", Online: false}, + }) + + code, out := runCLI("browsers") + + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "chrome-0123") + So(out, ShouldContainSubstring, "0123456789abcdef0123456789abcdef") + So(out, ShouldContainSubstring, "online") + So(out, ShouldContainSubstring, "Chrome") + So(out, ShouldContainSubstring, "128.0") + So(out, ShouldContainSubstring, "0.1.0") + So(out, ShouldContainSubstring, "edge-fedc") + So(out, ShouldContainSubstring, "offline") + }) + + Convey("裸调用与显式 list 子命令输出相同", func() { + stubDaemonBrowsers(t, []control.BrowserInfo{{ID: "abc", Name: "chrome-a", Online: true}}) + + codeBare, outBare := runCLI("browsers") + codeList, outList := runCLI("browsers", "list") + + So(codeBare, ShouldEqual, exitOK) + So(codeList, ShouldEqual, exitOK) + So(outBare, ShouldEqual, outList) + }) + + Convey("没有已配对实例时给出提示而不是空表头", func() { + stubDaemonBrowsers(t, nil) + + code, out := runCLI("browsers") + + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "no paired browser") + }) + }) +} + +func TestBrowsersListJSON(t *testing.T) { + Convey("-o json 输出完整实例列表供脚本消费", t, func() { + stubDaemonBrowsers(t, []control.BrowserInfo{ + {ID: "abc", Name: "chrome-a", Online: true, Product: "Chrome", ProductVersion: "128.0", ExtensionVersion: "0.1.0"}, + }) + + code, out := runCLI("browsers", "-o", "json") + + So(code, ShouldEqual, exitOK) + var got []control.BrowserInfo + So(json.Unmarshal([]byte(out), &got), ShouldBeNil) + So(got, ShouldHaveLength, 1) + So(got[0].Name, ShouldEqual, "chrome-a") + So(got[0].Online, ShouldBeTrue) + }) +} + +func TestBrowsersForget(t *testing.T) { + Convey("sctl browsers forget 删除一个已配对实例", t, func() { + Convey("daemon 确认删除 → 退出码 0,请求带上该引用", func() { + captured := stubDaemonForget(t, control.CallResult{OK: true}) + + code, out := runCLI("browsers", "forget", "chrome-a") + + So(code, ShouldEqual, exitOK) + So(captured.Ref, ShouldEqual, "chrome-a") + So(out, ShouldContainSubstring, "chrome-a") + }) + + Convey("目标不匹配任何已配对实例 → 退出码 3", func() { + stubDaemonForget(t, control.CallResult{OK: false, Error: &control.CallError{Code: "NOT_FOUND", Message: "no paired browser instance matches nope"}}) + + code, _ := runCLI("browsers", "forget", "nope") + + So(code, ShouldEqual, exitError) + }) + + Convey("daemon 不可达 → 退出码 3", func() { + t.Setenv("SCTL_BRIDGE_ADDR", "127.0.0.1:1") + t.Setenv("SCTL_DATA_DIR", t.TempDir()) + + code, _ := runCLI("browsers", "forget", "chrome-a") + + So(code, ShouldEqual, exitError) + }) + }) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bde6a0c..6b4dca4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -91,6 +91,7 @@ func NewRootCmd() *cobra.Command { newMcpCmd(), newConnectCmd(), newStatusCmd(), + newBrowsersCmd(), newVersionCmd(), newGetCmd(), newGrepCmd(), diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d862312..18b1f5e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -212,6 +212,74 @@ func TestStatusSecurityEvents(t *testing.T) { }) } +func TestStatusListsBrowserInstances(t *testing.T) { + Convey("status 额外列出已配对浏览器实例及其在线状态,ScriptCat 字段不变", t, func() { + Convey("人读输出按名称列出在线/离线实例,并保留原有 ScriptCat 字段", func() { + stubDaemonStatus(t, control.StatusResult{ + DaemonVersion: "0.1.0", + ExtConnected: true, + Browsers: []control.BrowserInfo{ + {ID: "0123456789abcdef0123456789abcdef", Name: "chrome-0123", Online: true}, + {ID: "fedcba9876543210fedcba9876543210", Name: "edge-fedc", Online: false}, + }, + }) + code, out := runCLI("status") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "extension connected: true") + So(out, ShouldContainSubstring, "chrome-0123") + So(out, ShouldContainSubstring, "online") + So(out, ShouldContainSubstring, "edge-fedc") + So(out, ShouldContainSubstring, "offline") + }) + + Convey("没有已配对浏览器实例时不打印浏览器小节", func() { + stubDaemonStatus(t, control.StatusResult{DaemonVersion: "0.1.0"}) + code, out := runCLI("status") + So(code, ShouldEqual, exitOK) + So(out, ShouldNotContainSubstring, "browsers:") + }) + + Convey("-o json 的已配对浏览器实例可解析且携带在线状态", func() { + stubDaemonStatus(t, control.StatusResult{ + DaemonVersion: "0.1.0", + Browsers: []control.BrowserInfo{{ID: "abc", Name: "chrome-a", Online: true}}, + }) + code, out := runCLI("status", "-o", "json") + So(code, ShouldEqual, exitOK) + var got control.StatusResult + So(json.Unmarshal([]byte(out), &got), ShouldBeNil) + So(got.Browsers, ShouldHaveLength, 1) + So(got.Browsers[0].Name, ShouldEqual, "chrome-a") + So(got.Browsers[0].Online, ShouldBeTrue) + }) + }) +} + +func TestConnectMentionsBothExtensions(t *testing.T) { + Convey("connect 的配对文案同时覆盖 ScriptCat 与 sctl Browser 扩展", t, func() { + mux := http.NewServeMux() + mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(control.PathEnroll, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(control.EnrollResult{Code: "123-456"}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://")) + dir := t.TempDir() + t.Setenv("SCTL_DATA_DIR", dir) + So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil) + + code, out := runCLI("connect") + + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "123-456") + So(out, ShouldContainSubstring, "ScriptCat") + So(out, ShouldContainSubstring, "sctl Browser") + }) +} + func TestDataDirFlag(t *testing.T) { Convey("--data-dir 指定 CLI 与 daemon 共享的数据目录", t, func() { mux := http.NewServeMux() diff --git a/internal/cli/connect.go b/internal/cli/connect.go index b8fdca9..a2a5b7f 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -10,12 +10,13 @@ import ( "github.com/scriptscat/sctl/internal/client/control" ) -// newConnectCmd 打开一次接入窗口并打印一次性配对码,供用户输入到扩展的「外部接入」页面完成接入 -// (2 分钟内有效)。接入只需一次:此后 CLI 与所有 MCP agent 都经这条可信通道继承信任。 +// newConnectCmd 打开一次接入窗口并打印一次性配对码,同一个码可以配对 ScriptCat 或 sctl Browser +// 扩展(2 分钟内有效);两者的配对与握手各自独立,谁先输入谁先配对成功,窗口关闭前都能再配一个。 +// 接入只需一次:此后 CLI 与所有 MCP agent 都经这条可信通道继承信任。 func newConnectCmd() *cobra.Command { return &cobra.Command{ Use: "connect", - Short: "Generate a one-time pairing code to set up external access with the ScriptCat extension", + Short: "Generate a one-time pairing code to set up external access with the ScriptCat or sctl Browser extension", RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) defer stop() @@ -27,7 +28,7 @@ func newConnectCmd() *cobra.Command { if err != nil { return err } - fmt.Fprintf(os.Stdout, "pairing code: %s\nEnter it under \"Tools › External Access\" in the ScriptCat extension settings to finish connecting (valid for 2 minutes).\nThis code is shown only in this terminal — do not forward it.\n", code) + fmt.Fprintf(os.Stdout, "pairing code: %s\nEnter it under \"Tools › External Access\" in the ScriptCat extension settings, or in the sctl Browser extension's popup, to finish connecting (valid for 2 minutes).\nThis code is shown only in this terminal — do not forward it.\n", code) return nil }, } diff --git a/internal/cli/status.go b/internal/cli/status.go index dbde8f2..3c5c372 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -34,6 +34,16 @@ func newStatusCmd() *cobra.Command { return printValueJSON(st) } fmt.Fprintf(os.Stdout, "daemon version: %s\nextension connected: %v\n", st.DaemonVersion, st.ExtConnected) + if len(st.Browsers) > 0 { + fmt.Fprintln(os.Stdout, "browsers:") + for _, b := range st.Browsers { + state := "offline" + if b.Online { + state = "online" + } + fmt.Fprintf(os.Stdout, " %s (%s): %s\n", b.Name, b.ID, state) + } + } // 人读输出只给一行摘要,完整事件走 -o json,避免刷屏淹没状态本身。 if summary := formatSecuritySummary(st.Security); summary != "" { fmt.Fprintf(os.Stdout, "recent security events: %s\n", summary) diff --git a/internal/client/control/client.go b/internal/client/control/client.go index 8d05452..d1fc846 100644 --- a/internal/client/control/client.go +++ b/internal/client/control/client.go @@ -175,6 +175,59 @@ func (c *Client) Status(ctx context.Context) (StatusResult, error) { return res, nil } +// Browsers 列出所有已配对的浏览器实例(在线与离线)。 +func (c *Client) Browsers(ctx context.Context) ([]BrowserInfo, error) { + req, err := c.newRequest(ctx, http.MethodGet, PathBrowsers, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, statusError(resp) + } + var res BrowsersResult + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + return res.Browsers, nil +} + +// ForgetBrowser 删除一个已配对浏览器实例的密钥与登记(按精确名称或精确实例 ID), +// 若它在线则先断开连接;之后该实例的握手会失败。ref 不匹配任何已配对实例时返回错误。 +func (c *Client) ForgetBrowser(ctx context.Context, ref string) error { + body, err := json.Marshal(ForgetBrowserRequest{Ref: ref}) + if err != nil { + return err + } + req, err := c.newRequest(ctx, http.MethodPost, PathBrowserForget, body) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return statusError(resp) + } + var res CallResult + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return err + } + if !res.OK { + if res.Error != nil { + return res.Error + } + return fmt.Errorf("forget browser %q failed", ref) + } + return nil +} + // Enroll 打开一次接入窗口,返回展示形配对码(供 sctl connect 在终端展示,用户输入扩展页面)。 func (c *Client) Enroll(ctx context.Context) (string, error) { req, err := c.newRequest(ctx, http.MethodPost, PathEnroll, []byte(`{}`)) diff --git a/internal/client/control/client_test.go b/internal/client/control/client_test.go index e2debd9..0739861 100644 --- a/internal/client/control/client_test.go +++ b/internal/client/control/client_test.go @@ -55,6 +55,64 @@ func captureCalls(t *testing.T) (*Client, *[]map[string]json.RawMessage) { return &Client{http: srv.Client(), base: srv.URL, controlToken: "t"}, &bodies } +func TestClientBrowsersListsPairedInstances(t *testing.T) { + Convey("Browsers 从 /control/browsers 取回已配对实例列表", t, func() { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewEncoder(w).Encode(BrowsersResult{Browsers: []BrowserInfo{ + {ID: "abc", Name: "chrome-abcd", Online: true, Product: "Chrome", ProductVersion: "128.0", ExtensionVersion: "0.1.0"}, + }}) + })) + t.Cleanup(srv.Close) + c := &Client{http: srv.Client(), base: srv.URL, controlToken: "t"} + + list, err := c.Browsers(context.Background()) + + So(err, ShouldBeNil) + So(gotPath, ShouldEqual, PathBrowsers) + So(list, ShouldHaveLength, 1) + So(list[0].Name, ShouldEqual, "chrome-abcd") + So(list[0].Online, ShouldBeTrue) + }) +} + +func TestClientForgetBrowserRoundTrip(t *testing.T) { + Convey("ForgetBrowser 把目标引用发给 /control/browsers/forget", t, func() { + var gotPath string + var gotBody ForgetBrowserRequest + + Convey("daemon 确认删除时返回 nil", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _ = json.NewEncoder(w).Encode(CallResult{OK: true}) + })) + t.Cleanup(srv.Close) + c := &Client{http: srv.Client(), base: srv.URL, controlToken: "t"} + + err := c.ForgetBrowser(context.Background(), "chrome-abcd") + + So(err, ShouldBeNil) + So(gotPath, ShouldEqual, PathBrowserForget) + So(gotBody.Ref, ShouldEqual, "chrome-abcd") + }) + + Convey("目标不存在时返回携带该错误码的错误", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(CallResult{OK: false, Error: &CallError{Code: "NOT_FOUND", Message: "no paired browser instance matches nope"}}) + })) + t.Cleanup(srv.Close) + c := &Client{http: srv.Client(), base: srv.URL, controlToken: "t"} + + err := c.ForgetBrowser(context.Background(), "nope") + + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "NOT_FOUND") + }) + }) +} + func TestCallCarriesTargetBrowser(t *testing.T) { Convey("控制客户端把目标浏览器随 /control/call 发给 daemon", t, func() { c, bodies := captureCalls(t) diff --git a/internal/client/control/protocol.go b/internal/client/control/protocol.go index 2fcb4ed..df9f3ba 100644 --- a/internal/client/control/protocol.go +++ b/internal/client/control/protocol.go @@ -13,16 +13,19 @@ package control import ( "encoding/json" + "time" "github.com/scriptscat/sctl/internal/pkg/audit" ) // 控制 API 路径。健康检查故意不鉴权(仅暴露「端口开着」这一威胁模型已接受的信息)。 const ( - PathHealth = "/control/health" - PathCall = "/control/call" - PathEnroll = "/control/enroll" - PathStatus = "/control/status" + PathHealth = "/control/health" + PathCall = "/control/call" + PathEnroll = "/control/enroll" + PathStatus = "/control/status" + PathBrowsers = "/control/browsers" + PathBrowserForget = "/control/browsers/forget" ) // 控制 API 请求头。 @@ -73,15 +76,38 @@ type HealthResult struct { Version string `json:"version"` } -// StatusResult 是 /control/status 的响应体:daemon 与扩展连接概览,附守卫侧安全事件。 +// StatusResult 是 /control/status 的响应体:daemon 与扩展连接概览,附守卫侧安全事件与已配对浏览器实例。 type StatusResult struct { DaemonVersion string `json:"daemonVersion"` ExtConnected bool `json:"extConnected"` SecurityCount int `json:"securityCount"` Security []audit.Event `json:"security,omitempty"` + Browsers []BrowserInfo `json:"browsers,omitempty"` } // EnrollResult 是 /control/enroll 的响应体:打开接入窗口后返回展示形配对码。 type EnrollResult struct { Code string `json:"code"` } + +// BrowserInfo 是一个已配对浏览器实例的当前视图,镜像 bridge.InstanceInfo(控制 API 不直接暴露 +// bridge 类型,见 internal/daemon/controlapi 的窄接口约定)。离线实例的 ConnectedAt 为零值。 +type BrowserInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Online bool `json:"online"` + Product string `json:"product,omitempty"` + ProductVersion string `json:"productVersion,omitempty"` + ExtensionVersion string `json:"extensionVersion,omitempty"` + ConnectedAt time.Time `json:"connectedAt,omitempty"` +} + +// BrowsersResult 是 /control/browsers 的响应体:全部已配对浏览器实例(在线与离线)。 +type BrowsersResult struct { + Browsers []BrowserInfo `json:"browsers"` +} + +// ForgetBrowserRequest 是 /control/browsers/forget 的请求体:Ref 是实例名称或完整实例 ID。 +type ForgetBrowserRequest struct { + Ref string `json:"ref"` +} diff --git a/internal/daemon/controlapi/controlapi.go b/internal/daemon/controlapi/controlapi.go index 6756bb2..98b7821 100644 --- a/internal/daemon/controlapi/controlapi.go +++ b/internal/daemon/controlapi/controlapi.go @@ -19,6 +19,7 @@ import ( "github.com/scriptscat/sctl/internal/daemon/bridge" "github.com/scriptscat/sctl/internal/pkg/audit" "github.com/scriptscat/sctl/internal/pkg/protocol" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" ) // Bridge 是控制 API 依赖的守卫侧能力面,由 *bridge.Server 实现。 @@ -30,6 +31,11 @@ type Bridge interface { AuditSnapshot() []audit.Event Call(ctx context.Context, req bridge.Request) (bridge.Response, error) BeginEnrollment() (string, error) + // Instances 列出所有已配对的浏览器实例(在线与离线),按名称排序。 + Instances() []bridge.InstanceInfo + // ForgetInstance 按精确实例 ID 或精确名称删除一个已配对浏览器实例的密钥与登记, + // 若它在线则先断开连接;不匹配任何已配对实例时返回 bridge.ErrInstanceNotFound。 + ForgetInstance(ref string) error } // Handler 是控制 API 的处理器集合。 @@ -53,6 +59,8 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc(control.PathCall, h.guard(h.call)) mux.HandleFunc(control.PathStatus, h.guard(h.status)) mux.HandleFunc(control.PathEnroll, h.guard(h.enroll)) + mux.HandleFunc(control.PathBrowsers, h.guard(h.browsers)) + mux.HandleFunc(control.PathBrowserForget, h.guard(h.forgetBrowser)) } // guard 包装需要控制令牌的处理器:恒定时间校验 X-Sctl-Control-Token,不符即 401(无细节)。 @@ -85,9 +93,49 @@ func (h *Handler) status(w http.ResponseWriter, _ *http.Request) { ExtConnected: h.bridge.ExtConnected(), SecurityCount: len(events), Security: events, + Browsers: toBrowserInfos(h.bridge.Instances()), }) } +// browsers 列出所有已配对的浏览器实例(在线与离线)。 +func (h *Handler) browsers(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, control.BrowsersResult{Browsers: toBrowserInfos(h.bridge.Instances())}) +} + +// forgetBrowser 删除一个已配对浏览器实例的密钥与登记,若在线则先断开连接(bridge.ForgetInstance)。 +func (h *Handler) forgetBrowser(w http.ResponseWriter, r *http.Request) { + var req control.ForgetBrowserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Ref == "" { + writeControlError(w, bridge.CodeInvalidRequest, "missing browser reference") + return + } + switch err := h.bridge.ForgetInstance(req.Ref); { + case err == nil: + writeJSON(w, control.CallResult{OK: true}) + case errors.Is(err, bridge.ErrInstanceNotFound): + writeControlError(w, generated.ErrorCodeNotFound, "no paired browser instance matches "+req.Ref) + default: + writeControlError(w, bridge.CodeInternal, "internal error") + } +} + +// toBrowserInfos 把守卫侧的实例视图映射为控制 API 对外的 DTO(不直接暴露 bridge 类型)。 +func toBrowserInfos(instances []bridge.InstanceInfo) []control.BrowserInfo { + out := make([]control.BrowserInfo, 0, len(instances)) + for _, inst := range instances { + out = append(out, control.BrowserInfo{ + ID: inst.ID, + Name: inst.Name, + Online: inst.Online, + Product: inst.Product, + ProductVersion: inst.ProductVersion, + ExtensionVersion: inst.ExtensionVersion, + ConnectedAt: inst.ConnectedAt, + }) + } + return out +} + // call 转发一次 bridge action:解析调用方自报标签(仅审计),再驱动阻塞的 Bridge.Call。 // 请求方(CLI/mcp)断开会取消 r.Context() → Call 向扩展发 $/cancelRequest 作废操作。 func (h *Handler) call(w http.ResponseWriter, r *http.Request) { diff --git a/internal/daemon/controlapi/controlapi_test.go b/internal/daemon/controlapi/controlapi_test.go index bb72b78..9f7d253 100644 --- a/internal/daemon/controlapi/controlapi_test.go +++ b/internal/daemon/controlapi/controlapi_test.go @@ -5,10 +5,12 @@ import ( "encoding/json" "net/http" "testing" + "time" . "github.com/smartystreets/goconvey/convey" "github.com/scriptscat/sctl/internal/client/control" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" ) func TestControlHealthAndAuth(t *testing.T) { @@ -146,6 +148,141 @@ func TestControlClientLabelForwarding(t *testing.T) { }) } +func decodeBrowsers(resp *http.Response) control.BrowsersResult { + defer resp.Body.Close() + var res control.BrowsersResult + So(json.NewDecoder(resp.Body).Decode(&res), ShouldBeNil) + return res +} + +func browserByID(list []control.BrowserInfo, id string) (control.BrowserInfo, bool) { + for _, b := range list { + if b.ID == id { + return b, true + } + } + return control.BrowserInfo{}, false +} + +func TestControlBrowsersList(t *testing.T) { + Convey("控制 API 列出已配对浏览器实例,在线与离线均含", t, func() { + h := startTestServer(t) + base := h.httpBase() + h.pairedBrowser(instanceA, "chrome-a") + h.connectBrowser(instanceB, "chrome-b") + + resp, err := postControl(context.Background(), base, control.PathBrowsers, testControlToken, "", nil) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + res := decodeBrowsers(resp) + + So(res.Browsers, ShouldHaveLength, 2) + offline, ok := browserByID(res.Browsers, instanceA) + So(ok, ShouldBeTrue) + So(offline.Name, ShouldEqual, "chrome-a") + So(offline.Online, ShouldBeFalse) + So(offline.ConnectedAt.IsZero(), ShouldBeTrue) + + online, ok := browserByID(res.Browsers, instanceB) + So(ok, ShouldBeTrue) + So(online.Name, ShouldEqual, "chrome-b") + So(online.Online, ShouldBeTrue) + So(online.ConnectedAt.IsZero(), ShouldBeFalse) + + Convey("缺少控制令牌时被 401 拒绝", func() { + resp, err := postControl(context.Background(), base, control.PathBrowsers, "", "", nil) + So(err, ShouldBeNil) + resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + }) +} + +func TestControlBrowserForget(t *testing.T) { + Convey("控制 API 忘记一个已配对浏览器实例", t, func() { + h := startTestServer(t) + base := h.httpBase() + + Convey("按名称忘记一个离线实例:删除后不再出现在列表里", func() { + h.pairedBrowser(instanceA, "chrome-a") + + resp, err := postControl(context.Background(), base, control.PathBrowserForget, testControlToken, "", control.ForgetBrowserRequest{Ref: "chrome-a"}) + So(err, ShouldBeNil) + res := decodeCall(resp) + So(res.OK, ShouldBeTrue) + + listResp, err := postControl(context.Background(), base, control.PathBrowsers, testControlToken, "", nil) + So(err, ShouldBeNil) + So(decodeBrowsers(listResp).Browsers, ShouldHaveLength, 0) + }) + + Convey("按完整实例 ID 忘记一个在线实例:断开连接并从列表移除", func() { + e := h.connectBrowser(instanceB, "chrome-b") + + resp, err := postControl(context.Background(), base, control.PathBrowserForget, testControlToken, "", control.ForgetBrowserRequest{Ref: instanceB}) + So(err, ShouldBeNil) + res := decodeCall(resp) + So(res.OK, ShouldBeTrue) + + // 忘记后连接被服务端关闭:下一次读取以错误收尾,而不是继续收到消息。 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _, err = e.ws.Read(ctx) + So(err, ShouldNotBeNil) + + listResp, err := postControl(context.Background(), base, control.PathBrowsers, testControlToken, "", nil) + So(err, ShouldBeNil) + So(decodeBrowsers(listResp).Browsers, ShouldHaveLength, 0) + }) + + Convey("目标不匹配任何已配对实例 → NOT_FOUND", func() { + resp, err := postControl(context.Background(), base, control.PathBrowserForget, testControlToken, "", control.ForgetBrowserRequest{Ref: "nope"}) + So(err, ShouldBeNil) + res := decodeCall(resp) + So(res.OK, ShouldBeFalse) + So(res.Error.Code, ShouldEqual, generated.ErrorCodeNotFound) + }) + + Convey("缺少 ref 字段 → INVALID_REQUEST", func() { + resp, err := postControl(context.Background(), base, control.PathBrowserForget, testControlToken, "", control.ForgetBrowserRequest{}) + So(err, ShouldBeNil) + res := decodeCall(resp) + So(res.OK, ShouldBeFalse) + So(res.Error.Code, ShouldEqual, "INVALID_REQUEST") + }) + + Convey("缺少控制令牌时被 401 拒绝", func() { + resp, err := postControl(context.Background(), base, control.PathBrowserForget, "", "", control.ForgetBrowserRequest{Ref: "chrome-a"}) + So(err, ShouldBeNil) + resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + }) +} + +func TestControlStatusIncludesBrowsers(t *testing.T) { + Convey("控制 API 的 status 附带已配对浏览器实例概览", t, func() { + h := startTestServer(t) + base := h.httpBase() + h.pairedBrowser(instanceA, "chrome-a") + h.connectBrowser(instanceB, "chrome-b") + + resp, err := postControl(context.Background(), base, control.PathStatus, testControlToken, "", nil) + So(err, ShouldBeNil) + defer resp.Body.Close() + var st control.StatusResult + So(json.NewDecoder(resp.Body).Decode(&st), ShouldBeNil) + + So(st.Browsers, ShouldHaveLength, 2) + offline, ok := browserByID(st.Browsers, instanceA) + So(ok, ShouldBeTrue) + So(offline.Online, ShouldBeFalse) + online, ok := browserByID(st.Browsers, instanceB) + So(ok, ShouldBeTrue) + So(online.Online, ShouldBeTrue) + }) +} + func TestControlEnroll(t *testing.T) { Convey("控制 enroll:打开接入窗口并返回展示形配对码", t, func() { h := startTestServer(t) From 95a6020f6f86d54aad6535c0b73c3b54543fb4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:42:37 +0800 Subject: [PATCH 07/25] feat: add sctl Browser extension project and connection core --- .github/workflows/release.yaml | 35 +- .github/workflows/test.yaml | 24 + .gitignore | 1 + docs/development.md | 29 +- docs/doc-maintenance.md | 7 +- extension/.prettierignore | 4 + extension/.prettierrc | 3 + extension/components.json | 22 + extension/eslint.config.js | 34 + extension/package.json | 46 + extension/pnpm-lock.yaml | 4487 +++++++++++++++++ extension/public/_locales/en/messages.json | 8 + extension/public/_locales/zh_CN/messages.json | 8 + extension/public/icons/icon-128.png | Bin 0 -> 1152 bytes extension/public/icons/icon-16.png | Bin 0 -> 518 bytes extension/public/icons/icon-32.png | Bin 0 -> 1044 bytes extension/public/icons/icon-48.png | Bin 0 -> 1601 bytes extension/src/background/browser-info.ts | 19 + extension/src/background/controller.test.ts | 203 + extension/src/background/controller.ts | 140 + extension/src/background/index.ts | 38 + .../src/background/offscreen-document.test.ts | 44 + .../src/background/offscreen-document.ts | 37 + extension/src/background/registry.test.ts | 43 + extension/src/background/registry.ts | 49 + extension/src/handlers/index.ts | 4 + extension/src/lib/utils.ts | 6 + extension/src/manifest.json | 28 + extension/src/offscreen/connection.fixture.ts | 277 + extension/src/offscreen/connection.test.ts | 439 ++ extension/src/offscreen/connection.ts | 523 ++ extension/src/offscreen/crypto.test.ts | 51 + extension/src/offscreen/crypto.ts | 93 + extension/src/offscreen/index.html | 10 + extension/src/offscreen/main.ts | 51 + extension/src/offscreen/wire.ts | 63 + extension/src/popup/index.html | 12 + extension/src/popup/main.tsx | 14 + extension/src/shared/address.ts | 22 + extension/src/shared/identity.test.ts | 72 + extension/src/shared/identity.ts | 42 + extension/src/shared/messages.ts | 55 + extension/src/shared/messaging.ts | 56 + extension/src/shared/pairing-code.ts | 10 + extension/src/shared/popup-api.test.ts | 108 + extension/src/shared/popup-api.ts | 41 + extension/src/shared/state.ts | 26 + extension/src/styles/globals.css | 89 + extension/tsconfig.json | 24 + extension/tsconfig.node.json | 21 + extension/vite.config.ts | 60 + extension/vitest.config.ts | 10 + 52 files changed, 7482 insertions(+), 6 deletions(-) create mode 100644 extension/.prettierignore create mode 100644 extension/.prettierrc create mode 100644 extension/components.json create mode 100644 extension/eslint.config.js create mode 100644 extension/package.json create mode 100644 extension/pnpm-lock.yaml create mode 100644 extension/public/_locales/en/messages.json create mode 100644 extension/public/_locales/zh_CN/messages.json create mode 100644 extension/public/icons/icon-128.png create mode 100644 extension/public/icons/icon-16.png create mode 100644 extension/public/icons/icon-32.png create mode 100644 extension/public/icons/icon-48.png create mode 100644 extension/src/background/browser-info.ts create mode 100644 extension/src/background/controller.test.ts create mode 100644 extension/src/background/controller.ts create mode 100644 extension/src/background/index.ts create mode 100644 extension/src/background/offscreen-document.test.ts create mode 100644 extension/src/background/offscreen-document.ts create mode 100644 extension/src/background/registry.test.ts create mode 100644 extension/src/background/registry.ts create mode 100644 extension/src/handlers/index.ts create mode 100644 extension/src/lib/utils.ts create mode 100644 extension/src/manifest.json create mode 100644 extension/src/offscreen/connection.fixture.ts create mode 100644 extension/src/offscreen/connection.test.ts create mode 100644 extension/src/offscreen/connection.ts create mode 100644 extension/src/offscreen/crypto.test.ts create mode 100644 extension/src/offscreen/crypto.ts create mode 100644 extension/src/offscreen/index.html create mode 100644 extension/src/offscreen/main.ts create mode 100644 extension/src/offscreen/wire.ts create mode 100644 extension/src/popup/index.html create mode 100644 extension/src/popup/main.tsx create mode 100644 extension/src/shared/address.ts create mode 100644 extension/src/shared/identity.test.ts create mode 100644 extension/src/shared/identity.ts create mode 100644 extension/src/shared/messages.ts create mode 100644 extension/src/shared/messaging.ts create mode 100644 extension/src/shared/pairing-code.ts create mode 100644 extension/src/shared/popup-api.test.ts create mode 100644 extension/src/shared/popup-api.ts create mode 100644 extension/src/shared/state.ts create mode 100644 extension/src/styles/globals.css create mode 100644 extension/tsconfig.json create mode 100644 extension/tsconfig.node.json create mode 100644 extension/vite.config.ts create mode 100644 extension/vitest.config.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0327a1b..191419b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -95,9 +95,42 @@ jobs: path: dist/*.${{ matrix.format }} if-no-files-found: error + extension: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + with: + package_json_file: extension/package.json + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: extension/pnpm-lock.yaml + - name: 构建并打包浏览器扩展 + working-directory: extension + run: | + set -euo pipefail + # 扩展版本号与 sctl 发布版本一致;预发布后缀由构建写进 manifest 的 version_name。 + VERSION="${GITHUB_REF_NAME#v}" + COMMIT_TIMESTAMP=$(git show -s --format=%ct HEAD) + PACKAGE="sctl-browser-extension-${VERSION}" + pnpm install --frozen-lockfile + SCTL_EXTENSION_VERSION="$VERSION" pnpm build + mkdir -p ../dist + cp -R dist "../dist/${PACKAGE}" + find "../dist/${PACKAGE}" -exec touch -d "@${COMMIT_TIMESTAMP}" {} + + (cd ../dist && find "$PACKAGE" | sort | zip -X -q "${PACKAGE}.zip" -@) + - uses: actions/upload-artifact@v7 + with: + name: release-extension + path: dist/*.zip + if-no-files-found: error + release: name: Create Release - needs: build + needs: [build, extension] runs-on: ubuntu-latest permissions: contents: write # 创建 GitHub Release 并上传产物 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8542829..ebcd90b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,6 +50,30 @@ jobs: - run: go vet ./... - run: go test -race ./... + extension: + name: extension + runs-on: ubuntu-latest + defaults: + run: + working-directory: extension + steps: + - uses: actions/checkout@v5 + # pnpm 版本取自 extension/package.json 的 packageManager 字段。 + - uses: pnpm/action-setup@v4 + with: + package_json_file: extension/package.json + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: extension/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm format:check + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + protocol-schema: name: protocol schema and ScriptCat mirror runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c8ee25a..0dc96b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .dev-kit # 一次性端到端验证脚本与证据(见 docs/verification.md),永远不进版本库 /e2e/scratch/ +node_modules/ diff --git a/docs/development.md b/docs/development.md index 3321d1d..50b18f5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -123,7 +123,7 @@ isolation with these flags, and what evidence makes a change count as | Trigger | What runs | |---|---| -| Every PR (any target branch) | `lint` + native Linux/macOS/Windows `test` (`build`, `vet`, `-race`) + protocol generation and exact paired-ScriptCat drift | +| Every PR (any target branch) | `lint` + native Linux/macOS/Windows `test` (`build`, `vet`, `-race`) + `extension` (lint, format check, type check, unit tests, build) + protocol generation and exact paired-ScriptCat drift | | push to `main` / `release/**` | Same as above | | push tag `v*` | Reuses the full test gate first; only builds and publishes once it passes | @@ -136,8 +136,10 @@ git tag -a v0.1.0 -m "v0.1.0" # use v0.1.0-rc.1 for prereleases; the workflo git push origin v0.1.0 ``` -It produces 6 artifacts (darwin/linux/windows × amd64/arm64) plus `checksums.txt`, generates build provenance -for the checksum file, and publishes the GitHub Release automatically after the full test gate passes. +It produces 7 artifacts — 6 sctl archives (darwin/linux/windows × amd64/arm64) and the browser extension +`sctl-browser-extension-.zip` — plus `checksums.txt`, generates build provenance for the checksum file, +and publishes the GitHub Release automatically after the full test gate passes. The extension zip holds one +`sctl-browser-extension-/` directory for "Load unpacked"; its manifest version is the release version. Artifacts are built with `-trimpath` and use the commit time as the archive member timestamp, so rebuilding the same tag is byte-for-byte reproducible. Version, commit, and build time are injected into `internal/cli` @@ -145,6 +147,27 @@ via `-ldflags` and are visible through `sctl version`. Cross-compilation, packaging, checksums, and the GitHub Release upload all happen inside that workflow. +## Browser extension + +The sctl Browser extension lives in `extension/` (pnpm, Vite, React, TypeScript, Tailwind CSS v4, shadcn/ui, +ESLint, Prettier, Vitest). CI uses Node 24; the pnpm version is pinned by `packageManager` in +`extension/package.json`. Run everything from `extension/`: + +```bash +pnpm install --frozen-lockfile +pnpm lint # ESLint +pnpm format:check # Prettier (pnpm format rewrites) +pnpm typecheck # tsc -b +pnpm test # Vitest, with chrome.* and WebSocket replaced by fakes +pnpm build # writes dist/, loadable through "Load unpacked" +``` + +`SCTL_EXTENSION_VERSION` sets the manifest version for a build (the release workflow passes the tag version; a +prerelease suffix such as `-rc.1` goes into `version_name`, because Chrome versions are numeric only). Without it, +the build uses the `version` in `extension/package.json`. The generated protocol bindings in +`extension/src/protocol/generated/` are excluded from lint and formatting; regenerate them rather than editing +them (see below). + ## Protocol source and generation `internal/pkg/protocol/protocol.json` is the only maintained source for the extension-facing RPC contract. Run diff --git a/docs/doc-maintenance.md b/docs/doc-maintenance.md index b60bb7c..fc891d1 100644 --- a/docs/doc-maintenance.md +++ b/docs/doc-maintenance.md @@ -46,8 +46,11 @@ authoritative source, never recalled from memory or copied from earlier prose. | A function / type exists **under that exact name** | `git grep -n 'func WriteFileAtomic' -- internal` — renames are the #1 source of drift | | Dependency direction between packages | `go list -f '{{.ImportPath}} {{join .Imports " "}}' ./...` | | Protocol constants (port, timeouts, TTLs, limits, method set) | `internal/pkg/protocol/protocol.json` is the authority; docs only explain semantics | -| "N MCP tools" | Count the `methods` keys in `internal/pkg/protocol/protocol.json`, not comments in the MCP implementation | -| "N release artifacts" | The build matrix in `.github/workflows/release.yaml` | +| "N MCP tools" | Count the `toolDefs` entries in `internal/client/mcpserver/tools.go` whose `action` is a `methods` key in `internal/pkg/protocol/protocol.json` — `mcpserver.New` registers only those; not every protocol method has a tool | +| "N release artifacts" | The build matrix plus the `extension` job in `.github/workflows/release.yaml` | +| Extension toolchain (Node, pnpm) | `node-version` in the `extension` job of `.github/workflows/test.yaml`; `packageManager` in `extension/package.json` | +| Extension commands | The `scripts` in `extension/package.json` | +| Extension permissions / minimum Chrome version | `extension/src/manifest.json` | | Toolchain versions | `GOLANGCI_LINT_VERSION` in `.github/workflows/test.yaml` must match [development.md](./development.md) | | CLI subcommands / flags | `git grep -n 'Use:' -- internal/cli`, or just `./sctl --help` | | Exit codes | The `exitOK` / `exitRejected` / `exitVoided` / `exitError` constants in `internal/cli/cli.go` | diff --git a/extension/.prettierignore b/extension/.prettierignore new file mode 100644 index 0000000..a7c6015 --- /dev/null +++ b/extension/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +pnpm-lock.yaml +src/protocol/generated diff --git a/extension/.prettierrc b/extension/.prettierrc new file mode 100644 index 0000000..963354f --- /dev/null +++ b/extension/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/extension/components.json b/extension/components.json new file mode 100644 index 0000000..f4bf9d9 --- /dev/null +++ b/extension/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/extension/eslint.config.js b/extension/eslint.config.js new file mode 100644 index 0000000..63178e0 --- /dev/null +++ b/extension/eslint.config.js @@ -0,0 +1,34 @@ +import js from "@eslint/js"; +import prettier from "eslint-config-prettier"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + // 生成代码由 make protocol-generate 产出,不手改也不按本工程规则检查。 + { ignores: ["dist", "src/protocol/generated"] }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], + languageOptions: { + ecmaVersion: 2023, + globals: { ...globals.browser, ...globals.serviceworker }, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { + // 与 tsc 的 noUnusedParameters 一致:下划线前缀表示有意不用的参数。 + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, + { + files: ["**/*.tsx"], + extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite], + }, + { + files: ["**/*.js"], + extends: [js.configs.recommended], + languageOptions: { globals: globals.node }, + }, + prettier, +); diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..7e46eed --- /dev/null +++ b/extension/package.json @@ -0,0 +1,46 @@ +{ + "name": "sctl-browser-extension", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "pnpm@10.11.0", + "scripts": { + "build": "vite build", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "@base-ui/react": "^1.8.0", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.48.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "shadcn": "^4.21.0", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/chrome": "^0.3.0", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", + "prettier": "^3.9.9", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.1", + "vite": "^8.3.1", + "vitest": "^5.0.2" + } +} diff --git a/extension/pnpm-lock.yaml b/extension/pnpm-lock.yaml new file mode 100644 index 0000000..9b79f64 --- /dev/null +++ b/extension/pnpm-lock.yaml @@ -0,0 +1,4487 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@base-ui/react': + specifier: ^1.8.0 + version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.48.0 + version: 1.48.0(react@19.3.0) + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + shadcn: + specifier: ^4.21.0 + version: 4.21.0(typescript@6.0.3) + tailwind-merge: + specifier: ^3.7.0 + version: 3.7.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.11.0(jiti@2.7.0)) + '@types/chrome': + specifier: ^0.3.0 + version: 0.3.0 + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) + eslint: + specifier: ^10.11.0 + version: 10.11.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.11.0(jiti@2.7.0)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.11.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.7 + version: 0.5.7(eslint@10.11.0(jiti@2.7.0)) + globals: + specifier: ^17.12.0 + version: 17.12.0 + prettier: + specifier: ^3.9.9 + version: 3.9.9 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.70.1 + version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.9': + resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@dotenvx/dotenvx@1.75.1': + resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} + hasBin: true + + '@dotenvx/primitives@0.8.0': + resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@modelcontextprotocol/sdk@1.30.1': + resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/chrome@0.3.0': + resolution: {integrity: sha512-HPAMKyJwUnNJdVsxgypwvIStlwRhhaLnxgEwKIB99oOcffJrC4UMeD5xseVoBwDinSkr690DCBaAtSYdK6M1qw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.70.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.70.1': + resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.70.1': + resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.70.1': + resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.70.1': + resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.70.1': + resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.70.1': + resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.70.1': + resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.70.1': + resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.3: + resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} + engines: {node: '>=4'} + + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.29.2: + resolution: {integrity: sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001812: + resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cn@0.2.6: + resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==} + engines: {node: '>=20'} + hasBin: true + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.439: + resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.7: + resolution: {integrity: sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.11.0: + resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.13.10: + resolution: {integrity: sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==} + engines: {node: '>=16.9.0'} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.4: + resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} + engines: {node: '>=20'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} + engines: {node: '>=4'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} + engines: {node: '>= 4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.21.0: + resolution: {integrity: sha512-UU2mFNusW8C5rvadKdH69vERYZqUlOOlXBcf0MYhYLdTGP6DPti7X4qovCu+RTfCqsAgq/T+YfE0Vnttxh9aiw==} + engines: {node: '>=20.18.1'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + systeminformation@5.33.13: + resolution: {integrity: sha512-GtyakkchTQKymM1WeAB652zi3rM1q00RKdc+jZe9KGstuc/mWUwAnQGN/RE5gVYvHIR8xYOUIUD9irVKDtA0wA==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.70.1: + resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + engines: {node: '>=20'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-spinner@1.2.2: + resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} + engines: {node: '>=18.19'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@base-ui/react@1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@base-ui/utils@0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + reselect: 5.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@dotenvx/dotenvx@1.75.1': + dependencies: + '@dotenvx/primitives': 0.8.0 + commander: 11.1.0 + conf: 10.2.0 + dotenv: 17.4.2 + enquirer: 2.4.1 + env-paths: 2.2.1 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.7) + ignore: 5.3.2 + object-treeify: 1.1.33 + open: 8.4.2 + picomatch: 4.0.7 + systeminformation: 5.33.13 + undici: 7.30.0 + which: 4.0.0 + yocto-spinner: 1.2.2 + + '@dotenvx/primitives@0.8.0': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.7.0))': + dependencies: + eslint: 10.11.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.11.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.11.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@floating-ui/utils@0.2.12': {} + + '@hono/node-server@2.1.1(hono@4.13.10)': + dependencies: + hono: 4.13.10 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@modelcontextprotocol/sdk@1.30.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.10) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.10 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@oxc-project/types@0.151.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.25.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.6 + path-browserify: 1.0.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/chrome@0.3.0': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.10 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.70.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.70.1': {} + + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.70.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + + '@vitest/mocker@5.0.2(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + + '@vitest/spy@5.0.2': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.4.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + ast-types@0.16.3: + dependencies: + tslib: 2.8.1 + + atomically@1.7.0: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.26: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.29.2: + dependencies: + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 + electron-to-chromium: 1.5.439 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.2) + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001812: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + clsx@2.1.1: {} + + cn@0.2.6: {} + + code-block-writer@13.0.3: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.5 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.2(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + diff@8.0.4: {} + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.439: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.25.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.7.0)): + dependencies: + eslint: 10.11.0(jiti@2.7.0) + + eslint-plugin-react-hooks@7.1.1(eslint@10.11.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + eslint: 10.11.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.7(eslint@10.11.0(jiti@2.7.0)): + dependencies: + eslint: 10.11.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.11.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + expect-type@1.4.0: {} + + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.2 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.8: {} + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.7.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.12.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.13.10: {} + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.10: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ip-address@10.7.2: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jiti@2.7.0: {} + + jose@6.2.12: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@7.0.3: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.48.0(react@19.3.0): + dependencies: + react: 19.3.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-fn@3.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-releases@2.0.57: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + obug@2.2.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.4: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.2.1 + wsl-utils: 1.0.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@3.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + postcss-selector-parser@7.1.6: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + powershell-utils@0.2.1: {} + + prelude-ls@1.2.1: {} + + prettier@3.9.9: {} + + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + recast@0.23.21: + dependencies: + ast-types: 0.16.3 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + require-from-string@2.0.2: {} + + reselect@5.3.0: {} + + resolve-from@4.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.28.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shadcn@4.21.0(typescript@6.0.3): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@dotenvx/dotenvx': 1.75.1 + '@modelcontextprotocol/sdk': 1.30.1(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.29.2 + cn: 0.2.6 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@6.0.3) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.4.1 + fuzzysort: 3.1.0 + kleur: 4.1.5 + open: 11.0.4 + ora: 8.2.0 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 + prompts: 2.4.2 + recast: 0.23.21 + socks: 2.8.10 + stringify-object: 5.0.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + undici: 7.30.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + smart-buffer@4.2.0: {} + + socks@2.8.10: + dependencies: + ip-address: 10.7.2 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + systeminformation@5.33.13: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript-eslint@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + undici@7.30.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.3.3(browserslist@4.29.2): + dependencies: + browserslist: 4.29.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 26.6.3 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@3.2.2: {} + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + wsl-utils@1.0.0: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-spinner@1.2.2: + dependencies: + yoctocolors: 2.2.0 + + yoctocolors@2.2.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/extension/public/_locales/en/messages.json b/extension/public/_locales/en/messages.json new file mode 100644 index 0000000..10a0c6b --- /dev/null +++ b/extension/public/_locales/en/messages.json @@ -0,0 +1,8 @@ +{ + "extName": { + "message": "sctl Browser" + }, + "extDescription": { + "message": "Connects this browser to the local sctl daemon so sctl commands and MCP clients can work with its tabs and windows." + } +} diff --git a/extension/public/_locales/zh_CN/messages.json b/extension/public/_locales/zh_CN/messages.json new file mode 100644 index 0000000..f665273 --- /dev/null +++ b/extension/public/_locales/zh_CN/messages.json @@ -0,0 +1,8 @@ +{ + "extName": { + "message": "sctl Browser" + }, + "extDescription": { + "message": "把这个浏览器连接到本机的 sctl daemon,让 sctl 命令和 MCP 客户端可以操作它的标签页和窗口。" + } +} diff --git a/extension/public/icons/icon-128.png b/extension/public/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..121c94a165d007fcba724616caae8ab17ec9526c GIT binary patch literal 1152 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&-3agraRt)c({A%DehVcRzT#Q= z4akNgpcK!FuRKdX0ue+FNCXu@6vGXHGkDkk2eIcrhls$H0lCNsZY*2@M1*_p6Q~K( zZUY%`WpKT45oC=JL8qwpCZONkN`m}?89F^aO)+Ho`|kFYEK51w^9TAmt3rJoe}4P& z^8Srou`W_VoTnaLzkGbtn)%av+Y55jgS_mtHC5$B4m>-1bm#I3^(n#5X5x!?%(=n9 zz%1zL;uunK>+Q|ht3=bW4q-+pXY^7-L)@}5Rb=lTOJ-d?(ER)#(jnxV5J=iV`?Tg7_O z2cFn2u3!}6vT>{ApWNSLw!@WaJG0wwj>+eHjAuAx7$wX(Z?Mw%-aJ(u>yNJtEFWu4 zHRHJ@x~_!ndhi`wr#!xF*kt4Zdi4=G}@Hd}+5sw(xF_Y|P>^ z=9sc#8A~VgD$9l=Vpnq<7de{pdi1PJdp6ad0QUWVe^ji5nqF&S_q%!6(kFNMT#37h0ltb$OTFhiy|cU(2hIfcBVXE~yEhR~~4Onank7!f#Jj`;Xhw zH!)3GVN@C_k@5bp)uatGthhT3N)0a5t+H-w{Koi^jqTL-%}Ph+T*#^^>dsUTD6u!y zDYniDi(OT;+V{xpFEbneshHUr8%1tr%-G%-&MC2iN6(*91DQIaaXoMXiO^=4E{IEbm-6?GXI> z%?s!JjS^-PPB>4OeqonTEV^!OQ^=eGUv-a@sqNV>Vw6lD&gnesyyT^{@UONhmhm$V zh_k=cp1(_^(EoHmQKiG;X73eQvfm_Dm)~#_d$<1FeO=4_yWAm(dhz|TdlUb%JfCC% P%x(;xu6{1-oD!MEeY}i`34=Z+Nw%jzt^B5bm2}*ILGe4j7z$UBaTO3bpNi?{-gTl z(J#E>Jjqvo%6Nvmsnm)Ab?@_ZaSV~T>^dP^ywO3#wO?}OM9xZ)MN%OmpROhU`+t9u z&aKz$s;}L=|Bf|^MdWRlmIt@O;*Ty1WutnHB>Oq8tV})sOiJ-qO&!o$PrKW?E?0Wv)sin$76>+8V&-4$=AziX@wR!=e&Y+z z3qQT~JoDRt@Azf^IeqNRCbseG?$_;|6Wn$1?Z1kBMv9xRe=gk5YmmZ|7B?kz;_QT+ hj$e6R7V2C69g=q3r(7S`Ji#3l4xX-lF6*2UngGq2-nsw) literal 0 HcmV?d00001 diff --git a/extension/public/icons/icon-32.png b/extension/public/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..df77f0b44709da58b8bb618c8455de4a6299bc65 GIT binary patch literal 1044 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabRA=0U@Qsn32_B-xo17#o^h9F?o*!m z&w1uP;hyuDd)h7TNmse2-{qe5kbC+a?m17mXFuYecAIHmM8#qW3)z2TmGgL}q3?%5Bydd~37d&bprhCV z0{864+;gAs%zFwH2bm1C8fY@mSRflH0uuzf1n4A?jX?3)4?)_2B0x3-&3edx>Oar& z&mg;iB0v&o0)!1R3ak-mGEi#fJ&+-@9`Y=D4dQ~#zYA3f6o(*?I7ki1MxZRnK#&@s zgF(gurGPGk7!GnFSQl6UNF~@Vkn?~D7=Vl3!psDl15^(T%$X0M=0NmLyUj59cRMiL zt4o6Xf`K$KfGK2>AGhSqubch2rEY!Q;>XQ*^qW5qBg=+8v**ufNb~<~^-1-q6vrVJ z+n{5PZ~i#GWpyaOGAWDI{exibE}y@ehi>niIaNrCi!tGm+448+Ci#=*G9GlAu@IOH zu6VjQhFF~Lotz$i$UvmcJM~7JS`s6BgsY>=TIMMBCh6Bsu18zB@(Lfu{rqgETVW${ z{^Q?C_kPS(q{QqsS3R?Nvp3F|Z?=2!w8xE~m%nAP34guec8>!`x$CZK zg;m9&IpMNRws$A4+mO(*BK%nGU*_hVy%GLNwTbNPzkYAH)6L`1bIjmamz4?69l7<_ zAF~~HUbSd>Qs70Kb0;*^x>hH5wxw)RG+cJ9_sENm%96qwrrAMi%a<)*t~RyE!Z0r^ zBPGos)OeTcl`A&h>}j)S^Bj2Xaj|jA;cTV?zr{aq$(S%6v)%8&EcWc~(_=ryre_?u zv}irgkLjsW3i`i!r5VijIx&Y`(qUkUh&{dLa>Lg%x3m}^99uSRDPv@AG4mv@=-WT| zbMq&1{W*k?CovYCaRQfVg6S=#cA%H)30y;ZeRC(`*rbk x#v!)d9s)H2b+4K(sB?6f9r+ObU-Da_7 zh>(L2Lc$S?AX*%)a!5x*1qvdE4OhY$2#6+xLvc{k-zGZK|GV?{x4(V!-sgSa`|ak@ z!uJsHoA5?PMugCiz)0})LT*zGSYze5yGBOnDB3=H5O{^krXle>R5AvYDBx-}ESiIB zR^h6baQQ4$B*X5GhXj+5XdVV8o`F}EO9z!HO`D zjvV-rUV!oSPT&UyV)Ppz00~Aa$`OUt8iWS+f+?gg$)bJ&AQ52_AL@`qy*#LN0*Emf z0yi)L3bgP6h(sde;weC<1}7|@LpY6qqZb3#GNqw3f)z}I0U88=Ss@-U69XBnzz`2& z$aoPYa(!RphStF8Ahp1$0kxnD{y2yP^G9kSs`ScBL3jEM;Cujn#DZ6l1p*9%0_l1J zfDl`B$TxtPhVZ)%sSDB#Er1P}@CSK7j}jsLx5`Hd$dVuDalyCsI5jf-0C@hN%iJRJ zaVN{gnbap|Ih`N1o04a{kopuX7r0rG?To5acC@^=O6guB(bz3dWu~@i2Ok!LzotJl(4QXC{)98b7^PabW4;ZB zlHEp6;WxOutJM@QAGyLttqy1Y3@&R(Cu^$iJ)Rxs`KGng<>~Tzu-b#X;fy3V7hO6; z(eg&7QrZ%!^uQ?b_AR!;Et0X;^j*3bdRTww*DU|c8(tD&^by6bOmRl_JBo_zm%;q; zG?SaKOP1Aaa^Yw>IS=zunXWyB1+>VY?*}YixQe(2QV7Z&+W<4RMMm72J-D3tW2_ z;Ih@qqO#K4!MlN}#pUJUw9E!?n(q$01-;0tM9JQKF0x2uRZJK24($Ky^U#Ai!4dB6 zLRV4wHDg?2ursHSe||UG(M=H6(Q}N-I$Pkvnc!CuarR$a3G3*>7K*Pjs7_YOlT=p1 zQF9KBUW3DJtv}{k5tWa{jU?hYG*(go+K$b=s_F_me=2@XoJ@=H_n@;eKDEn*gY`FR zZ#s~ZYU^B2V~uO->rxVvKO>iJqC8(n%DYY#9IhEF5li?{;kGdgI$ZruVgvTh-SkwN zr-pdtmCrGXn7O*6xhcJ4`71@V@a&O?f8?Oee@fZh*gM!oLiLg>#{H-s@XCVUu-PiX zxHHEe6B0|=#B2DpqW2N@n zA~F+9+aJGLdGQuqz$g<5M@CK+l4Ivbh8Z!@; +} + +export async function readBrowserInfo(): Promise { + const data = (navigator as Navigator & { userAgentData?: UserAgentData }).userAgentData; + const product = detectProduct(data?.brands); + const full = data ? await data.getHighEntropyValues(["fullVersionList"]) : {}; + const version = + full.fullVersionList?.find((b) => b.brand === product.brand)?.version ?? + data?.brands.find((b) => b.brand === product.brand)?.version ?? + ""; + return { product, version }; +} diff --git a/extension/src/background/controller.test.ts b/extension/src/background/controller.test.ts new file mode 100644 index 0000000..4da0306 --- /dev/null +++ b/extension/src/background/controller.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import type { OffscreenCommand } from "@/shared/messages"; +import { Background, type StorageLike } from "./controller"; +import { HandlerRegistry } from "./registry"; + +class MemoryStorage implements StorageLike { + readonly items = new Map(); + + get(keys: string[]): Promise> { + return Promise.resolve( + Object.fromEntries(keys.filter((k) => this.items.has(k)).map((k) => [k, this.items.get(k)])), + ); + } + + set(items: Record): Promise { + Object.entries(items).forEach(([k, v]) => this.items.set(k, v)); + return Promise.resolve(); + } + + remove(keys: string | string[]): Promise { + [keys].flat().forEach((k) => this.items.delete(k)); + return Promise.resolve(); + } +} + +function setup(storage = new MemoryStorage()) { + const commands: OffscreenCommand[] = []; + let reply: unknown = undefined; + const registry = new HandlerRegistry(); + registry.register("windows.list", () => Promise.resolve({ windows: [] })); + const background = new Background({ + storage, + offscreen: (command) => { + commands.push(command); + return Promise.resolve(reply); + }, + registry, + browser: Promise.resolve({ + product: { brand: "Microsoft Edge", slug: "edge", product: "Edge" }, + version: "129.0.2792.65", + }), + extensionVersion: "0.1.0", + }); + return { + background, + storage, + commands, + setReply: (next: unknown) => { + reply = next; + }, + }; +} + +describe("instance identity and configuration", () => { + it("generates an instance ID and default name on first start and reuses them afterwards", async () => { + const first = setup(); + const config = await first.background.handle({ target: "background", type: "offscreenReady" }); + + expect(config).toMatchObject({ instanceId: expect.stringMatching(/^[0-9a-f]{32}$/) as unknown }); + const { instanceId, name } = config as { instanceId: string; name: string }; + expect(name).toBe(`edge-${instanceId.slice(0, 4)}`); + expect(first.storage.items.get("instanceId")).toBe(instanceId); + expect(first.storage.items.get("name")).toBe(name); + + const restarted = setup(first.storage); + await expect(restarted.background.handle({ target: "background", type: "offscreenReady" })).resolves.toMatchObject({ + instanceId, + name, + }); + }); + + it("creates a single identity when several requests race on first start", async () => { + const { background, storage } = setup(); + const [a, b] = await Promise.all([ + background.handle({ target: "background", type: "offscreenReady" }), + background.handle({ target: "background", type: "offscreenReady" }), + ]); + + expect((a as { instanceId: string }).instanceId).toBe((b as { instanceId: string }).instanceId); + expect(storage.items.get("instanceId")).toBe((a as { instanceId: string }).instanceId); + }); + + it("hands the offscreen document the stored pairing, the default address and the declared methods", async () => { + const { background, storage } = setup(); + await storage.set({ key: "ab".repeat(32) }); + + await expect(background.handle({ target: "background", type: "offscreenReady" })).resolves.toMatchObject({ + key: "ab".repeat(32), + address: "127.0.0.1:8643", + methods: ["windows.list"], + product: "Edge", + productVersion: "129.0.2792.65", + extensionVersion: "0.1.0", + }); + }); + + it("reports no key before pairing", async () => { + const { background } = setup(); + + await expect(background.handle({ target: "background", type: "offscreenReady" })).resolves.toMatchObject({ + key: null, + }); + }); +}); + +describe("persistence reported by the offscreen document", () => { + it("saves the key and name of a completed pairing", async () => { + const { background, storage } = setup(); + await background.handle({ target: "background", type: "paired", key: "cd".repeat(32), name: "edge-1234" }); + + expect(storage.items.get("key")).toBe("cd".repeat(32)); + expect(storage.items.get("name")).toBe("edge-1234"); + }); + + it("saves a name the daemon accepted", async () => { + const { background, storage } = setup(); + await background.handle({ target: "background", type: "renamed", name: "work" }); + + expect(storage.items.get("name")).toBe("work"); + }); + + it("runs business requests through the handler registry", async () => { + const { background } = setup(); + + await expect( + background.handle({ target: "background", type: "rpc", method: "windows.list", input: {} }), + ).resolves.toEqual({ ok: true, result: { windows: [] } }); + }); +}); + +describe("popup requests", () => { + it("forwards getState to the offscreen document", async () => { + const { background, commands, setReply } = setup(); + setReply({ status: "unpaired" }); + + await expect(background.handle({ target: "background", type: "getState" })).resolves.toEqual({ + status: "unpaired", + }); + expect(commands).toEqual([{ target: "offscreen", type: "getState" }]); + }); + + it("pairs with the normalized code and refuses a malformed one", async () => { + const { background, commands } = setup(); + + await expect(background.handle({ target: "background", type: "pair", code: "7k3m-9qpx" })).resolves.toEqual({ + ok: true, + }); + await expect(background.handle({ target: "background", type: "pair", code: "12" })).resolves.toEqual({ + ok: false, + error: "invalid-code", + }); + expect(commands).toEqual([{ target: "offscreen", type: "pair", code: "7K3M9QPX" }]); + }); + + it("refuses an invalid name without reconnecting and passes on the offscreen result otherwise", async () => { + const { background, commands, setReply } = setup(); + setReply({ ok: false, error: "name-taken" }); + + await expect(background.handle({ target: "background", type: "rename", name: "Bad Name" })).resolves.toEqual({ + ok: false, + error: "invalid-name", + }); + await expect(background.handle({ target: "background", type: "rename", name: "work" })).resolves.toEqual({ + ok: false, + error: "name-taken", + }); + expect(commands).toEqual([{ target: "offscreen", type: "rename", name: "work" }]); + }); + + it("saves a valid daemon address and reconnects to it, and refuses an invalid one", async () => { + const { background, commands, storage } = setup(); + + await expect( + background.handle({ target: "background", type: "setAddress", address: " 127.0.0.1:9000 " }), + ).resolves.toEqual({ ok: true }); + await expect(background.handle({ target: "background", type: "setAddress", address: "http://x" })).resolves.toEqual( + { ok: false, error: "invalid-address" }, + ); + expect(storage.items.get("address")).toBe("127.0.0.1:9000"); + expect(commands).toEqual([{ target: "offscreen", type: "setAddress", address: "127.0.0.1:9000" }]); + + await expect(background.handle({ target: "background", type: "offscreenReady" })).resolves.toMatchObject({ + address: "127.0.0.1:9000", + }); + }); + + it("forget deletes the stored key and disconnects", async () => { + const { background, commands, storage } = setup(); + await storage.set({ key: "ab".repeat(32), name: "work" }); + + await background.handle({ target: "background", type: "forget" }); + expect(storage.items.has("key")).toBe(false); + expect(storage.items.get("name")).toBe("work"); + expect(commands).toEqual([{ target: "offscreen", type: "forget" }]); + }); + + it("retry now is passed to the offscreen document", async () => { + const { background, commands } = setup(); + await background.handle({ target: "background", type: "retryNow" }); + + expect(commands).toEqual([{ target: "offscreen", type: "retryNow" }]); + }); +}); diff --git a/extension/src/background/controller.ts b/extension/src/background/controller.ts new file mode 100644 index 0000000..6b14b8d --- /dev/null +++ b/extension/src/background/controller.ts @@ -0,0 +1,140 @@ +import { DEFAULT_ADDRESS, parseAddress } from "@/shared/address"; +import { type ProductInfo, defaultName, isValidName, newInstanceId } from "@/shared/identity"; +import type { + BackgroundMessage, + ConnectionConfig, + OffscreenCommand, + PairResult, + RenameResult, + SetAddressResult, +} from "@/shared/messages"; +import { normalizePairingCode } from "@/shared/pairing-code"; +import type { HandlerRegistry } from "./registry"; + +// chrome.storage.local 的最小子集:实例身份、名称、会话密钥与 daemon 地址都存在扩展本地存储里。 +export interface StorageLike { + get(keys: string[]): Promise>; + set(items: Record): Promise; + remove(keys: string | string[]): Promise; +} + +export interface BackgroundDeps { + storage: StorageLike; + // 确保 offscreen 文档存在后把命令交给它,返回它的应答。 + offscreen(command: OffscreenCommand): Promise; + registry: HandlerRegistry; + // 浏览器的完整版本号只能异步取得(navigator.userAgentData.getHighEntropyValues)。 + browser: Promise; + extensionVersion: string; +} + +export interface BrowserInfo { + product: ProductInfo; + version: string; +} + +interface Identity { + instanceId: string; + name: string; +} + +function stored(items: Record, key: string): string | undefined { + const value = items[key]; + return typeof value === "string" ? value : undefined; +} + +// service worker 一侧:唯一能读写扩展存储的地方,负责弹窗请求的边界校验、持久化,并把业务请求交给处理注册表。 +export class Background { + private identity: Promise | null = null; + + constructor(private readonly deps: BackgroundDeps) {} + + handle(message: BackgroundMessage): Promise { + switch (message.type) { + case "offscreenReady": + return this.config(); + case "paired": + return this.deps.storage.set({ key: message.key, name: message.name }); + case "renamed": + return this.deps.storage.set({ name: message.name }); + case "rpc": + return this.deps.registry.dispatch(message.method, message.input); + case "getState": + return this.deps.offscreen({ target: "offscreen", type: "getState" }); + case "pair": + return this.pair(message.code); + case "rename": + return this.rename(message.name); + case "retryNow": + return this.deps.offscreen({ target: "offscreen", type: "retryNow" }); + case "forget": + return this.forget(); + case "setAddress": + return this.setAddress(message.address); + } + } + + // 实例 ID 首次使用时生成并持久化;同一 service worker 生命周期内的并发调用共享同一次生成。 + private loadIdentity(): Promise { + this.identity ??= (async () => { + const items = await this.deps.storage.get(["instanceId", "name"]); + const instanceId = stored(items, "instanceId") ?? newInstanceId(); + const name = stored(items, "name") ?? defaultName((await this.deps.browser).product, instanceId); + await this.deps.storage.set({ instanceId, name }); + return { instanceId, name }; + })(); + return this.identity; + } + + private async config(): Promise { + const { instanceId } = await this.loadIdentity(); + const items = await this.deps.storage.get(["name", "key", "address"]); + const browser = await this.deps.browser; + const name = stored(items, "name"); + if (!name) { + throw new Error("instance name missing after identity was created"); + } + return { + instanceId, + name, + address: stored(items, "address") ?? DEFAULT_ADDRESS, + key: stored(items, "key") ?? null, + methods: this.deps.registry.methods(), + product: browser.product.product, + productVersion: browser.version, + extensionVersion: this.deps.extensionVersion, + }; + } + + private async pair(input: string): Promise { + const code = normalizePairingCode(input); + if (!code) { + return { ok: false, error: "invalid-code" }; + } + await this.deps.offscreen({ target: "offscreen", type: "pair", code }); + return { ok: true }; + } + + private async rename(name: string): Promise { + if (!isValidName(name)) { + return { ok: false, error: "invalid-name" }; + } + return (await this.deps.offscreen({ target: "offscreen", type: "rename", name })) as RenameResult; + } + + // 只删除扩展这边的密钥;daemon 那边的登记要由 sctl browsers forget 删除。 + private async forget(): Promise { + await this.deps.storage.remove("key"); + await this.deps.offscreen({ target: "offscreen", type: "forget" }); + } + + private async setAddress(input: string): Promise { + const address = parseAddress(input); + if (!address) { + return { ok: false, error: "invalid-address" }; + } + await this.deps.storage.set({ address }); + await this.deps.offscreen({ target: "offscreen", type: "setAddress", address }); + return { ok: true }; + } +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts new file mode 100644 index 0000000..e480df3 --- /dev/null +++ b/extension/src/background/index.ts @@ -0,0 +1,38 @@ +import { registerHandlers } from "@/handlers"; +import type { BackgroundMessage } from "@/shared/messages"; +import { listen, request } from "@/shared/messaging"; +import { readBrowserInfo } from "./browser-info"; +import { Background } from "./controller"; +import { createOffscreenKeeper } from "./offscreen-document"; +import { HandlerRegistry } from "./registry"; + +const registry = new HandlerRegistry(); +registerHandlers(registry); + +const ensureOffscreen = createOffscreenKeeper({ + getContexts: (filter) => chrome.runtime.getContexts(filter), + createDocument: (parameters) => chrome.offscreen.createDocument(parameters), +}); + +const background = new Background({ + storage: chrome.storage.local, + offscreen: async (command) => { + await ensureOffscreen(); + return request(chrome.runtime, command); + }, + registry, + browser: readBrowserInfo(), + extensionVersion: chrome.runtime.getManifest().version, +}); + +// 监听器必须在顶层同步注册,service worker 被消息唤醒时才能收到这条消息。 +listen(chrome.runtime, "background", (message) => background.handle(message)); + +// 连接住在 offscreen 文档里;浏览器启动、扩展安装或 service worker 被唤醒时都确保它存在。 +function keepConnection(): void { + ensureOffscreen().catch((error: unknown) => console.error("failed to create the offscreen document", error)); +} + +chrome.runtime.onStartup.addListener(keepConnection); +chrome.runtime.onInstalled.addListener(keepConnection); +keepConnection(); diff --git a/extension/src/background/offscreen-document.test.ts b/extension/src/background/offscreen-document.test.ts new file mode 100644 index 0000000..e964713 --- /dev/null +++ b/extension/src/background/offscreen-document.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { OFFSCREEN_URL, createOffscreenKeeper, type OffscreenApi } from "./offscreen-document"; + +function fakeApi(existing: number) { + const created: string[] = []; + let contexts = existing; + const api: OffscreenApi = { + getContexts: () => Promise.resolve(Array.from({ length: contexts })), + createDocument: ({ url }) => { + created.push(url); + return new Promise((resolve) => + setTimeout(() => { + contexts++; + resolve(); + }, 1), + ); + }, + }; + return { api, created }; +} + +describe("offscreen document", () => { + it("creates the document holding the connection when none exists", async () => { + const { api, created } = fakeApi(0); + await createOffscreenKeeper(api)(); + + expect(created).toEqual([OFFSCREEN_URL]); + }); + + it("reuses an existing document", async () => { + const { api, created } = fakeApi(1); + await createOffscreenKeeper(api)(); + + expect(created).toEqual([]); + }); + + it("creates only one document when several callers race", async () => { + const { api, created } = fakeApi(0); + const ensure = createOffscreenKeeper(api); + await Promise.all([ensure(), ensure(), ensure()]); + + expect(created).toEqual([OFFSCREEN_URL]); + }); +}); diff --git a/extension/src/background/offscreen-document.ts b/extension/src/background/offscreen-document.ts new file mode 100644 index 0000000..edb4547 --- /dev/null +++ b/extension/src/background/offscreen-document.ts @@ -0,0 +1,37 @@ +// 相对扩展根目录的路径,与构建产物 dist/offscreen/index.html 对应。 +export const OFFSCREEN_URL = "offscreen/index.html"; + +export interface OffscreenApi { + getContexts(filter: { contextTypes: ["OFFSCREEN_DOCUMENT"] }): Promise; + createDocument(parameters: { + url: string; + reasons: `${chrome.offscreen.Reason}`[]; + justification: string; + }): Promise; +} + +// 返回一个幂等的 ensure:offscreen 文档不存在时创建它,并发调用只创建一次(Chrome 同时只允许一个)。 +export function createOffscreenKeeper(api: OffscreenApi): () => Promise { + let creating: Promise | null = null; + return async () => { + if (creating) { + return creating; + } + creating = (async () => { + const existing = await api.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] }); + if (existing.length === 0) { + await api.createDocument({ + url: OFFSCREEN_URL, + // 没有专门针对 WebSocket 的 reason;选一个不会被 Chrome 按时长回收的,justification 如实说明用途。 + reasons: ["WORKERS"], + justification: "Keep the WebSocket connection to the local sctl daemon open while the service worker sleeps", + }); + } + })(); + try { + await creating; + } finally { + creating = null; + } + }; +} diff --git a/extension/src/background/registry.test.ts b/extension/src/background/registry.test.ts new file mode 100644 index 0000000..a3a1657 --- /dev/null +++ b/extension/src/background/registry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { HandlerError, HandlerRegistry } from "./registry"; + +describe("handler registry", () => { + it("declares registered methods and answers with the handler's result", async () => { + const registry = new HandlerRegistry(); + registry.register("windows.list", () => Promise.resolve({ windows: [] })); + + expect(registry.methods()).toEqual(["windows.list"]); + await expect(registry.dispatch("windows.list", {})).resolves.toEqual({ ok: true, result: { windows: [] } }); + }); + + it("maps a handler's domain error to its code and message", async () => { + const registry = new HandlerRegistry(); + registry.register("tabs.activate", () => Promise.reject(new HandlerError("NOT_FOUND", "no tab 7"))); + + await expect(registry.dispatch("tabs.activate", { tabId: 7 })).resolves.toEqual({ + ok: false, + code: "NOT_FOUND", + message: "no tab 7", + }); + }); + + it("hides unexpected handler failures behind INTERNAL_ERROR", async () => { + const registry = new HandlerRegistry(); + registry.register("tabs.close", () => Promise.reject(new Error("chrome exploded at /secret/path"))); + + await expect(registry.dispatch("tabs.close", { tabIds: [1] })).resolves.toEqual({ + ok: false, + code: "INTERNAL_ERROR", + message: "internal error", + }); + }); + + it("refuses to register the same method twice", () => { + const registry = new HandlerRegistry(); + registry.register("tabs.list", () => Promise.resolve({ contentTrust: "untrusted-page-content", tabs: [] })); + + expect(() => + registry.register("tabs.list", () => Promise.resolve({ contentTrust: "untrusted-page-content", tabs: [] })), + ).toThrow(/tabs.list/); + }); +}); diff --git a/extension/src/background/registry.ts b/extension/src/background/registry.ts new file mode 100644 index 0000000..1f61d4b --- /dev/null +++ b/extension/src/background/registry.ts @@ -0,0 +1,49 @@ +import type { RpcMethod, RpcParams, RpcResult } from "@/protocol/generated/protocol.generated"; +import type { ErrorCode, RpcOutcome } from "@/shared/messages"; + +// 入参已由 offscreen 在 WebSocket 边界用生成的校验器验证过,处理函数可直接按类型使用。 +export type RpcHandler = (params: RpcParams) => Promise>; + +// 处理函数用它报告协议登记的领域错误;其他异常一律作为 INTERNAL_ERROR,不把内部细节交给 daemon。 +export class HandlerError extends Error { + constructor( + readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "HandlerError"; + } +} + +// 业务方法处理注册表:注册了哪些方法,扩展就在能力声明里声明哪些方法。 +export class HandlerRegistry { + private readonly handlers = new Map Promise>(); + + register(method: M, handler: RpcHandler): void { + if (this.handlers.has(method)) { + throw new Error(`handler for ${method} is already registered`); + } + this.handlers.set(method, handler as (params: unknown) => Promise); + } + + methods(): RpcMethod[] { + return [...this.handlers.keys()]; + } + + async dispatch(method: RpcMethod, params: unknown): Promise { + const handler = this.handlers.get(method); + if (!handler) { + // offscreen 只转发已声明的方法,走到这里说明声明与注册不一致。 + throw new Error(`no handler registered for ${method}`); + } + try { + return { ok: true, result: await handler(params) }; + } catch (error) { + if (error instanceof HandlerError) { + return { ok: false, code: error.code, message: error.message }; + } + console.error(`handler for ${method} failed`, error); + return { ok: false, code: "INTERNAL_ERROR", message: "internal error" }; + } + } +} diff --git a/extension/src/handlers/index.ts b/extension/src/handlers/index.ts new file mode 100644 index 0000000..36d36ec --- /dev/null +++ b/extension/src/handlers/index.ts @@ -0,0 +1,4 @@ +import type { HandlerRegistry } from "@/background/registry"; + +// 标签页与窗口方法的处理函数在这里注册;注册了哪些方法,扩展就向 daemon 声明哪些能力。 +export function registerHandlers(_registry: HandlerRegistry): void {} diff --git a/extension/src/lib/utils.ts b/extension/src/lib/utils.ts new file mode 100644 index 0000000..a70ebb6 --- /dev/null +++ b/extension/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/extension/src/manifest.json b/extension/src/manifest.json new file mode 100644 index 0000000..6dccc21 --- /dev/null +++ b/extension/src/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "description": "__MSG_extDescription__", + "default_locale": "en", + "minimum_chrome_version": "116", + "permissions": ["tabs", "storage", "offscreen"], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "__MSG_extName__", + "default_popup": "popup/index.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/src/offscreen/connection.fixture.ts b/extension/src/offscreen/connection.fixture.ts new file mode 100644 index 0000000..241a040 --- /dev/null +++ b/extension/src/offscreen/connection.fixture.ts @@ -0,0 +1,277 @@ +// 连接测试的替身:可手动推进的计时器、可由测试驱动的假 WebSocket,以及按 docs/protocol.md §2 +// 独立实现 daemon 一侧握手的假 daemon(MAC 直接用 WebCrypto 计算,不复用被测实现)。 +import { CRYPTO, SCHEMA_VERSION } from "@/protocol/generated/protocol.generated"; +import type { ConnectionConfig, RpcOutcome } from "@/shared/messages"; +import type { ConnectionState } from "@/shared/state"; +import { Connection, type SocketEvents, type SocketLike, type Timers } from "./connection"; +import { derivePairingKeys } from "./crypto"; + +export const INSTANCE_ID = "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b"; +export const SESSION_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +export const PAIRING_CODE = "7K3M9QPX"; + +export function config(overrides: Partial = {}): ConnectionConfig { + return { + instanceId: INSTANCE_ID, + name: "chrome-3f2a", + address: "127.0.0.1:8643", + key: SESSION_KEY, + methods: ["tabs.list"], + product: "Chrome", + productVersion: "129.0.6668.58", + extensionVersion: "0.1.0", + ...overrides, + }; +} + +export class ManualTimers implements Timers { + private current = 1_000_000; + private seq = 0; + private readonly tasks = new Map void }>(); + + now(): number { + return this.current; + } + + setTimeout(fn: () => void, ms: number): number { + const id = ++this.seq; + this.tasks.set(id, { at: this.current + ms, fn }); + return id; + } + + clearTimeout(id: number): void { + this.tasks.delete(id); + } + + pending(): number { + return this.tasks.size; + } + + advance(ms: number): void { + const target = this.current + ms; + for (;;) { + const due = [...this.tasks.entries()].filter(([, t]) => t.at <= target).sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) { + break; + } + this.tasks.delete(due[0]); + this.current = due[1].at; + due[1].fn(); + } + this.current = target; + } +} + +export interface WireMessage { + jsonrpc: "2.0"; + id?: string; + method?: string; + params?: Record; + result?: Record; + error?: { code: number; message: string; data?: { code: string } }; +} + +export class FakeSocket implements SocketLike { + readonly sent: WireMessage[] = []; + closedWith: number | null = null; + private waiters: Array<() => void> = []; + + constructor( + readonly url: string, + private readonly events: SocketEvents, + ) {} + + send(data: string): void { + this.sent.push(JSON.parse(data) as WireMessage); + const waiters = this.waiters; + this.waiters = []; + waiters.forEach((wake) => wake()); + } + + close(code = 1005): void { + this.closedWith ??= code; + } + + open(): void { + this.events.open(); + } + + receive(message: WireMessage): void { + this.events.message(JSON.stringify(message)); + } + + // 模拟对端或网络关闭:浏览器在连接失败时以 1006 关闭,daemon 拒绝握手时以 1008 关闭。 + drop(code: number): void { + this.closedWith ??= code; + this.events.close(code); + } + + async nextSent(): Promise { + const count = this.sent.length; + while (this.sent.length === count) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame on ${this.url}`)), 1000); + this.waiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + return this.sent[count]; + } +} + +const encoder = new TextEncoder(); + +function hex(bytes: ArrayBuffer | Uint8Array): string { + return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function fromHex(text: string): Uint8Array { + return Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16)); +} + +async function hmac(key: Uint8Array, text: string): Promise { + const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return hex(await crypto.subtle.sign("HMAC", k, encoder.encode(text))); +} + +export const NONCE_D = "d".repeat(64); + +export interface AuthOptions { + // 会话模式用 hex 长期密钥,配对模式用 daemon 显示的配对码。 + auth: { mode: "session"; key: string } | { mode: "pairing"; code: string; deliverKey: string }; + corruptDaemonMac?: boolean; +} + +// 扮演 daemon 完成认证:发出挑战、按 §2.1 校验扩展应答,然后发送 $session.authenticated。返回扩展的应答。 +export async function authenticate(socket: FakeSocket, options: AuthOptions): Promise { + const reply = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "auth-1", method: "$session.authenticate", params: { nonceD: NONCE_D } }); + const response = await reply; + const result = response.result as { mode: string; nonceE: string; hmac: string; peer: { instanceId: string } }; + const auth = options.auth; + const pairing = auth.mode === "pairing"; + const keys = auth.mode === "pairing" ? await derivePairingKeys(auth.code) : null; + const macKey = keys ? keys.mac : fromHex(auth.mode === "session" ? auth.key : ""); + const extContext = pairing ? CRYPTO.context.browserPairExt : CRYPTO.context.browserSessionExt; + const expected = await hmac(macKey, extContext + result.peer.instanceId + NONCE_D + result.nonceE); + if (expected !== result.hmac) { + throw new Error("extension MAC does not verify"); + } + const daemonContext = pairing ? CRYPTO.context.browserPairDaemon : CRYPTO.context.browserSessionDaemon; + let mac = await hmac(macKey, daemonContext + result.peer.instanceId + result.nonceE + NONCE_D); + if (options.corruptDaemonMac) { + mac = mac.replace(/^./, (c) => (c === "0" ? "1" : "0")); + } + const params: Record = { hmac: mac }; + if (keys && auth.mode === "pairing") { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const aes = await crypto.subtle.importKey("raw", keys.enc, { name: "AES-GCM" }, false, ["encrypt"]); + const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aes, fromHex(auth.deliverKey)); + params.key = { + ciphertext: btoa(String.fromCharCode(...new Uint8Array(sealed))), + iv: btoa(String.fromCharCode(...iv)), + }; + } + socket.receive({ jsonrpc: "2.0", method: "$session.authenticated", params }); + return response; +} + +// 发出 hello 并返回扩展随后的能力声明请求。 +export async function hello(socket: FakeSocket): Promise { + const request = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" } }); + const capabilities = await request; + if (capabilities.method !== "$session.capabilities" || capabilities.params?.schemaVersion !== SCHEMA_VERSION) { + throw new Error("expected a capabilities request"); + } + return capabilities; +} + +export async function acceptSession(socket: FakeSocket, key = SESSION_KEY): Promise { + socket.open(); + await authenticate(socket, { auth: { mode: "session", key } }); + const capabilities = await hello(socket); + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + return capabilities; +} + +export function conflict(socket: FakeSocket, capabilities: WireMessage): void { + socket.receive({ + jsonrpc: "2.0", + id: capabilities.id, + error: { code: -32000, message: "name taken", data: { code: "CONFLICT" } }, + }); + socket.drop(1000); +} + +export function harness(start: Partial = {}) { + const timers = new ManualTimers(); + const sockets: FakeSocket[] = []; + const states: ConnectionState[] = []; + const persisted: { keys: Array<{ key: string; name: string }>; names: string[] } = { keys: [], names: [] }; + const dispatched: Array<{ method: string; input: unknown }> = []; + let outcome: RpcOutcome = { ok: true, result: { contentTrust: "untrusted-page-content", tabs: [] } }; + // 测试可以让持久化挂起,模拟 service worker 写存储期间用户又发出了别的命令。 + let persistence: Promise = Promise.resolve(); + const connection = new Connection({ + createSocket: (url, events) => { + const socket = new FakeSocket(url, events); + sockets.push(socket); + return socket; + }, + timers, + onState: (state) => states.push(state), + persistKey: (key, name) => { + persisted.keys.push({ key, name }); + return persistence; + }, + persistName: (name) => { + persisted.names.push(name); + return persistence; + }, + dispatch: (method, input) => { + dispatched.push({ method, input }); + return Promise.resolve(outcome); + }, + }); + connection.start(config(start)); + return { + connection, + timers, + sockets, + states, + persisted, + dispatched, + socket: (): FakeSocket => { + const last = sockets.at(-1); + if (!last) { + throw new Error("no socket was opened"); + } + return last; + }, + state: (): ConnectionState => connection.getState(), + setOutcome: (next: RpcOutcome) => { + outcome = next; + }, + holdPersistence: (): (() => void) => { + let release = () => {}; + persistence = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + }; +} + +// 等待异步握手(WebCrypto 走线程池)推进到条件成立。 +export async function until(condition: () => boolean): Promise { + for (let i = 0; i < 200; i++) { + if (condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("condition not reached"); +} diff --git a/extension/src/offscreen/connection.test.ts b/extension/src/offscreen/connection.test.ts new file mode 100644 index 0000000..4b83687 --- /dev/null +++ b/extension/src/offscreen/connection.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from "vitest"; +import { + INSTANCE_ID, + PAIRING_CODE, + SESSION_KEY, + acceptSession, + authenticate, + conflict, + harness, + hello, + until, +} from "./connection.fixture"; + +const DELIVERED_KEY = "ff".repeat(32); + +describe("session handshake", () => { + it("binds the instance identity into authentication, declares its name and capabilities, then connects", async () => { + const h = harness(); + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 0, retryAt: null }); + const socket = h.socket(); + expect(socket.url).toBe("ws://127.0.0.1:8643/"); + socket.open(); + + const reply = await authenticate(socket, { auth: { mode: "session", key: SESSION_KEY } }); + expect(reply.id).toBe("auth-1"); + expect(reply.result).toMatchObject({ mode: "session", peer: { kind: "browser", instanceId: INSTANCE_ID } }); + expect(reply.result?.nonceE).toMatch(/^[0-9a-f]{64}$/); + + const capabilities = await hello(socket); + expect(capabilities.params).toEqual({ + schemaVersion: "1.0.0", + methods: ["tabs.list"], + peer: { name: "chrome-3f2a", product: "Chrome", productVersion: "129.0.6668.58", extensionVersion: "0.1.0" }, + }); + expect(h.state().status).toBe("reconnecting"); + + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.state().status === "connected"); + expect(h.state()).toEqual({ + status: "connected", + daemonVersion: "0.1.0", + instanceId: INSTANCE_ID, + name: "chrome-3f2a", + address: "127.0.0.1:8643", + }); + expect(h.states.at(-1)).toEqual(h.state()); + }); + + it("stops retrying and reports rejected when the daemon cannot prove it holds the session key", async () => { + const h = harness(); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "session", key: SESSION_KEY }, corruptDaemonMac: true }); + await until(() => socket.closedWith !== null); + socket.drop(socket.closedWith ?? 1005); + + expect(h.state().status).toBe("rejected"); + expect(h.timers.pending()).toBe(0); + }); + + it("stops retrying and reports rejected when the daemon refuses the handshake", async () => { + const h = harness(); + const socket = h.socket(); + socket.open(); + const reply = socket.nextSent(); + socket.receive({ + jsonrpc: "2.0", + id: "auth-1", + method: "$session.authenticate", + params: { nonceD: "d".repeat(64) }, + }); + await reply; + socket.drop(1008); + + await until(() => h.state().status !== "reconnecting"); + expect(h.state().status).toBe("rejected"); + expect(h.timers.pending()).toBe(0); + expect(h.sockets).toHaveLength(1); + }); + + it("abandons a handshake that does not complete within the auth timeout and retries", () => { + const h = harness(); + const socket = h.socket(); + socket.open(); + h.timers.advance(4999); + expect(socket.closedWith).toBeNull(); + h.timers.advance(1); + expect(socket.closedWith).not.toBeNull(); + socket.drop(socket.closedWith ?? 1005); + + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1 }); + }); + + it("closes the connection on a malformed challenge and retries", async () => { + const h = harness(); + const socket = h.socket(); + socket.open(); + socket.receive({ jsonrpc: "2.0", id: "auth-1", method: "$session.authenticate", params: { nonceD: "zz" } }); + await until(() => socket.closedWith !== null); + socket.drop(socket.closedWith ?? 1005); + + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1 }); + }); +}); + +describe("reconnect backoff", () => { + it("retries an unreachable daemon after 1 s, doubling each time up to 30 s", () => { + const h = harness(); + const delays: number[] = []; + for (let attempt = 1; attempt <= 7; attempt++) { + const before = h.sockets.length; + h.socket().drop(1006); + const state = h.state(); + expect(state).toMatchObject({ status: "reconnecting", attempt }); + const retryAt = state.status === "reconnecting" ? state.retryAt : null; + if (retryAt === null) { + throw new Error("no retry scheduled"); + } + const delay = retryAt - h.timers.now(); + delays.push(delay); + h.timers.advance(delay - 1); + expect(h.sockets).toHaveLength(before); + h.timers.advance(1); + expect(h.sockets).toHaveLength(before + 1); + } + expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000]); + }); + + it("retry now connects immediately and resets the backoff", () => { + const h = harness(); + h.socket().drop(1006); + h.timers.advance(1000); + h.socket().drop(1006); + h.timers.advance(2000); + h.socket().drop(1006); + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 3, retryAt: h.timers.now() + 4000 }); + + h.connection.retryNow(); + expect(h.sockets).toHaveLength(4); + expect(h.state()).toMatchObject({ status: "reconnecting", retryAt: null }); + h.socket().drop(1006); + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1, retryAt: h.timers.now() + 1000 }); + expect(h.timers.pending()).toBe(1); + }); + + it("reconnects 1 s after an established connection drops", async () => { + const h = harness(); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + h.socket().drop(1001); + + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1, retryAt: h.timers.now() + 1000 }); + }); +}); + +describe("pairing", () => { + it("starts unpaired and persists the delivered key only after the daemon accepts the capabilities", async () => { + const h = harness({ key: null }); + expect(h.state().status).toBe("unpaired"); + expect(h.sockets).toHaveLength(0); + + h.connection.pair(PAIRING_CODE); + expect(h.state().status).toBe("pairing"); + const socket = h.socket(); + socket.open(); + const reply = await authenticate(socket, { + auth: { mode: "pairing", code: PAIRING_CODE, deliverKey: DELIVERED_KEY }, + }); + expect(reply.result).toMatchObject({ mode: "pairing", peer: { kind: "browser", instanceId: INSTANCE_ID } }); + const capabilities = await hello(socket); + expect(capabilities.params?.peer).toMatchObject({ name: "chrome-3f2a" }); + expect(h.persisted.keys).toEqual([]); + + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.state().status === "connected"); + expect(h.persisted.keys).toEqual([{ key: DELIVERED_KEY, name: "chrome-3f2a" }]); + + // 之后的重连改用下发的会话密钥。 + socket.drop(1001); + h.timers.advance(1000); + await acceptSession(h.socket(), DELIVERED_KEY); + await until(() => h.state().status === "connected"); + }); + + it("reports a wrong or expired code as pair-failed without retrying", async () => { + const h = harness({ key: null }); + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + socket.open(); + const reply = socket.nextSent(); + socket.receive({ + jsonrpc: "2.0", + id: "auth-1", + method: "$session.authenticate", + params: { nonceD: "d".repeat(64) }, + }); + await reply; + socket.drop(1008); + + await until(() => h.state().status !== "pairing"); + expect(h.state()).toMatchObject({ status: "pair-failed", reason: "code-rejected" }); + expect(h.timers.pending()).toBe(0); + expect(h.persisted.keys).toEqual([]); + }); + + it("reports an unreachable daemon as pair-unreachable with the address it tried", () => { + const h = harness({ key: null, address: "127.0.0.1:9000" }); + h.connection.pair(PAIRING_CODE); + h.socket().drop(1006); + + expect(h.state()).toMatchObject({ status: "pair-unreachable", address: "127.0.0.1:9000" }); + expect(h.timers.pending()).toBe(0); + }); + + it("reports a default name held by another instance as name-taken and keeps the pairing unsaved", async () => { + const h = harness({ key: null }); + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "pairing", code: PAIRING_CODE, deliverKey: DELIVERED_KEY } }); + conflict(socket, await hello(socket)); + + await until(() => h.state().status === "pair-failed"); + expect(h.state()).toMatchObject({ status: "pair-failed", reason: "name-taken" }); + expect(h.persisted.keys).toEqual([]); + expect(h.timers.pending()).toBe(0); + }); + + it("stays unpaired when forgotten while the new pairing is still being saved", async () => { + const h = harness({ key: null }); + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "pairing", code: PAIRING_CODE, deliverKey: DELIVERED_KEY } }); + const capabilities = await hello(socket); + const release = h.holdPersistence(); + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.persisted.keys.length === 1); + + h.connection.forget(); + release(); + await new Promise((resolve) => setTimeout(resolve, 5)); + h.connection.setAddress("127.0.0.1:9000"); + + expect(h.state().status).toBe("unpaired"); + expect(h.sockets).toHaveLength(1); + }); + + it("re-pairing after a rejection replaces the old key", async () => { + const h = harness(); + const first = h.socket(); + first.open(); + first.drop(1008); + expect(h.state().status).toBe("rejected"); + + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "pairing", code: PAIRING_CODE, deliverKey: DELIVERED_KEY } }); + const capabilities = await hello(socket); + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.state().status === "connected"); + expect(h.persisted.keys).toEqual([{ key: DELIVERED_KEY, name: "chrome-3f2a" }]); + }); +}); + +describe("rename", () => { + async function connected() { + const h = harness(); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + return h; + } + + it("reconnects with the new name and persists it once the daemon accepts", async () => { + const h = await connected(); + const old = h.socket(); + const result = h.connection.rename("work-laptop"); + expect(old.closedWith).not.toBeNull(); + + const capabilities = await acceptSession(h.socket()); + expect(capabilities.params?.peer).toMatchObject({ name: "work-laptop" }); + await expect(result).resolves.toEqual({ ok: true }); + expect(h.persisted.names).toEqual(["work-laptop"]); + expect(h.state()).toMatchObject({ status: "connected", name: "work-laptop" }); + }); + + it("keeps the previous name and reconnects with it when another browser holds the new name", async () => { + const h = await connected(); + const result = h.connection.rename("taken"); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "session", key: SESSION_KEY } }); + conflict(socket, await hello(socket)); + + await expect(result).resolves.toEqual({ ok: false, error: "name-taken" }); + const retry = await acceptSession(h.socket()); + expect(retry.params?.peer).toMatchObject({ name: "chrome-3f2a" }); + await until(() => h.state().status === "connected"); + expect(h.state()).toMatchObject({ name: "chrome-3f2a" }); + expect(h.persisted.names).toEqual([]); + }); + + it("keeps the name the daemon accepted when the address changes while the rename is being saved", async () => { + const h = await connected(); + const result = h.connection.rename("work-laptop"); + const socket = h.socket(); + const release = h.holdPersistence(); + socket.open(); + await authenticate(socket, { auth: { mode: "session", key: SESSION_KEY } }); + const capabilities = await hello(socket); + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.persisted.names.length === 1); + + h.connection.setAddress("127.0.0.1:9000"); + release(); + await expect(result).resolves.toEqual({ ok: true }); + const next = await acceptSession(h.socket()); + expect(next.params?.peer).toMatchObject({ name: "work-laptop" }); + }); + + it("keeps the previous name when the daemon cannot be reached during the rename", async () => { + const h = await connected(); + const result = h.connection.rename("work-laptop"); + h.socket().drop(1006); + + await expect(result).resolves.toEqual({ ok: false, error: "not-connected" }); + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1, name: "chrome-3f2a" }); + }); + + it("refuses to rename while not connected", async () => { + const h = harness(); + h.socket().drop(1006); + + await expect(h.connection.rename("work-laptop")).resolves.toEqual({ ok: false, error: "not-connected" }); + expect(h.sockets).toHaveLength(1); + }); +}); + +describe("address and forget", () => { + it("reconnects to a changed address and keeps the pairing", async () => { + const h = harness(); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + const old = h.socket(); + + h.connection.setAddress("127.0.0.1:9000"); + expect(old.closedWith).not.toBeNull(); + expect(h.socket().url).toBe("ws://127.0.0.1:9000/"); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + expect(h.state()).toMatchObject({ address: "127.0.0.1:9000" }); + }); + + it("updates the address shown before pairing without connecting", () => { + const h = harness({ key: null }); + h.connection.setAddress("127.0.0.1:9000"); + + expect(h.state()).toMatchObject({ status: "unpaired", address: "127.0.0.1:9000" }); + expect(h.sockets).toHaveLength(0); + }); + + it("forget closes the connection and returns to unpaired without retrying", async () => { + const h = harness(); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + + h.connection.forget(); + expect(h.socket().closedWith).not.toBeNull(); + expect(h.state().status).toBe("unpaired"); + expect(h.timers.pending()).toBe(0); + }); +}); + +describe("business requests", () => { + async function connected() { + const h = harness(); + await acceptSession(h.socket()); + await until(() => h.state().status === "connected"); + return h; + } + + it("dispatches a declared method's input and answers with its result", async () => { + const h = await connected(); + const socket = h.socket(); + const response = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "r1", method: "tabs.list", params: { clientId: "sctl-cli", input: {} } }); + + expect(await response).toEqual({ + jsonrpc: "2.0", + id: "r1", + result: { contentTrust: "untrusted-page-content", tabs: [] }, + }); + expect(h.dispatched).toEqual([{ method: "tabs.list", input: {} }]); + }); + + it("answers a handler failure with the application error code", async () => { + const h = await connected(); + h.setOutcome({ ok: false, code: "NOT_FOUND", message: "no such window" }); + const socket = h.socket(); + const response = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "r2", method: "tabs.list", params: { input: { windowId: 9 } } }); + + expect(await response).toEqual({ + jsonrpc: "2.0", + id: "r2", + error: { code: -32000, message: "no such window", data: { code: "NOT_FOUND" } }, + }); + }); + + it("answers invalid input with INVALID_REQUEST without dispatching", async () => { + const h = await connected(); + const socket = h.socket(); + const response = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "r3", method: "tabs.list", params: { input: { windowId: "x" } } }); + + expect((await response).error).toMatchObject({ code: -32000, data: { code: "INVALID_REQUEST" } }); + expect(h.dispatched).toEqual([]); + }); + + it("answers a method it did not declare with METHOD_NOT_FOUND", async () => { + const h = await connected(); + const socket = h.socket(); + const response = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "r4", method: "tabs.open", params: { input: { url: "https://a.test" } } }); + + expect((await response).error).toMatchObject({ code: -32000, data: { code: "METHOD_NOT_FOUND" } }); + expect(h.dispatched).toEqual([]); + }); + + it("answers daemon pings", async () => { + const h = await connected(); + const socket = h.socket(); + const response = socket.nextSent(); + socket.receive({ jsonrpc: "2.0", id: "p1", method: "$session.ping", params: {} }); + + expect(await response).toEqual({ jsonrpc: "2.0", id: "p1", result: {} }); + }); +}); diff --git a/extension/src/offscreen/connection.ts b/extension/src/offscreen/connection.ts new file mode 100644 index 0000000..8f00ec7 --- /dev/null +++ b/extension/src/offscreen/connection.ts @@ -0,0 +1,523 @@ +import { CRYPTO, LIMITS, SCHEMA_VERSION, type RpcMethod } from "@/protocol/generated/protocol.generated"; +import { RPC_PARAM_VALIDATORS } from "@/protocol/generated/validators.generated"; +import { websocketUrl } from "@/shared/address"; +import type { ConnectionConfig, RenameResult, RpcOutcome } from "@/shared/messages"; +import type { ConnectionState, StatusDetail } from "@/shared/state"; +import { + type Bytes, + type MacMaterial, + constantTimeEqualHex, + daemonMac, + decryptSessionKey, + derivePairingKeys, + extensionMac, + hexToBytes, + randomNonceHex, +} from "./crypto"; +import { type JsonRpcMessage, decodeFrame, isRecord } from "./wire"; + +// 退避从 1 秒开始逐次翻倍,最长 30 秒。 +const BASE_RETRY_MS = 1000; +const MAX_RETRY_MS = 30_000; +// daemon 以 1008 关闭即握手被拒,原因从不回显(docs/protocol.md §2.1)。 +const CLOSE_POLICY_VIOLATION = 1008; +const CLOSE_NORMAL = 1000; +// 应用层失败统一用 -32000,领域错误码放在 error.data.code(docs/protocol.md §4)。 +const RPC_APPLICATION_ERROR = -32000; +const NONCE_PATTERN = new RegExp(`^[0-9a-f]{${CRYPTO.nonceBytes * 2}}$`); + +export interface SocketEvents { + open(): void; + message(data: string): void; + close(code: number): void; +} + +export interface SocketLike { + send(data: string): void; + close(code?: number): void; +} + +export interface Timers { + setTimeout(fn: () => void, ms: number): number; + clearTimeout(id: number): void; + now(): number; +} + +export interface ConnectionDeps { + createSocket(url: string, events: SocketEvents): SocketLike; + timers: Timers; + onState(state: ConnectionState): void; + // 持久化只能由 service worker 完成;配对与改名都在 daemon 接受能力声明、登记生效之后才落盘。 + persistKey(key: string, name: string): Promise; + persistName(name: string): Promise; + dispatch(method: RpcMethod, input: unknown): Promise; +} + +type Auth = { mode: "session"; key: string } | { mode: "pairing"; code: string }; + +// challenge → proof → hello → capabilities → connected,与 docs/protocol.md §2 的会话顺序一一对应。 +type Phase = "challenge" | "proof" | "hello" | "capabilities" | "connected"; + +// 一次连接尝试。它被替换或关闭后,其套接字事件与未完成的异步步骤全部作废。 +interface Attempt { + socket: SocketLike; + auth: Auth; + name: string; + phase: Phase; + opened: boolean; + verify: { material: MacMaterial; nonceD: string; nonceE: string; enc: Bytes | null } | null; + capabilitiesId: string; + daemonVersion: string; + pairedKey: string | null; + // 扩展主动关闭时记下的结论,close 事件据此决定下一状态;为 null 时按关闭码判断。 + verdict: "rejected" | "conflict" | null; + authTimer: number | null; + // WebCrypto 是异步的:帧按到达顺序串行处理,close 排在已到达的帧之后。 + queue: Promise; + inFlight: number; +} + +interface PendingRename { + from: string; + resolve(result: RenameResult): void; +} + +// Connection 持有到 daemon 的唯一 WebSocket,驱动带实例身份的配对/会话握手、重连退避与业务请求应答。 +export class Connection { + private config: ConnectionConfig | null = null; + private state: ConnectionState | null = null; + private attempt: Attempt | null = null; + private failures = 0; + private retryTimer: number | null = null; + private renaming: PendingRename | null = null; + + constructor(private readonly deps: ConnectionDeps) {} + + start(config: ConnectionConfig): void { + this.config = { ...config }; + if (config.key) { + this.connectSession(); + } else { + this.setState({ status: "unpaired" }); + } + } + + getState(): ConnectionState { + if (!this.state) { + throw new Error("connection has not been started"); + } + return this.state; + } + + pair(code: string): void { + this.reset(); + this.open({ mode: "pairing", code }); + this.setState({ status: "pairing" }); + } + + rename(name: string): Promise { + const config = this.cfg(); + if (this.state?.status !== "connected") { + return Promise.resolve({ ok: false, error: "not-connected" }); + } + if (name === config.name) { + return Promise.resolve({ ok: true }); + } + return new Promise((resolve) => { + const from = config.name; + this.reset(); + this.renaming = { from, resolve }; + config.name = name; + this.connectSession(); + }); + } + + retryNow(): void { + if (this.retryTimer === null) { + return; + } + this.clearRetry(); + this.failures = 0; + this.connectSession(); + } + + forget(): void { + this.reset(); + this.cfg().key = null; + this.setState({ status: "unpaired" }); + } + + setAddress(address: string): void { + const config = this.cfg(); + this.reset(); + config.address = address; + if (config.key) { + this.connectSession(); + } else { + this.setState({ status: "unpaired" }); + } + } + + private cfg(): ConnectionConfig { + if (!this.config) { + throw new Error("connection has not been started"); + } + return this.config; + } + + private setState(detail: StatusDetail): void { + const config = this.cfg(); + this.state = { ...detail, instanceId: config.instanceId, name: config.name, address: config.address }; + this.deps.onState(this.state); + } + + // 放弃当前尝试与计划中的重试,退避从头开始;进行中的改名视为失败并恢复原名称。 + private reset(): void { + this.clearRetry(); + this.failures = 0; + this.detach(); + this.abandonRename("not-connected"); + } + + private detach(): void { + const attempt = this.attempt; + if (!attempt) { + return; + } + this.attempt = null; + this.clearAuthTimer(attempt); + attempt.socket.close(CLOSE_NORMAL); + } + + private abandonRename(error: "not-connected" | "name-taken"): void { + const renaming = this.renaming; + if (!renaming) { + return; + } + this.renaming = null; + this.cfg().name = renaming.from; + renaming.resolve({ ok: false, error }); + } + + private clearRetry(): void { + if (this.retryTimer !== null) { + this.deps.timers.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } + + private clearAuthTimer(attempt: Attempt): void { + if (attempt.authTimer !== null) { + this.deps.timers.clearTimeout(attempt.authTimer); + attempt.authTimer = null; + } + } + + private connectSession(): void { + const key = this.cfg().key; + if (!key) { + throw new Error("session connect requires a paired key"); + } + this.open({ mode: "session", key }); + this.setState({ status: "reconnecting", attempt: this.failures, retryAt: null }); + } + + private open(auth: Auth): void { + const config = this.cfg(); + // 套接字事件总在创建之后异步到达,此时 attempt 已经赋值。 + const events: SocketEvents = { + open: () => this.onOpen(attempt), + message: (data) => this.onMessage(attempt, data), + close: (code) => this.onClose(attempt, code), + }; + const attempt: Attempt = { + socket: this.deps.createSocket(websocketUrl(config.address), events), + auth, + name: config.name, + phase: "challenge", + opened: false, + verify: null, + capabilitiesId: "", + daemonVersion: "", + pairedKey: null, + verdict: null, + authTimer: null, + queue: Promise.resolve(), + inFlight: 0, + }; + this.attempt = attempt; + } + + private onOpen(attempt: Attempt): void { + if (attempt !== this.attempt) { + return; + } + attempt.opened = true; + // 认证必须在 authTimeoutMs 内完成,否则放弃这次尝试。 + attempt.authTimer = this.deps.timers.setTimeout(() => { + attempt.authTimer = null; + if (attempt === this.attempt) { + attempt.socket.close(CLOSE_NORMAL); + } + }, LIMITS.authTimeoutMs); + } + + private onMessage(attempt: Attempt, data: string): void { + if (attempt !== this.attempt) { + return; + } + let message: JsonRpcMessage; + try { + message = decodeFrame(data); + } catch (error) { + console.warn("closing connection after a malformed frame", error); + attempt.socket.close(CLOSE_NORMAL); + return; + } + attempt.inFlight++; + attempt.queue = attempt.queue + .then(() => (attempt === this.attempt ? this.handle(attempt, message) : undefined)) + .catch((error: unknown) => { + console.error("closing connection after a failed handshake step", error); + if (attempt === this.attempt) { + attempt.socket.close(CLOSE_NORMAL); + } + }) + .finally(() => { + attempt.inFlight--; + }); + } + + private onClose(attempt: Attempt, code: number): void { + if (attempt !== this.attempt) { + return; + } + if (attempt.inFlight > 0) { + attempt.queue = attempt.queue.then(() => this.onClose(attempt, code)); + return; + } + this.attempt = null; + this.clearAuthTimer(attempt); + if (attempt.auth.mode === "pairing" && attempt.phase !== "connected") { + this.pairingClosed(attempt); + return; + } + if (attempt.verdict === "conflict" && this.renaming) { + // 新名称被另一个实例占用:恢复原名称并立即用它重连。 + this.abandonRename("name-taken"); + this.connectSession(); + return; + } + const refused = attempt.opened && attempt.phase !== "connected" && code === CLOSE_POLICY_VIOLATION; + if (attempt.verdict !== null || refused) { + this.abandonRename("not-connected"); + this.setState({ status: "rejected" }); + return; + } + this.abandonRename("not-connected"); + this.scheduleRetry(); + } + + private pairingClosed(attempt: Attempt): void { + if (attempt.verdict === "conflict") { + this.setState({ status: "pair-failed", reason: "name-taken" }); + } else if (!attempt.opened) { + this.setState({ status: "pair-unreachable" }); + } else { + this.setState({ status: "pair-failed", reason: "code-rejected" }); + } + } + + private scheduleRetry(): void { + this.failures++; + const delay = Math.min(BASE_RETRY_MS * 2 ** (this.failures - 1), MAX_RETRY_MS); + this.retryTimer = this.deps.timers.setTimeout(() => { + this.retryTimer = null; + this.connectSession(); + }, delay); + this.setState({ status: "reconnecting", attempt: this.failures, retryAt: this.deps.timers.now() + delay }); + } + + private send(attempt: Attempt, message: Omit): void { + if (attempt === this.attempt) { + attempt.socket.send(JSON.stringify({ jsonrpc: "2.0", ...message })); + } + } + + // 握手期间出现不符合会话顺序的消息即视为协议错误,关闭连接。 + private violation(attempt: Attempt, message: JsonRpcMessage): void { + console.warn("closing connection after an unexpected message", attempt.phase, message.method ?? message.id); + attempt.socket.close(CLOSE_NORMAL); + } + + private async handle(attempt: Attempt, message: JsonRpcMessage): Promise { + switch (attempt.phase) { + case "challenge": + return this.answerChallenge(attempt, message); + case "proof": + return this.verifyProof(attempt, message); + case "hello": + return this.declareCapabilities(attempt, message); + case "capabilities": + return this.finishHandshake(attempt, message); + case "connected": + return this.serve(attempt, message); + } + } + + private async answerChallenge(attempt: Attempt, message: JsonRpcMessage): Promise { + const nonceD = message.params?.nonceD; + if (message.method !== "$session.authenticate" || !message.id || typeof nonceD !== "string") { + return this.violation(attempt, message); + } + if (!NONCE_PATTERN.test(nonceD)) { + return this.violation(attempt, message); + } + const instanceId = this.cfg().instanceId; + let material: MacMaterial; + let enc: Bytes | null = null; + if (attempt.auth.mode === "session") { + material = { mode: "session", instanceId, key: hexToBytes(attempt.auth.key) }; + } else { + const keys = await derivePairingKeys(attempt.auth.code); + material = { mode: "pairing", instanceId, key: keys.mac }; + enc = keys.enc; + } + const nonceE = randomNonceHex(); + const hmac = await extensionMac(material, nonceD, nonceE); + attempt.verify = { material, nonceD, nonceE, enc }; + attempt.phase = "proof"; + this.send(attempt, { + id: message.id, + result: { mode: attempt.auth.mode, nonceE, hmac, peer: { kind: "browser", instanceId } }, + }); + } + + private async verifyProof(attempt: Attempt, message: JsonRpcMessage): Promise { + const verify = attempt.verify; + const hmac = message.params?.hmac; + if (message.method !== "$session.authenticated" || message.id || !verify || typeof hmac !== "string") { + return this.violation(attempt, message); + } + const expected = await daemonMac(verify.material, verify.nonceD, verify.nonceE); + if (!constantTimeEqualHex(expected, hmac)) { + // 对端不持有本实例的密钥,重试也不会成功。 + attempt.verdict = "rejected"; + attempt.socket.close(CLOSE_NORMAL); + return; + } + if (verify.enc) { + const delivery = message.params?.key; + if (!isRecord(delivery) || typeof delivery.ciphertext !== "string" || typeof delivery.iv !== "string") { + return this.violation(attempt, message); + } + attempt.pairedKey = await decryptSessionKey(verify.enc, { ciphertext: delivery.ciphertext, iv: delivery.iv }); + } + this.clearAuthTimer(attempt); + attempt.phase = "hello"; + } + + private declareCapabilities(attempt: Attempt, message: JsonRpcMessage): void { + const daemonVersion = message.params?.daemonVersion; + if (message.method !== "$session.hello" || typeof daemonVersion !== "string") { + return this.violation(attempt, message); + } + const config = this.cfg(); + attempt.daemonVersion = daemonVersion; + attempt.capabilitiesId = crypto.randomUUID(); + attempt.phase = "capabilities"; + this.send(attempt, { + id: attempt.capabilitiesId, + method: "$session.capabilities", + params: { + schemaVersion: SCHEMA_VERSION, + methods: config.methods, + peer: { + name: attempt.name, + product: config.product, + productVersion: config.productVersion, + extensionVersion: config.extensionVersion, + }, + }, + }); + } + + private async finishHandshake(attempt: Attempt, message: JsonRpcMessage): Promise { + if (message.method || message.id !== attempt.capabilitiesId) { + return this.violation(attempt, message); + } + if (message.error) { + // 名称冲突之后 daemon 会关闭连接;记下结论,由 close 事件决定回退还是报告。 + if (message.error.data?.code === "CONFLICT") { + attempt.verdict = "conflict"; + } + attempt.socket.close(CLOSE_NORMAL); + return; + } + // daemon 已经登记了这个名称:改名在此刻生效,之后的命令不能再把它当作未完成的改名回退。 + const renaming = this.renaming; + this.renaming = null; + if (attempt.pairedKey) { + await this.deps.persistKey(attempt.pairedKey, attempt.name); + // 保存期间用户可能已经忘记或重新配对,此时不能把这把密钥装回来。 + if (attempt !== this.attempt) { + return; + } + this.cfg().key = attempt.pairedKey; + } + if (renaming) { + await this.deps.persistName(attempt.name); + renaming.resolve({ ok: true }); + } + if (attempt !== this.attempt) { + return; + } + attempt.phase = "connected"; + this.failures = 0; + this.setState({ status: "connected", daemonVersion: attempt.daemonVersion }); + } + + private serve(attempt: Attempt, message: JsonRpcMessage): void { + const { id, method } = message; + if (!method || method === "$session.shutdown" || method === "$/cancelRequest") { + // 响应不会出现(连接建立后扩展不发请求);shutdown 之后 daemon 会关闭连接; + // 浏览器方法都是即时完成的,没有可作废的挂起操作。 + return; + } + if (!id) { + return; + } + if (method === "$session.ping") { + this.send(attempt, { id, result: {} }); + return; + } + const declared = this.cfg().methods.find((m) => m === method); + if (!declared) { + this.fail(attempt, id, "METHOD_NOT_FOUND", `method ${method} is not supported`); + return; + } + const input = message.params?.input; + if (!RPC_PARAM_VALIDATORS[declared](input)) { + this.fail(attempt, id, "INVALID_REQUEST", `invalid input for ${method}`); + return; + } + // 业务请求不进入帧队列:一个慢调用不能挡住心跳应答。 + void this.deps.dispatch(declared, input).then( + (outcome) => { + if (outcome.ok) { + this.send(attempt, { id, result: outcome.result as Record }); + } else { + this.fail(attempt, id, outcome.code, outcome.message); + } + }, + (error: unknown) => { + console.error(`handler for ${method} failed`, error); + this.fail(attempt, id, "INTERNAL_ERROR", "internal error"); + }, + ); + } + + private fail(attempt: Attempt, id: string, code: string, message: string): void { + this.send(attempt, { id, error: { code: RPC_APPLICATION_ERROR, message, data: { code } } }); + } +} diff --git a/extension/src/offscreen/crypto.test.ts b/extension/src/offscreen/crypto.test.ts new file mode 100644 index 0000000..cff3f63 --- /dev/null +++ b/extension/src/offscreen/crypto.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { daemonMac, decryptSessionKey, derivePairingKeys, extensionMac, hexToBytes } from "./crypto"; + +// 已知答案由 daemon 的 Go 实现(internal/daemon/auth.Crypto)对同一组输入算出, +// 钉住浏览器实例的 MAC 构成:browser* 上下文 || instanceId || 两个 nonce,以及配对密钥派生与下发。 +const KEY = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); +const INSTANCE_ID = "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b"; +const NONCE_D = "aa00112233445566778899aabbccddeeff00112233445566778899aabbccddee"; +const NONCE_E = "bbffeeddccbbaa99887766554433221100ffeeddccbbaa998877665544332211"; + +describe("browser handshake crypto", () => { + it("computes the session MACs the daemon expects for this instance", async () => { + const session = { mode: "session" as const, instanceId: INSTANCE_ID, key: KEY }; + expect(await extensionMac(session, NONCE_D, NONCE_E)).toBe( + "2e345f21f5791d0b707a6f0eae6d02ac2de953de8d45dc8e0c97dd8bcf302c99", + ); + expect(await daemonMac(session, NONCE_D, NONCE_E)).toBe( + "ac72627a58d9329f5f77346c22e63049563d767cd690a3cfa6c9b6314d768bb9", + ); + }); + + it("derives pairing keys from a hand-typed code and computes the pairing MACs", async () => { + const { mac } = await derivePairingKeys("7K3M9QPX"); + const pairing = { mode: "pairing" as const, instanceId: INSTANCE_ID, key: mac }; + expect(await extensionMac(pairing, NONCE_D, NONCE_E)).toBe( + "b220d935a23f96696a8c3b1d934e9287de32b0eb4d9a5a5bbf19e755fea6a940", + ); + expect(await daemonMac(pairing, NONCE_D, NONCE_E)).toBe( + "ea4c7cd883c94406cc8952702e32804fc7099e7f6841e19cbd2762ff6d0e268e", + ); + }); + + it("decrypts the session key the daemon delivers with the pairing encryption key", async () => { + const { enc } = await derivePairingKeys("7K3M9QPX"); + const key = await decryptSessionKey(enc, { + ciphertext: "C12Ve2AhzpeCAEKbKBa5vjkfIcZ0dOq9P3CkpYPZ/r3FnApc9KfpT7OP+Y4GoqQ5", + iv: "ni82RKZHtuASe7ar", + }); + expect(key).toBe("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + }); + + it("fails to decrypt a delivery sealed with a different pairing code", async () => { + const { enc } = await derivePairingKeys("7K3M9QPY"); + await expect( + decryptSessionKey(enc, { + ciphertext: "C12Ve2AhzpeCAEKbKBa5vjkfIcZ0dOq9P3CkpYPZ/r3FnApc9KfpT7OP+Y4GoqQ5", + iv: "ni82RKZHtuASe7ar", + }), + ).rejects.toThrow(); + }); +}); diff --git a/extension/src/offscreen/crypto.ts b/extension/src/offscreen/crypto.ts new file mode 100644 index 0000000..eee9e72 --- /dev/null +++ b/extension/src/offscreen/crypto.ts @@ -0,0 +1,93 @@ +import { CRYPTO } from "@/protocol/generated/protocol.generated"; + +// WebCrypto 的 BufferSource 要求 ArrayBuffer 后备,所有原语统一使用这个别名。 +export type Bytes = Uint8Array; + +// 一次握手使用的 MAC 素材:会话模式用长期密钥 K,配对模式用配对码派生的 MAC 密钥。 +export interface MacMaterial { + mode: "session" | "pairing"; + instanceId: string; + key: Bytes; +} + +export interface KeyDelivery { + ciphertext: string; + iv: string; +} + +const encoder = new TextEncoder(); + +function utf8(text: string): Bytes { + return new Uint8Array(encoder.encode(text)); +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function hexToBytes(hex: string): Bytes { + const out = new Uint8Array(hex.length >> 1); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function base64ToBytes(b64: string): Bytes { + const bin = atob(b64); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +export function randomNonceHex(): string { + return bytesToHex(crypto.getRandomValues(new Uint8Array(CRYPTO.nonceBytes))); +} + +// 比较两个十六进制 MAC,耗时与内容无关。 +export function constantTimeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +// HMAC-SHA-256(key, utf8(context || instanceId || first || second)),与 daemon 的 BrowserExtHMAC/BrowserDaemonHMAC 一致; +// browser* 上下文区分对端类型,instanceId 让截获的 MAC 无法冒充另一个实例。 +async function handshakeMac(material: MacMaterial, context: string, first: string, second: string): Promise { + const key = await crypto.subtle.importKey("raw", material.key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const sig = await crypto.subtle.sign("HMAC", key, utf8(context + material.instanceId + first + second)); + return bytesToHex(new Uint8Array(sig)); +} + +export function extensionMac(material: MacMaterial, nonceD: string, nonceE: string): Promise { + const context = material.mode === "session" ? CRYPTO.context.browserSessionExt : CRYPTO.context.browserPairExt; + return handshakeMac(material, context, nonceD, nonceE); +} + +export function daemonMac(material: MacMaterial, nonceD: string, nonceE: string): Promise { + const context = material.mode === "session" ? CRYPTO.context.browserSessionDaemon : CRYPTO.context.browserPairDaemon; + return handshakeMac(material, context, nonceE, nonceD); +} + +// 从已归一化的配对码经 HKDF-SHA-256 派生 MAC 与 AES-GCM 两把 256 位临时密钥,参数与 ScriptCat 配对相同。 +export async function derivePairingKeys(code: string): Promise<{ mac: Bytes; enc: Bytes }> { + const ikm = await crypto.subtle.importKey("raw", utf8(code), "HKDF", false, ["deriveBits"]); + const salt = utf8(CRYPTO.context.pairKdfSalt); + const derive = async (info: string): Promise => + new Uint8Array(await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: utf8(info) }, ikm, 256)); + return { mac: await derive(CRYPTO.context.pairKdfInfoMac), enc: await derive(CRYPTO.context.pairKdfInfoEnc) }; +} + +// 解开 $session.authenticated 下发的长期密钥(AES-256-GCM,密文与 tag 拼接),返回小写 hex。 +export async function decryptSessionKey(enc: Bytes, delivery: KeyDelivery): Promise { + const key = await crypto.subtle.importKey("raw", enc, { name: "AES-GCM" }, false, ["decrypt"]); + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(delivery.iv) }, + key, + base64ToBytes(delivery.ciphertext), + ); + return bytesToHex(new Uint8Array(plain)); +} diff --git a/extension/src/offscreen/index.html b/extension/src/offscreen/index.html new file mode 100644 index 0000000..b4c13d3 --- /dev/null +++ b/extension/src/offscreen/index.html @@ -0,0 +1,10 @@ + + + + + sctl Browser + + + + + diff --git a/extension/src/offscreen/main.ts b/extension/src/offscreen/main.ts new file mode 100644 index 0000000..52ff068 --- /dev/null +++ b/extension/src/offscreen/main.ts @@ -0,0 +1,51 @@ +import type { ConnectionConfig, OffscreenCommand, OffscreenEvent, RpcOutcome, StateBroadcast } from "@/shared/messages"; +import { broadcast, listen, request } from "@/shared/messaging"; +import { Connection } from "./connection"; + +const toBackground = (event: OffscreenEvent) => request(chrome.runtime, event); + +const connection = new Connection({ + createSocket(url, events) { + const socket = new WebSocket(url); + socket.onopen = () => events.open(); + // daemon 只发文本帧;其他类型交给解码器当作畸形帧处理。 + socket.onmessage = (event: MessageEvent) => events.message(typeof event.data === "string" ? event.data : ""); + socket.onclose = (event) => events.close(event.code); + return socket; + }, + timers: { + setTimeout: (fn, ms) => window.setTimeout(fn, ms), + clearTimeout: (id) => window.clearTimeout(id), + now: () => Date.now(), + }, + onState: (state) => broadcast(chrome.runtime, { target: "popup", type: "state", state } satisfies StateBroadcast), + persistKey: (key, name) => toBackground({ target: "background", type: "paired", key, name }), + persistName: (name) => toBackground({ target: "background", type: "renamed", name }), + dispatch: (method, input) => toBackground({ target: "background", type: "rpc", method, input }), +}); + +// offscreen 文档不能访问扩展存储,配置由 service worker 读出后交给它。 +const started = toBackground({ target: "background", type: "offscreenReady" }).then((config) => + connection.start(config), +); +// 启动失败时之后的每条命令也会以同一错误失败,这里先记录一次原因。 +started.catch((error: unknown) => console.error("failed to start the connection", error)); + +// 命令可能在配置到达之前就来了,等连接启动后再处理。 +listen(chrome.runtime, "offscreen", async (command) => { + await started; + switch (command.type) { + case "getState": + return connection.getState(); + case "pair": + return connection.pair(command.code); + case "rename": + return connection.rename(command.name); + case "retryNow": + return connection.retryNow(); + case "forget": + return connection.forget(); + case "setAddress": + return connection.setAddress(command.address); + } +}); diff --git a/extension/src/offscreen/wire.ts b/extension/src/offscreen/wire.ts new file mode 100644 index 0000000..198a73e --- /dev/null +++ b/extension/src/offscreen/wire.ts @@ -0,0 +1,63 @@ +// daemon 发来的帧是不可信输入:先校验 JSON-RPC 2.0 形状(docs/protocol.md §1),再交给连接状态机。 +export interface JsonRpcMessage { + jsonrpc: "2.0"; + id?: string; + method?: string; + params?: Record; + result?: Record; + error?: { code: number; message: string; data?: { code?: string } }; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertOnlyFields(value: Record, allowed: readonly string[]): void { + const unexpected = Object.keys(value).find((field) => !allowed.includes(field)); + if (unexpected) { + throw new Error(`unexpected JSON-RPC field ${unexpected}`); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +export function decodeFrame(frame: string): JsonRpcMessage { + const value: unknown = JSON.parse(frame); + if (!isRecord(value) || value.jsonrpc !== "2.0") { + throw new Error("not a JSON-RPC 2.0 message"); + } + if (value.method !== undefined) { + if (!isNonEmptyString(value.method) || (value.id !== undefined && !isNonEmptyString(value.id))) { + throw new Error("invalid JSON-RPC request"); + } + if (value.params !== undefined && !isRecord(value.params)) { + throw new Error("invalid JSON-RPC params"); + } + assertOnlyFields(value, ["jsonrpc", "id", "method", "params"]); + return value as unknown as JsonRpcMessage; + } + const hasResult = value.result !== undefined; + const hasError = value.error !== undefined; + if (!isNonEmptyString(value.id) || hasResult === hasError) { + throw new Error("invalid JSON-RPC response"); + } + if (hasResult && !isRecord(value.result)) { + throw new Error("invalid JSON-RPC result"); + } + if (hasError) { + const error = value.error; + if (!isRecord(error) || !Number.isInteger(error.code) || typeof error.message !== "string") { + throw new Error("invalid JSON-RPC error"); + } + if ( + error.data !== undefined && + !(isRecord(error.data) && (error.data.code === undefined || typeof error.data.code === "string")) + ) { + throw new Error("invalid JSON-RPC error data"); + } + } + assertOnlyFields(value, ["jsonrpc", "id", "result", "error"]); + return value as unknown as JsonRpcMessage; +} diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html new file mode 100644 index 0000000..3300cdf --- /dev/null +++ b/extension/src/popup/index.html @@ -0,0 +1,12 @@ + + + + + + sctl Browser + + +
+ + + diff --git a/extension/src/popup/main.tsx b/extension/src/popup/main.tsx new file mode 100644 index 0000000..8618737 --- /dev/null +++ b/extension/src/popup/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@/styles/globals.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("popup root element is missing"); +} + +createRoot(root).render( + +
sctl Browser
+
, +); diff --git a/extension/src/shared/address.ts b/extension/src/shared/address.ts new file mode 100644 index 0000000..22e6ca4 --- /dev/null +++ b/extension/src/shared/address.ts @@ -0,0 +1,22 @@ +import { TRANSPORT } from "@/protocol/generated/protocol.generated"; + +// daemon 默认监听地址取自协议常量,与 sctl serve 的默认 --listen-address 一致。 +export const DEFAULT_ADDRESS = new URL(TRANSPORT.defaultUrl).host; + +// 地址必须是 host:port(IPv6 带方括号),与 sctl serve --listen-address 的写法相同;不接受协议头、路径或缺省端口。 +const ADDRESS_PATTERN = /^(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):(\d{1,5})$/; +const MAX_PORT = 65535; + +export function parseAddress(input: string): string | null { + const trimmed = input.trim(); + const match = ADDRESS_PATTERN.exec(trimmed); + if (!match) { + return null; + } + const port = Number(match[2]); + return port >= 1 && port <= MAX_PORT ? trimmed : null; +} + +export function websocketUrl(address: string): string { + return `ws://${address}/`; +} diff --git a/extension/src/shared/identity.test.ts b/extension/src/shared/identity.test.ts new file mode 100644 index 0000000..a506fe7 --- /dev/null +++ b/extension/src/shared/identity.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { detectProduct, defaultName, isValidName, newInstanceId } from "./identity"; +import { DEFAULT_ADDRESS, parseAddress } from "./address"; +import { normalizePairingCode } from "./pairing-code"; + +describe("instance identity", () => { + it("generates a fresh 32-digit lowercase hex instance ID each time", () => { + const first = newInstanceId(); + expect(first).toMatch(/^[0-9a-f]{32}$/); + expect(newInstanceId()).not.toBe(first); + }); + + it("names an instance by its browser brand and the first four hex digits of its ID", () => { + const id = "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b"; + const edge = detectProduct([ + { brand: "Not.A/Brand", version: "99" }, + { brand: "Microsoft Edge", version: "129" }, + ]); + const chrome = detectProduct([ + { brand: "Chromium", version: "129" }, + { brand: "Google Chrome", version: "129" }, + ]); + expect(defaultName(edge, id)).toBe("edge-3f2a"); + expect(defaultName(chrome, id)).toBe("chrome-3f2a"); + expect(chrome.product).toBe("Chrome"); + }); + + it("falls back to chromium when the browser reports no known brand", () => { + expect(defaultName(detectProduct(undefined), "91c0aaaaaaaaaaaaaaaaaaaaaaaaaaaa")).toBe("chromium-91c0"); + }); + + it("accepts only 1-32 lowercase letters, digits and hyphens as a name", () => { + expect(isValidName("chrome-3f2a")).toBe(true); + expect(isValidName("a".repeat(32))).toBe(true); + expect(isValidName("a".repeat(33))).toBe(false); + expect(isValidName("")).toBe(false); + expect(isValidName("Chrome")).toBe(false); + expect(isValidName("work laptop")).toBe(false); + }); +}); + +describe("daemon address", () => { + it("defaults to the daemon's default listen address", () => { + expect(DEFAULT_ADDRESS).toBe("127.0.0.1:8643"); + }); + + it("accepts host:port forms and trims surrounding space", () => { + expect(parseAddress(" 127.0.0.1:9000 ")).toBe("127.0.0.1:9000"); + expect(parseAddress("localhost:8643")).toBe("localhost:8643"); + expect(parseAddress("[::1]:8643")).toBe("[::1]:8643"); + }); + + it("rejects an address without a port, with a scheme or path, or with an out-of-range port", () => { + expect(parseAddress("127.0.0.1")).toBeNull(); + expect(parseAddress("ws://127.0.0.1:8643")).toBeNull(); + expect(parseAddress("127.0.0.1:8643/x")).toBeNull(); + expect(parseAddress("127.0.0.1:65536")).toBeNull(); + expect(parseAddress("")).toBeNull(); + }); +}); + +describe("pairing code", () => { + it("normalizes the displayed form and ambiguous characters the way the daemon does", () => { + expect(normalizePairingCode("7k3m-9qpx")).toBe("7K3M9QPX"); + expect(normalizePairingCode(" O1IL-abcd ")).toBe("0111ABCD"); + }); + + it("rejects input that cannot be an 8-character Crockford code", () => { + expect(normalizePairingCode("7K3M-9QP")).toBeNull(); + expect(normalizePairingCode("7K3M-9QPU")).toBeNull(); + }); +}); diff --git a/extension/src/shared/identity.ts b/extension/src/shared/identity.ts new file mode 100644 index 0000000..99eb34f --- /dev/null +++ b/extension/src/shared/identity.ts @@ -0,0 +1,42 @@ +// 实例 ID 与名称的格式由 daemon 在握手边界校验(docs/protocol.md §2.1、§2.2),这里与之保持一致。 +const NAME_PATTERN = /^[a-z0-9-]{1,32}$/; +const INSTANCE_ID_BYTES = 16; + +export interface BrandInfo { + brand: string; + version: string; +} + +// brand 是 userAgentData 里的品牌名(用于取版本号),slug 用于默认名称,product 作为自报的产品名发给 daemon。 +export interface ProductInfo { + brand: string; + slug: string; + product: string; +} + +// 按优先级匹配:Edge、Opera 等的品牌列表里同时带有 Chromium,必须先认出具体品牌。 +const KNOWN_PRODUCTS: readonly ProductInfo[] = [ + { brand: "Microsoft Edge", slug: "edge", product: "Edge" }, + { brand: "Opera", slug: "opera", product: "Opera" }, + { brand: "Brave", slug: "brave", product: "Brave" }, + { brand: "Google Chrome", slug: "chrome", product: "Chrome" }, +]; + +const CHROMIUM: ProductInfo = { brand: "Chromium", slug: "chromium", product: "Chromium" }; + +export function newInstanceId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(INSTANCE_ID_BYTES)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function detectProduct(brands: readonly BrandInfo[] | undefined): ProductInfo { + return KNOWN_PRODUCTS.find((known) => brands?.some((b) => b.brand === known.brand)) ?? CHROMIUM; +} + +export function defaultName(product: ProductInfo, instanceId: string): string { + return `${product.slug}-${instanceId.slice(0, 4)}`; +} + +export function isValidName(name: string): boolean { + return NAME_PATTERN.test(name); +} diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts new file mode 100644 index 0000000..d2130d1 --- /dev/null +++ b/extension/src/shared/messages.ts @@ -0,0 +1,55 @@ +import type { ERROR_CODES, RpcMethod } from "@/protocol/generated/protocol.generated"; +import type { ConnectionState } from "./state"; + +export type ErrorCode = (typeof ERROR_CODES)[number]; + +// 业务方法处理结果;失败码必须是协议登记的领域错误码,由 offscreen 转成 JSON-RPC -32000 错误。 +export type RpcOutcome = { ok: true; result: unknown } | { ok: false; code: ErrorCode; message: string }; + +export type PairResult = { ok: true } | { ok: false; error: "invalid-code" }; +export type RenameResult = { ok: true } | { ok: false; error: "invalid-name" | "not-connected" | "name-taken" }; +export type SetAddressResult = { ok: true } | { ok: false; error: "invalid-address" }; + +// offscreen 启动连接所需的全部配置;只有 service worker 能读写扩展存储,由它组装后交给 offscreen。 +export interface ConnectionConfig { + instanceId: string; + name: string; + address: string; + // 长期会话密钥(小写 hex),未配对时为 null。 + key: string | null; + methods: RpcMethod[]; + product: string; + productVersion: string; + extensionVersion: string; +} + +// 同一条 chrome.runtime 消息会送达除发送者外的所有扩展页面,target 决定由谁处理。 +export type PopupRequest = + | { target: "background"; type: "getState" } + | { target: "background"; type: "pair"; code: string } + | { target: "background"; type: "rename"; name: string } + | { target: "background"; type: "retryNow" } + | { target: "background"; type: "forget" } + | { target: "background"; type: "setAddress"; address: string }; + +export type OffscreenEvent = + | { target: "background"; type: "offscreenReady" } + | { target: "background"; type: "paired"; key: string; name: string } + | { target: "background"; type: "renamed"; name: string } + | { target: "background"; type: "rpc"; method: RpcMethod; input: unknown }; + +export type BackgroundMessage = PopupRequest | OffscreenEvent; + +export type OffscreenCommand = + | { target: "offscreen"; type: "getState" } + | { target: "offscreen"; type: "pair"; code: string } + | { target: "offscreen"; type: "rename"; name: string } + | { target: "offscreen"; type: "retryNow" } + | { target: "offscreen"; type: "forget" } + | { target: "offscreen"; type: "setAddress"; address: string }; + +export interface StateBroadcast { + target: "popup"; + type: "state"; + state: ConnectionState; +} diff --git a/extension/src/shared/messaging.ts b/extension/src/shared/messaging.ts new file mode 100644 index 0000000..2823449 --- /dev/null +++ b/extension/src/shared/messaging.ts @@ -0,0 +1,56 @@ +// chrome.runtime 消息的请求/应答约定:应答包成信封,处理方的失败会在调用方变成被拒绝的 Promise, +// 而不是一个看似成功的 undefined。 +type Reply = { ok: true; value: unknown } | { ok: false; error: string }; + +type MessageListener = (message: unknown, sender: unknown, sendResponse: (reply: unknown) => void) => boolean | void; + +export interface RuntimeLike { + sendMessage(message: unknown): Promise; + onMessage: { + addListener(listener: MessageListener): void; + removeListener(listener: MessageListener): void; + }; +} + +export type Target = "background" | "offscreen" | "popup"; + +function isTargeted(message: unknown, target: Target): message is { target: Target; type: string } { + return typeof message === "object" && message !== null && (message as { target?: unknown }).target === target; +} + +// 只处理发给 target 的消息;同一条消息会送达每个扩展页面,其余页面必须不作应答。 +export function listen( + runtime: RuntimeLike, + target: M["target"], + handler: (message: M) => Promise, +): void { + runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!isTargeted(message, target)) { + return false; + } + handler(message as M).then( + (value) => sendResponse({ ok: true, value } satisfies Reply), + (error: unknown) => { + console.error(`failed to handle ${message.type}`, error); + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) } satisfies Reply); + }, + ); + return true; + }); +} + +// 状态广播不等待应答:弹窗通常没有打开,此时 Chrome 以"没有接收方"拒绝这条消息,这正是预期情况。 +export function broadcast(runtime: RuntimeLike, message: M): void { + runtime.sendMessage(message).catch(() => undefined); +} + +export async function request(runtime: RuntimeLike, message: { target: Target; type: string }): Promise { + const reply = (await runtime.sendMessage(message)) as Reply | undefined; + if (!reply) { + throw new Error(`no ${message.target} context answered ${message.type}`); + } + if (!reply.ok) { + throw new Error(reply.error); + } + return reply.value as R; +} diff --git a/extension/src/shared/pairing-code.ts b/extension/src/shared/pairing-code.ts new file mode 100644 index 0000000..9d4241d --- /dev/null +++ b/extension/src/shared/pairing-code.ts @@ -0,0 +1,10 @@ +import { PAIRING_CODE } from "@/protocol/generated/protocol.generated"; + +const CROCKFORD = /^[0-9A-HJKMNP-TV-Z]+$/; + +// 与 daemon 的 NormalizePairingCode 相同:去掉分隔符、转大写,按 Crockford 规则把 O 视为 0、I/L 视为 1, +// 使用户手输的易混字符与 daemon 生成的规范码派生出同一把密钥。无法构成合法配对码时返回 null。 +export function normalizePairingCode(input: string): string | null { + const code = input.replace(/[-\s]/g, "").toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"); + return code.length === PAIRING_CODE.length && CROCKFORD.test(code) ? code : null; +} diff --git a/extension/src/shared/popup-api.test.ts b/extension/src/shared/popup-api.test.ts new file mode 100644 index 0000000..282c9e8 --- /dev/null +++ b/extension/src/shared/popup-api.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { listen, type RuntimeLike } from "./messaging"; +import { createPopupApi } from "./popup-api"; +import type { ConnectionState } from "./state"; + +type Listener = Parameters[0]; + +// 模拟 chrome.runtime:消息送达除发送者之外的所有监听者,第一个 sendResponse 的回复即结果。 +class FakeRuntime { + private readonly listeners = new Set<{ owner: string; fn: Listener }>(); + + as(owner: string): RuntimeLike { + return { + sendMessage: (message: unknown) => + new Promise((resolve) => { + let answered = false; + for (const { owner: other, fn } of this.listeners) { + if (other === owner) { + continue; + } + fn(message, {}, (reply: unknown) => { + if (!answered) { + answered = true; + resolve(reply); + } + }); + } + setTimeout(() => { + if (!answered) { + resolve(undefined); + } + }, 5); + }), + onMessage: { + addListener: (fn: Listener) => this.listeners.add({ owner, fn }), + removeListener: (fn: Listener) => { + for (const entry of this.listeners) { + if (entry.fn === fn) { + this.listeners.delete(entry); + } + } + }, + }, + }; + } + + count(): number { + return this.listeners.size; + } +} + +const STATE: ConnectionState = { + status: "unpaired", + instanceId: "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b", + name: "chrome-3f2a", + address: "127.0.0.1:8643", +}; + +describe("popup message API", () => { + it("asks the background for the state and for commands, ignoring other contexts' traffic", async () => { + const runtime = new FakeRuntime(); + const received: unknown[] = []; + listen(runtime.as("background"), "background", (message) => { + received.push(message); + return Promise.resolve(message.type === "getState" ? STATE : { ok: true }); + }); + listen(runtime.as("offscreen"), "offscreen", () => Promise.reject(new Error("must not be asked"))); + const api = createPopupApi(runtime.as("popup")); + + await expect(api.getState()).resolves.toEqual(STATE); + await expect(api.pair("7K3M-9QPX")).resolves.toEqual({ ok: true }); + await expect(api.rename("work")).resolves.toEqual({ ok: true }); + await expect(api.setAddress("127.0.0.1:9000")).resolves.toEqual({ ok: true }); + await api.retryNow(); + await api.forget(); + expect(received).toEqual([ + { target: "background", type: "getState" }, + { target: "background", type: "pair", code: "7K3M-9QPX" }, + { target: "background", type: "rename", name: "work" }, + { target: "background", type: "setAddress", address: "127.0.0.1:9000" }, + { target: "background", type: "retryNow" }, + { target: "background", type: "forget" }, + ]); + }); + + it("rejects when the background fails the request", async () => { + const runtime = new FakeRuntime(); + listen(runtime.as("background"), "background", () => Promise.reject(new Error("storage unavailable"))); + + await expect(createPopupApi(runtime.as("popup")).getState()).rejects.toThrow("storage unavailable"); + }); + + it("delivers state broadcasts to subscribers until they unsubscribe", async () => { + const runtime = new FakeRuntime(); + const api = createPopupApi(runtime.as("popup")); + const seen: ConnectionState[] = []; + const unsubscribe = api.subscribe((state) => seen.push(state)); + const offscreen = runtime.as("offscreen"); + + await offscreen.sendMessage({ target: "popup", type: "state", state: STATE }); + await offscreen.sendMessage({ target: "background", type: "renamed", name: "x" }); + unsubscribe(); + await offscreen.sendMessage({ target: "popup", type: "state", state: STATE }); + + expect(seen).toEqual([STATE]); + expect(runtime.count()).toBe(0); + }); +}); diff --git a/extension/src/shared/popup-api.ts b/extension/src/shared/popup-api.ts new file mode 100644 index 0000000..cabb163 --- /dev/null +++ b/extension/src/shared/popup-api.ts @@ -0,0 +1,41 @@ +import type { PairResult, PopupRequest, RenameResult, SetAddressResult, StateBroadcast } from "./messages"; +import { type RuntimeLike, request } from "./messaging"; +import type { ConnectionState } from "./state"; + +// 弹窗与连接之间的全部接口:命令发给 service worker,状态变化由 offscreen 广播推送。 +export interface PopupApi { + getState(): Promise; + subscribe(listener: (state: ConnectionState) => void): () => void; + pair(code: string): Promise; + rename(name: string): Promise; + retryNow(): Promise; + forget(): Promise; + setAddress(address: string): Promise; +} + +function isStateBroadcast(message: unknown): message is StateBroadcast { + const m = message as Partial | null; + return typeof m === "object" && m !== null && m.target === "popup" && m.type === "state"; +} + +export function createPopupApi(runtime: RuntimeLike = chrome.runtime): PopupApi { + const send = (message: PopupRequest) => request(runtime, message); + return { + getState: () => send({ target: "background", type: "getState" }), + subscribe(listener) { + const onMessage = (message: unknown) => { + if (isStateBroadcast(message)) { + listener(message.state); + } + return false; + }; + runtime.onMessage.addListener(onMessage); + return () => runtime.onMessage.removeListener(onMessage); + }, + pair: (code) => send({ target: "background", type: "pair", code }), + rename: (name) => send({ target: "background", type: "rename", name }), + retryNow: () => send({ target: "background", type: "retryNow" }), + forget: () => send({ target: "background", type: "forget" }), + setAddress: (address) => send({ target: "background", type: "setAddress", address }), + }; +} diff --git a/extension/src/shared/state.ts b/extension/src/shared/state.ts new file mode 100644 index 0000000..b0e5838 --- /dev/null +++ b/extension/src/shared/state.ts @@ -0,0 +1,26 @@ +// 连接状态由 offscreen 文档里的连接持有者产生,经 service worker 转交弹窗渲染。 + +// 每个状态都带上实例身份与 daemon 地址,弹窗在任何状态下都能显示它们。 +export interface InstanceSummary { + instanceId: string; + name: string; + address: string; +} + +// code-rejected:配对码错误或已过期(daemon 不回显握手失败原因,两者无法区分); +// name-taken:首次配对时默认名称已被另一个已配对实例占用,这次配对不会被 daemon 保存。 +export type PairFailure = "code-rejected" | "name-taken"; + +export type StatusDetail = + | { status: "unpaired" } + | { status: "pairing" } + | { status: "pair-failed"; reason: PairFailure } + | { status: "pair-unreachable" } + | { status: "connected"; daemonVersion: string } + // attempt 是连续失败的次数(0 表示首次连接);retryAt 是下一次重试的时间戳(毫秒),正在尝试时为 null。 + | { status: "reconnecting"; attempt: number; retryAt: number | null } + | { status: "rejected" }; + +export type ConnectionState = InstanceSummary & StatusDetail; + +export type ConnectionStatus = ConnectionState["status"]; diff --git a/extension/src/styles/globals.css b/extension/src/styles/globals.css new file mode 100644 index 0000000..40d961a --- /dev/null +++ b/extension/src/styles/globals.css @@ -0,0 +1,89 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + /* 只用系统字体,不打包字体文件。 */ + --font-sans: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --font-heading: var(--font-sans); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + html { + @apply font-sans; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..86a5d53 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["chrome", "vite/client"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/extension/tsconfig.node.json b/extension/tsconfig.node.json new file mode 100644 index 0000000..9921ddf --- /dev/null +++ b/extension/tsconfig.node.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + // 被 tsconfig.json 引用的工程不能关闭输出,声明文件写进 node_modules/.tmp,不进仓库。 + "composite": true, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.tmp/tsconfig.node", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/extension/vite.config.ts b/extension/vite.config.ts new file mode 100644 index 0000000..742b131 --- /dev/null +++ b/extension/vite.config.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; + +const src = resolve(import.meta.dirname, "src"); + +// Chrome 的 manifest version 只接受 1–4 段纯数字,预发布后缀(如 0.2.0-rc.1)只能放进 version_name。 +function manifestVersion(release: string): { version: string; version_name?: string } { + const match = /^(\d+\.\d+\.\d+)(-[0-9A-Za-z.-]+)?$/.exec(release); + if (!match) { + throw new Error(`SCTL_EXTENSION_VERSION must look like 1.2.3 or 1.2.3-rc.1, got ${release}`); + } + return match[2] ? { version: match[1], version_name: release } : { version: match[1] }; +} + +// 版本号与 sctl 发布版本一致:发布流程通过 SCTL_EXTENSION_VERSION 传入 tag 版本,本地构建用 package.json 的版本。 +function manifest(): Plugin { + return { + name: "sctl-manifest", + generateBundle() { + const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname, "package.json"), "utf8")) as { + version: string; + }; + const source = JSON.parse(readFileSync(resolve(src, "manifest.json"), "utf8")) as Record; + const release = process.env.SCTL_EXTENSION_VERSION || pkg.version; + this.emitFile({ + type: "asset", + fileName: "manifest.json", + source: `${JSON.stringify({ ...source, ...manifestVersion(release) }, null, 2)}\n`, + }); + }, + }; +} + +export default defineConfig({ + root: src, + publicDir: resolve(import.meta.dirname, "public"), + base: "/", + plugins: [react(), tailwindcss(), manifest()], + resolve: { alias: { "@": src } }, + build: { + outDir: resolve(import.meta.dirname, "dist"), + emptyOutDir: true, + // service worker 没有 document,Vite 的 modulepreload polyfill 会在其中报错。 + modulePreload: { polyfill: false }, + rollupOptions: { + input: { + background: resolve(src, "background/index.ts"), + popup: resolve(src, "popup/index.html"), + offscreen: resolve(src, "offscreen/index.html"), + }, + output: { + // manifest 按固定路径引用 service worker,其余入口保留内容哈希。 + entryFileNames: (chunk) => (chunk.name === "background" ? "background.js" : "assets/[name]-[hash].js"), + }, + }, + }, +}); diff --git a/extension/vitest.config.ts b/extension/vitest.config.ts new file mode 100644 index 0000000..105782a --- /dev/null +++ b/extension/vitest.config.ts @@ -0,0 +1,10 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { alias: { "@": resolve(import.meta.dirname, "src") } }, + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); From c7cf7d7f2845620ae78b14463194bf6c3ca7d452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:48:04 +0800 Subject: [PATCH 08/25] feat: expose browser tools over MCP --- internal/client/mcpserver/mcpserver.go | 104 ++++++++++++++++++-- internal/client/mcpserver/mcpserver_test.go | 50 ++++++++-- internal/client/mcpserver/tools.go | 61 ++++++++++++ 3 files changed, 199 insertions(+), 16 deletions(-) diff --git a/internal/client/mcpserver/mcpserver.go b/internal/client/mcpserver/mcpserver.go index 8cf2a87..bc421d9 100644 --- a/internal/client/mcpserver/mcpserver.go +++ b/internal/client/mcpserver/mcpserver.go @@ -29,9 +29,11 @@ const maxFullSourceResponseBytes = 64 * 1024 // 超时的值,使每次通知都能刷新其倒计时。以 var 暴露仅为便于测试压缩等待。 var progressInterval = 10 * time.Second -// BridgeCaller 抽象「向 daemon 转发一次 bridge action 调用」,便于测试注入桩。 +// BridgeCaller 抽象「向 daemon 转发 bridge action 调用与查询浏览器列表」,便于测试注入桩。 type BridgeCaller interface { Call(ctx context.Context, action string, input json.RawMessage) (control.CallResult, error) + CallBrowser(ctx context.Context, action, browser string, input json.RawMessage) (control.CallResult, error) + Browsers(ctx context.Context) ([]control.BrowserInfo, error) } // Deps 是构建 MCP server 所需依赖。扁平信任下不再按客户端 scope 过滤:注册 protocol.json 中 @@ -43,7 +45,7 @@ type Deps struct { Caller BridgeCaller } -// New 按依赖构建 MCP server,注册 protocol.json 里定义的全部 bridge action 工具。 +// New 按依赖构建 MCP server,注册 protocol.json 里定义的全部 bridge action 工具以及 browsers_list。 func New(d Deps) *mcp.Server { srv := mcp.NewServer(&mcp.Implementation{Name: d.Name, Version: d.Version}, &mcp.ServerOptions{}) for _, td := range toolDefs { @@ -52,9 +54,24 @@ func New(d Deps) *mcp.Server { } registerTool(srv, td, d.Caller) } + // browsers_list 特殊处理:不是 bridge action,由控制 API 直接提供已配对实例列表。 + registerBrowsersListTool(srv, d.Caller) return srv } +// isBrowserAction 判断某个 action 是否是浏览器方法。 +func isBrowserAction(action string) bool { + return strings.HasPrefix(action, "tabs.") || strings.HasPrefix(action, "windows.") +} + +// extractBrowserParam 从工具参数中抽取可选 browser 参数并返回。若无 browser 参数返回空串。 +// 修改 input 使其不含 browser 参数。 +func extractBrowserParam(input map[string]any) string { + browser, _ := input["browser"].(string) + delete(input, "browser") + return browser +} + func registerTool(srv *mcp.Server, td toolDef, caller BridgeCaller) { schemaDoc, err := jsonschema.UnmarshalJSON(strings.NewReader(td.inputSchema)) if err != nil { @@ -82,6 +99,22 @@ func registerTool(srv *mcp.Server, td toolDef, caller BridgeCaller) { if err := schema.Validate(input); err != nil { return nil, fmt.Errorf("invalid %s arguments: %w", td.name, err) } + // 浏览器方法支持可选的 browser 参数:抽取并从 input 中删除。 + var browser string + if isBrowserAction(action) { + inputMap, ok := input.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid %s arguments: expected object", td.name) + } + browser = extractBrowserParam(inputMap) + // 参数已修改,重新编码为 JSON。 + var inputBytes []byte + inputBytes, err = json.Marshal(inputMap) + if err != nil { + return nil, fmt.Errorf("encode %s arguments: %w", td.name, err) + } + req.Params.Arguments = inputBytes + } if action == "scripts.source.get" { var args map[string]any if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { @@ -95,16 +128,73 @@ func registerTool(srv *mcp.Server, td toolDef, caller BridgeCaller) { } } } - return handleCall(ctx, req, action, caller) + return handleCall(ctx, req, action, browser, caller) + }) +} + +// registerBrowsersListTool 注册 browsers_list 工具,它不是 bridge action,由控制 API 提供。 +func registerBrowsersListTool(srv *mcp.Server, caller BridgeCaller) { + const ( + toolName = "browsers_list" + toolDescription = "List all paired browser instances: name, instance ID, online/offline status, brand and version, extension version, and connection time." + inputSchema = `{"type":"object","properties":{},"additionalProperties":false}` + ) + schemaDoc, err := jsonschema.UnmarshalJSON(strings.NewReader(inputSchema)) + if err != nil { + panic(fmt.Sprintf("invalid input schema for %s: %v", toolName, err)) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource(toolName+".json", schemaDoc); err != nil { + panic(fmt.Sprintf("load input schema for %s: %v", toolName, err)) + } + schema, err := compiler.Compile(toolName + ".json") + if err != nil { + panic(fmt.Sprintf("compile input schema for %s: %v", toolName, err)) + } + tool := &mcp.Tool{ + Name: toolName, + Description: toolDescription, + InputSchema: json.RawMessage(inputSchema), + } + srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := jsonschema.UnmarshalJSON(bytes.NewReader(req.Params.Arguments)) + if err != nil { + return nil, fmt.Errorf("invalid %s arguments: %w", toolName, err) + } + if err := schema.Validate(input); err != nil { + return nil, fmt.Errorf("invalid %s arguments: %w", toolName, err) + } + stop := startProgress(ctx, req) + browsers, err := caller.Browsers(ctx) + stop() + if err != nil { + return nil, err + } + // 将浏览器列表转换为 JSON 结果格式。 + result := map[string]any{ + "browsers": browsers, + } + resultJSON, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("encode %s result: %w", toolName, err) + } + return okResult(resultJSON), nil }) } -// handleCall 转发工具调用到 daemon,并在等待期间发 progress。桥接业务错误(拒绝/过期/scope 等) -// 作为 IsError 工具结果返回(模型可见并自我纠正);传输/取消错误作为协议级错误返回。 -func handleCall(ctx context.Context, req *mcp.CallToolRequest, action string, caller BridgeCaller) (*mcp.CallToolResult, error) { +// handleCall 转发工具调用到 daemon,并在等待期间发 progress。浏览器方法使用 CallBrowser, +// scripts 方法使用 Call。桥接业务错误(拒绝/过期/scope 等)作为 IsError 工具结果返回 +// (模型可见并自我纠正);传输/取消错误作为协议级错误返回。 +func handleCall(ctx context.Context, req *mcp.CallToolRequest, action, browser string, caller BridgeCaller) (*mcp.CallToolResult, error) { input := req.Params.Arguments stop := startProgress(ctx, req) - res, err := caller.Call(ctx, action, input) + var res control.CallResult + var err error + if isBrowserAction(action) { + res, err = caller.CallBrowser(ctx, action, browser, input) + } else { + res, err = caller.Call(ctx, action, input) + } stop() if err != nil { return nil, err diff --git a/internal/client/mcpserver/mcpserver_test.go b/internal/client/mcpserver/mcpserver_test.go index 9ac642b..bda18b0 100644 --- a/internal/client/mcpserver/mcpserver_test.go +++ b/internal/client/mcpserver/mcpserver_test.go @@ -17,13 +17,16 @@ import ( // fakeCaller 是 BridgeCaller 的测试桩:可配置阻塞、返回值,并记录收到的调用。 type fakeCaller struct { - mu sync.Mutex - actions []string - inputs []json.RawMessage - block chan struct{} // 非 nil 则 Call 阻塞至其关闭或 ctx 取消 - result control.CallResult - err error - sawCtx atomic.Bool // Call 是否因 ctx 取消而返回 + mu sync.Mutex + actions []string + inputs []json.RawMessage + browserParams []string // 记录 CallBrowser 调用中的 browser 参数 + block chan struct{} // 非 nil 则 Call 阻塞至其关闭或 ctx 取消 + result control.CallResult + err error + sawCtx atomic.Bool // Call/CallBrowser 是否因 ctx 取消而返回 + browsersList []control.BrowserInfo // Browsers 的返回值 + browsersErr error } func (f *fakeCaller) Call(ctx context.Context, action string, input json.RawMessage) (control.CallResult, error) { @@ -43,6 +46,28 @@ func (f *fakeCaller) Call(ctx context.Context, action string, input json.RawMess return f.result, f.err } +func (f *fakeCaller) CallBrowser(ctx context.Context, action, browser string, input json.RawMessage) (control.CallResult, error) { + f.mu.Lock() + f.actions = append(f.actions, action) + f.browserParams = append(f.browserParams, browser) + f.inputs = append(f.inputs, append(json.RawMessage(nil), input...)) + block := f.block + f.mu.Unlock() + if block != nil { + select { + case <-block: + case <-ctx.Done(): + f.sawCtx.Store(true) + return control.CallResult{}, ctx.Err() + } + } + return f.result, f.err +} + +func (f *fakeCaller) Browsers(ctx context.Context) ([]control.BrowserInfo, error) { + return f.browsersList, f.browsersErr +} + // connect 用内存 transport 把一个 mcpserver 与一个测试 MCP client 对接。 func connect(t *testing.T, deps Deps, clientOpts *mcp.ClientOptions) *mcp.ClientSession { t.Helper() @@ -74,16 +99,23 @@ func toolNames(res *mcp.ListToolsResult) []string { } func TestToolsListExposesAllTools(t *testing.T) { - Convey("扁平信任:tools/list 暴露 protocol.json 定义的全部工具(不再按 scope 过滤)", t, func() { + Convey("扁平信任:tools/list 暴露 protocol.json 定义的全部方法(工具)与特殊的 browsers_list", t, func() { p := loadProto(t) caller := &fakeCaller{result: control.CallResult{OK: true, Result: json.RawMessage(`{}`)}} session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil) res, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) So(err, ShouldBeNil) - So(len(res.Tools), ShouldEqual, len(p.Actions)) + // 每个 protocol 方法映射一个工具,加上 browsers_list (不是方法)。 + So(len(res.Tools), ShouldEqual, len(p.Actions)+1) So(toolNames(res), ShouldContain, "scripts_list") So(toolNames(res), ShouldContain, "scripts_delete_request") + So(toolNames(res), ShouldContain, "browsers_list") + So(toolNames(res), ShouldContain, "tabs_list") + So(toolNames(res), ShouldContain, "tabs_open") + So(toolNames(res), ShouldContain, "tabs_close") + So(toolNames(res), ShouldContain, "tabs_activate") + So(toolNames(res), ShouldContain, "windows_list") }) } diff --git a/internal/client/mcpserver/tools.go b/internal/client/mcpserver/tools.go index 1c0f7e1..51d4f98 100644 --- a/internal/client/mcpserver/tools.go +++ b/internal/client/mcpserver/tools.go @@ -1,5 +1,10 @@ package mcpserver +import ( + "encoding/json" + "fmt" +) + // toolDef 把一个 bridge action 映射成一个 MCP 工具:工具名(点号换下划线以兼容各客户端)、 // 静态描述(绝不拼入脚本可控文本,契合 contentTrust=untrusted-user-script-source),以及 // 手写的 2020-12 输入 schema(权威校验仍在扩展侧;此处只作客户端提示)。 @@ -43,7 +48,33 @@ const ( `"required":["uuid","edits"],"additionalProperties":false}` ) +// schemaWithOptionalBrowser 在既有 schema 基础上加一个可选 browser 参数,供工具向 MCP 客户端声明。 +func schemaWithOptionalBrowser(baseSchema string) string { + var parsed any + if err := json.Unmarshal([]byte(baseSchema), &parsed); err != nil { + panic(fmt.Sprintf("schemaWithOptionalBrowser: parse base schema: %v", err)) + } + m, ok := parsed.(map[string]any) + if !ok { + panic("schemaWithOptionalBrowser: base schema is not a JSON object") + } + props, ok := m["properties"].(map[string]any) + if !ok { + panic("schemaWithOptionalBrowser: schema.properties is not a JSON object") + } + props["browser"] = map[string]any{ + "type": "string", + "description": "Name or instance ID prefix of the target browser. If not specified and multiple browsers are online, returns error.", + } + result, err := json.Marshal(m) + if err != nil { + panic(fmt.Sprintf("schemaWithOptionalBrowser: marshal modified schema: %v", err)) + } + return string(result) +} + // toolDefs 是全部 bridge action 的工具定义,顺序稳定,注册时按 protocol.json 是否定义该 action 过滤。 +// browsers_list 特殊处理:它不是 bridge action,由 registerBrowsersListTool 单独注册。 var toolDefs = []toolDef{ { action: "scripts.list", @@ -104,4 +135,34 @@ var toolDefs = []toolDef{ "written; nothing changes if they decline.", inputSchema: schemaEditRequest, }, + { + action: "tabs.list", + name: "tabs_list", + description: "List tabs in a browser window: tab ID, window ID, active status, pinned status, title, and URL.", + inputSchema: schemaWithOptionalBrowser(`{"type":"object","properties":{"windowId":{"type":"integer","minimum":0}},"additionalProperties":false}`), + }, + { + action: "tabs.open", + name: "tabs_open", + description: "Open a new tab in a browser window at the specified URL and return the tab ID.", + inputSchema: schemaWithOptionalBrowser(`{"type":"object","properties":{"url":{"type":"string"},"windowId":{"type":"integer","minimum":0},"background":{"type":"boolean"}},"required":["url"],"additionalProperties":false}`), + }, + { + action: "tabs.close", + name: "tabs_close", + description: "Close one or more tabs by their IDs.", + inputSchema: schemaWithOptionalBrowser(`{"type":"object","properties":{"tabIds":{"type":"array","items":{"type":"integer"}}},"required":["tabIds"],"additionalProperties":false}`), + }, + { + action: "tabs.activate", + name: "tabs_activate", + description: "Activate a tab and bring its window to focus.", + inputSchema: schemaWithOptionalBrowser(`{"type":"object","properties":{"tabId":{"type":"integer"}},"required":["tabId"],"additionalProperties":false}`), + }, + { + action: "windows.list", + name: "windows_list", + description: "List all browser windows: window ID, focused status, window state, and tab count.", + inputSchema: schemaWithOptionalBrowser(`{"type":"object","properties":{},"additionalProperties":false}`), + }, } From e32fcbe1ef231a206520fa4a6cff309be809dfc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:49:50 +0800 Subject: [PATCH 09/25] test: verify MCP tool descriptions are static and never spliced with page data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tool descriptions for browser tools are static text per spec — tab titles and URLs returned from tabs.list are never included in tool descriptions. Add TestToolDescriptionsAreStatic to verify this: list tools before and after calling tabs.list with marker strings in tab data, confirm descriptions remain identical and contain no markers. Closes task 6 gap: MCP tool descriptions are static text. --- internal/client/mcpserver/mcpserver_test.go | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/internal/client/mcpserver/mcpserver_test.go b/internal/client/mcpserver/mcpserver_test.go index bda18b0..5955ad4 100644 --- a/internal/client/mcpserver/mcpserver_test.go +++ b/internal/client/mcpserver/mcpserver_test.go @@ -344,3 +344,73 @@ func TestBlockingProgressAndCancel(t *testing.T) { }) }) } + +func TestToolDescriptionsAreStatic(t *testing.T) { + Convey("MCP 工具描述为静态文本,从不拼入页面控制数据如标签页标题与 URL", t, func() { + p := loadProto(t) + // 模拟浏览器调用返回包含特征标记字符串的标签页数据。 + markerTitle := "MARKER_TITLE_TESTDATA_12345" + markerURL := "MARKER_URL_TESTDATA_67890" + tabsResult := map[string]any{ + "tabs": []map[string]any{ + { + "id": 1, + "windowId": 1, + "active": true, + "pinned": false, + "title": markerTitle, + "url": markerURL, + }, + }, + } + resultJSON, err := json.Marshal(tabsResult) + So(err, ShouldBeNil) + + caller := &fakeCaller{ + result: control.CallResult{OK: true, Result: resultJSON}, + } + + session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil) + + // 第一次列出工具,记录描述。 + res1, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + So(err, ShouldBeNil) + desc1 := make(map[string]string) + for _, tool := range res1.Tools { + desc1[tool.Name] = tool.Description + } + + // 调用 tabs_list,让模拟器返回包含特征标记的数据。 + _, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "tabs_list", + Arguments: map[string]any{}, + }) + So(err, ShouldBeNil) + + // 第二次列出工具,再次记录描述。 + res2, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + So(err, ShouldBeNil) + desc2 := make(map[string]string) + for _, tool := range res2.Tools { + desc2[tool.Name] = tool.Description + } + + Convey("任何工具描述都不含标签页标题的特征标记", func() { + for name, desc := range desc2 { + So(desc, ShouldNotContainSubstring, markerTitle) + So(name, ShouldNotBeEmpty) // Ensure we checked at least some tools + } + }) + + Convey("任何工具描述都不含标签页 URL 的特征标记", func() { + for name, desc := range desc2 { + So(desc, ShouldNotContainSubstring, markerURL) + So(name, ShouldNotBeEmpty) // Ensure we checked at least some tools + } + }) + + Convey("工具描述前后一致,不随数据调用而改变", func() { + So(desc1, ShouldResemble, desc2) + }) + }) +} From f1af2ab5a0cbcf90799af1f669e9cc7c9ad6de3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:50:27 +0800 Subject: [PATCH 10/25] feat: add sctl tabs and windows commands --- internal/cli/cli.go | 2 + internal/cli/dispatch.go | 15 ++- internal/cli/tabs.go | 246 +++++++++++++++++++++++++++++++++++ internal/cli/tabs_test.go | 265 ++++++++++++++++++++++++++++++++++++++ internal/cli/windows.go | 83 ++++++++++++ 5 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 internal/cli/tabs.go create mode 100644 internal/cli/tabs_test.go create mode 100644 internal/cli/windows.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6b4dca4..52f8ed7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -92,6 +92,8 @@ func NewRootCmd() *cobra.Command { newConnectCmd(), newStatusCmd(), newBrowsersCmd(), + newTabsCmd(), + newWindowsCmd(), newVersionCmd(), newGetCmd(), newGrepCmd(), diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go index f2c4539..844943b 100644 --- a/internal/cli/dispatch.go +++ b/internal/cli/dispatch.go @@ -20,15 +20,22 @@ import ( // exitVoided。桥接业务错误按 code 映射:USER_REJECTED→exitRejected,OPERATION_EXPIRED→exitVoided, // 其余→exitError。 func dispatch(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error { - return dispatchAction(cmd, action, input, false, onOK) + return dispatchAction(cmd, action, "", input, false, onOK) } // dispatchBlocking 与 dispatch 相同,但用于写动词:调用前告知用户正在等待浏览器裁决。 func dispatchBlocking(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error { - return dispatchAction(cmd, action, input, true, onOK) + return dispatchAction(cmd, action, "", input, true, onOK) } -func dispatchAction(cmd *cobra.Command, action string, input json.RawMessage, blocking bool, onOK func(result json.RawMessage) error) error { +// dispatchBrowser 与 dispatch 相同,但携带一个显式的目标浏览器(名称或实例-ID 前缀;空串交给 +// daemon 按在线实例解析,见 docs/protocol.md §3.1)。用于 tabs.*/windows.* 等浏览器方法命令; +// 这些方法不做逐次人工审批(spec 设计决策 3),所以从不走 blocking 提示。 +func dispatchBrowser(cmd *cobra.Command, action, browser string, input json.RawMessage, onOK func(result json.RawMessage) error) error { + return dispatchAction(cmd, action, browser, input, false, onOK) +} + +func dispatchAction(cmd *cobra.Command, action, browser string, input json.RawMessage, blocking bool, onOK func(result json.RawMessage) error) error { ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) defer stop() @@ -41,7 +48,7 @@ func dispatchAction(cmd *cobra.Command, action string, input json.RawMessage, bl if blocking { fmt.Fprintln(os.Stderr, "waiting for approval in the browser… (Ctrl-C cancels and voids this operation)") } - res, err := client.Call(ctx, action, input) + res, err := client.CallBrowser(ctx, action, browser, input) if err != nil { if errors.Is(err, context.Canceled) { return &ExitError{Code: exitVoided, Message: "canceled, operation voided"} diff --git a/internal/cli/tabs.go b/internal/cli/tabs.go new file mode 100644 index 0000000..657b725 --- /dev/null +++ b/internal/cli/tabs.go @@ -0,0 +1,246 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +// browserTarget 是 --browser 的值,默认取自 SCTL_BROWSER(与 --data-dir/SCTL_DATA_DIR 同一先例, +// 见 cli.go:86):环境变量在构建标志时读取,显式传入的 --browser 优先于它。由 daemon 解析为 +// 具体的已配对实例(docs/protocol.md §3.1「路由与目标选择」)。 +var browserTarget string + +// addBrowserFlag 给一棵浏览器命令树的父命令挂 --browser,其全部子命令通过 PersistentFlags 继承。 +func addBrowserFlag(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&browserTarget, "browser", os.Getenv("SCTL_BROWSER"), + "target browser instance: name or instance-ID prefix (env: SCTL_BROWSER; an explicit --browser wins)") +} + +// browserRef 镜像多实例汇总结果里每一项携带的 "browser":{"id","name"}(docs/protocol.md §3.1)。 +type browserRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// browserLabel 把 browserRef 呈现为表格里的一格;nil 表示单实例结果,不应被调用(调用方先判断)。 +func browserLabel(b *browserRef) string { + if b == nil { + return "" + } + return b.Name +} + +// newTabsCmd 构造 `sctl tabs`:list/open/close/activate 四个子命令,均路由到浏览器方法 +// (docs/specs 第 1 期命令表)。 +func newTabsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tabs", + Short: "Manage tabs on a paired sctl Browser instance", + } + addBrowserFlag(cmd) + cmd.AddCommand(newTabsListCmd(), newTabsOpenCmd(), newTabsCloseCmd(), newTabsActivateCmd()) + return cmd +} + +func newTabsListCmd() *cobra.Command { + var window int + cmd := &cobra.Command{ + Use: "list", + Short: "List tabs, optionally restricted to one window", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + input := map[string]any{} + if cmd.Flags().Changed("window") { + input["windowId"] = window + } + return dispatchBrowser(cmd, "tabs.list", browserTarget, mustInput(input), func(result json.RawMessage) error { + if outputFormat == outputJSON { + return printResultJSON(result) + } + return printTabsTable(result) + }) + }, + } + cmd.Flags().IntVar(&window, "window", 0, "restrict the listing to tabs in this window ID") + return cmd +} + +func newTabsOpenCmd() *cobra.Command { + var window int + var background bool + cmd := &cobra.Command{ + Use: "open ", + Short: "Open a URL in a new tab and print its tab ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{"url": args[0]} + if cmd.Flags().Changed("window") { + input["windowId"] = window + } + if cmd.Flags().Changed("background") { + input["background"] = background + } + return dispatchBrowser(cmd, "tabs.open", browserTarget, mustInput(input), func(result json.RawMessage) error { + if outputFormat == outputJSON { + return printResultJSON(result) + } + return printTabIDTable(result) + }) + }, + } + cmd.Flags().IntVar(&window, "window", 0, "open in this window ID (default: the last-focused window)") + cmd.Flags().BoolVar(&background, "background", false, "open without activating the new tab (default: activates it)") + return cmd +} + +func newTabsCloseCmd() *cobra.Command { + return &cobra.Command{ + Use: "close ...", + Short: "Close one or more tabs", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ids, err := parseTabIDs(args) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + input := mustInput(map[string]any{"tabIds": ids}) + return dispatchBrowser(cmd, "tabs.close", browserTarget, input, func(result json.RawMessage) error { + if outputFormat == outputJSON { + return printResultJSON(result) + } + return printClosedTabsTable(result) + }) + }, + } +} + +func newTabsActivateCmd() *cobra.Command { + return &cobra.Command{ + Use: "activate ", + Short: "Activate a tab and focus its window", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return &ExitError{Code: exitError, Message: fmt.Sprintf("invalid tab ID %q: %v", args[0], err)} + } + input := mustInput(map[string]any{"tabId": id}) + return dispatchBrowser(cmd, "tabs.activate", browserTarget, input, func(result json.RawMessage) error { + if outputFormat == outputJSON { + return printResultJSON(result) + } + return printActivatedTabTable(result) + }) + }, + } +} + +// parseTabIDs 把位置参数解析为标签 ID 列表;任一项非法整数就整体报错(不做部分容错)。 +func parseTabIDs(args []string) ([]int, error) { + ids := make([]int, 0, len(args)) + for _, a := range args { + id, err := strconv.Atoi(a) + if err != nil { + return nil, fmt.Errorf("invalid tab ID %q: %w", a, err) + } + ids = append(ids, id) + } + return ids, nil +} + +// tabRow 承载 tabs.list 表格所需字段。Browser 只在多实例汇总时非空(docs/protocol.md §3.1); +// Title/URL 是网页可控数据,只作为结构化字段打印,绝不解释执行(spec「页面内容不可信」)。 +type tabRow struct { + TabId int `json:"tabId"` + WindowId int `json:"windowId"` + Active bool `json:"active"` + Pinned bool `json:"pinned"` + Title string `json:"title"` + URL string `json:"url"` + Browser *browserRef `json:"browser,omitempty"` +} + +func printTabsTable(result json.RawMessage) error { + var payload struct { + Tabs []tabRow `json:"tabs"` + } + if err := json.Unmarshal(result, &payload); err != nil { + // 结构不符预期时退回原样 JSON,不吞信息(与 get.go 的既有约定一致)。 + return printResultJSON(result) + } + if len(payload.Tabs) == 0 { + fmt.Fprintln(os.Stdout, "(no tabs)") + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + multi := anyTabHasBrowser(payload.Tabs) + if multi { + fmt.Fprintln(tw, "TAB ID\tWINDOW ID\tACTIVE\tPINNED\tTITLE\tURL\tBROWSER") + } else { + fmt.Fprintln(tw, "TAB ID\tWINDOW ID\tACTIVE\tPINNED\tTITLE\tURL") + } + for _, t := range payload.Tabs { + if multi { + fmt.Fprintf(tw, "%d\t%d\t%v\t%v\t%s\t%s\t%s\n", t.TabId, t.WindowId, t.Active, t.Pinned, t.Title, t.URL, browserLabel(t.Browser)) + } else { + fmt.Fprintf(tw, "%d\t%d\t%v\t%v\t%s\t%s\n", t.TabId, t.WindowId, t.Active, t.Pinned, t.Title, t.URL) + } + } + return tw.Flush() +} + +func anyTabHasBrowser(rows []tabRow) bool { + for _, r := range rows { + if r.Browser != nil { + return true + } + } + return false +} + +func printTabIDTable(result json.RawMessage) error { + var payload struct { + TabId int `json:"tabId"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return printResultJSON(result) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "TAB ID") + fmt.Fprintf(tw, "%d\n", payload.TabId) + return tw.Flush() +} + +func printClosedTabsTable(result json.RawMessage) error { + var payload struct { + TabIds []int `json:"tabIds"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return printResultJSON(result) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "TAB ID") + for _, id := range payload.TabIds { + fmt.Fprintf(tw, "%d\n", id) + } + return tw.Flush() +} + +func printActivatedTabTable(result json.RawMessage) error { + var payload struct { + TabId int `json:"tabId"` + WindowId int `json:"windowId"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return printResultJSON(result) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "TAB ID\tWINDOW ID") + fmt.Fprintf(tw, "%d\t%d\n", payload.TabId, payload.WindowId) + return tw.Flush() +} diff --git a/internal/cli/tabs_test.go b/internal/cli/tabs_test.go new file mode 100644 index 0000000..c4fd194 --- /dev/null +++ b/internal/cli/tabs_test.go @@ -0,0 +1,265 @@ +package cli + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/client/control" +) + +// TestTabsList 覆盖 sctl tabs list 的表格与 -o json 输出(docs/specs 第 1 期命令表)。 +func TestTabsList(t *testing.T) { + Convey("sctl tabs list 列出标签页", t, func() { + result := `{"contentTrust":"untrusted-page-content","tabs":[ + {"tabId":1,"windowId":10,"active":true,"pinned":false,"title":"Example","url":"https://example.com"} + ]}` + + Convey("默认表格含标签 ID、窗口 ID、是否激活、是否固定、标题与 URL", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(result)}) + code, out := runCLI("tabs", "list") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "TAB ID") + So(out, ShouldContainSubstring, "WINDOW ID") + So(out, ShouldContainSubstring, "ACTIVE") + So(out, ShouldContainSubstring, "PINNED") + So(out, ShouldContainSubstring, "TITLE") + So(out, ShouldContainSubstring, "URL") + So(out, ShouldContainSubstring, "Example") + So(out, ShouldContainSubstring, "https://example.com") + // 单实例结果不该多出 BROWSER 列。 + So(out, ShouldNotContainSubstring, "BROWSER") + }) + + Convey("-o json 原样输出结果", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(result)}) + code, out := runCLI("tabs", "list", "-o", "json") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, `"tabId": 1`) + So(out, ShouldContainSubstring, "untrusted-page-content") + }) + + Convey("--window 传下去的请求带 windowId", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"contentTrust":"untrusted-page-content","tabs":[]}`)}) + code, _ := runCLI("tabs", "list", "--window", "7") + So(code, ShouldEqual, exitOK) + So(req.Action, ShouldEqual, "tabs.list") + So(string(req.Input), ShouldContainSubstring, `"windowId":7`) + }) + + Convey("不带 --window 时不下发 windowId 字段", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"contentTrust":"untrusted-page-content","tabs":[]}`)}) + code, _ := runCLI("tabs", "list") + So(code, ShouldEqual, exitOK) + So(string(req.Input), ShouldNotContainSubstring, "windowId") + }) + + Convey("多实例汇总结果给表格加 BROWSER 列,JSON 每项带浏览器名称和 ID", func() { + merged := `{"contentTrust":"untrusted-page-content","tabs":[ + {"tabId":1,"windowId":10,"active":true,"pinned":false,"title":"A","url":"https://a.example","browser":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"chrome-a"}}, + {"tabId":2,"windowId":20,"active":false,"pinned":true,"title":"B","url":"https://b.example","browser":{"id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","name":"chrome-b"}} + ]}` + stubDaemon(t, control.CallResult{OK: true, Result: []byte(merged)}) + + code, out := runCLI("tabs", "list") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "BROWSER") + So(out, ShouldContainSubstring, "chrome-a") + So(out, ShouldContainSubstring, "chrome-b") + + code, out = runCLI("tabs", "list", "-o", "json") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, `"name": "chrome-a"`) + So(out, ShouldContainSubstring, `"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"`) + }) + }) +} + +// TestTabsOpen 覆盖 sctl tabs open:打印新标签 ID,--window/--background 透传。 +func TestTabsOpen(t *testing.T) { + Convey("sctl tabs open 打开一个标签并打印它的标签 ID", t, func() { + Convey("默认表格打印标签 ID", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(`{"tabId":42}`)}) + code, out := runCLI("tabs", "open", "https://example.com") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "TAB ID") + So(out, ShouldContainSubstring, "42") + }) + + Convey("-o json 原样输出结果", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(`{"tabId":42}`)}) + code, out := runCLI("tabs", "open", "https://example.com", "-o", "json") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, `"tabId": 42`) + }) + + Convey("--window 与 --background 透传给请求", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"tabId":1}`)}) + code, _ := runCLI("tabs", "open", "https://example.com", "--window", "9", "--background") + So(code, ShouldEqual, exitOK) + So(req.Action, ShouldEqual, "tabs.open") + So(string(req.Input), ShouldContainSubstring, `"url":"https://example.com"`) + So(string(req.Input), ShouldContainSubstring, `"windowId":9`) + So(string(req.Input), ShouldContainSubstring, `"background":true`) + }) + + Convey("不带 --window/--background 时只下发 url", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"tabId":1}`)}) + code, _ := runCLI("tabs", "open", "https://example.com") + So(code, ShouldEqual, exitOK) + So(string(req.Input), ShouldNotContainSubstring, "windowId") + So(string(req.Input), ShouldNotContainSubstring, "background") + }) + }) +} + +// TestTabsClose 覆盖 sctl tabs close ...。 +func TestTabsClose(t *testing.T) { + Convey("sctl tabs close 关闭一个或多个标签页", t, func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"tabIds":[1,2]}`)}) + code, out := runCLI("tabs", "close", "1", "2") + So(code, ShouldEqual, exitOK) + So(req.Action, ShouldEqual, "tabs.close") + So(string(req.Input), ShouldEqual, `{"tabIds":[1,2]}`) + So(out, ShouldContainSubstring, "TAB ID") + So(out, ShouldContainSubstring, "1") + So(out, ShouldContainSubstring, "2") + }) +} + +// TestTabsActivate 覆盖 sctl tabs activate 。 +func TestTabsActivate(t *testing.T) { + Convey("sctl tabs activate 激活标签并切到它所在窗口", t, func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"tabId":5,"windowId":10}`)}) + code, out := runCLI("tabs", "activate", "5") + So(code, ShouldEqual, exitOK) + So(req.Action, ShouldEqual, "tabs.activate") + So(string(req.Input), ShouldEqual, `{"tabId":5}`) + So(out, ShouldContainSubstring, "WINDOW ID") + So(out, ShouldContainSubstring, "10") + }) +} + +// TestWindowsList 覆盖 sctl windows list。 +func TestWindowsList(t *testing.T) { + Convey("sctl windows list 列出窗口", t, func() { + result := `{"windows":[{"windowId":1,"focused":true,"state":"normal","tabCount":3}]}` + + Convey("默认表格含窗口 ID、是否有焦点、状态与标签数", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(result)}) + code, out := runCLI("windows", "list") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "WINDOW ID") + So(out, ShouldContainSubstring, "FOCUSED") + So(out, ShouldContainSubstring, "STATE") + So(out, ShouldContainSubstring, "TABS") + So(out, ShouldContainSubstring, "normal") + So(out, ShouldNotContainSubstring, "BROWSER") + }) + + Convey("-o json 原样输出结果", func() { + stubDaemon(t, control.CallResult{OK: true, Result: []byte(result)}) + code, out := runCLI("windows", "list", "-o", "json") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, `"windowId": 1`) + }) + + Convey("多实例汇总结果给表格加 BROWSER 列", func() { + merged := `{"windows":[ + {"windowId":1,"focused":true,"state":"normal","tabCount":1,"browser":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"chrome-a"}}, + {"windowId":2,"focused":false,"state":"minimized","tabCount":2,"browser":{"id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","name":"chrome-b"}} + ]}` + stubDaemon(t, control.CallResult{OK: true, Result: []byte(merged)}) + code, out := runCLI("windows", "list") + So(code, ShouldEqual, exitOK) + So(out, ShouldContainSubstring, "BROWSER") + So(out, ShouldContainSubstring, "chrome-a") + So(out, ShouldContainSubstring, "chrome-b") + }) + }) +} + +// TestBrowserTargetFlag 覆盖「显式 --browser 优先于 SCTL_BROWSER」(docs/specs 路由与目标选择)。 +func TestBrowserTargetFlag(t *testing.T) { + Convey("--browser 与 SCTL_BROWSER 决定下发的目标浏览器", t, func() { + Convey("只设置 SCTL_BROWSER 时下发它的值", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"windows":[]}`)}) + t.Setenv("SCTL_BROWSER", "env-browser") + code, _ := runCLI("windows", "list") + So(code, ShouldEqual, exitOK) + So(req.Browser, ShouldEqual, "env-browser") + }) + + Convey("显式 --browser 优先于 SCTL_BROWSER", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"windows":[]}`)}) + t.Setenv("SCTL_BROWSER", "env-browser") + code, _ := runCLI("windows", "list", "--browser", "flag-browser") + So(code, ShouldEqual, exitOK) + So(req.Browser, ShouldEqual, "flag-browser") + }) + + Convey("都不设置时下发空目标,交给 daemon 按在线实例解析", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"windows":[]}`)}) + t.Setenv("SCTL_BROWSER", "") + code, _ := runCLI("windows", "list") + So(code, ShouldEqual, exitOK) + So(req.Browser, ShouldEqual, "") + }) + + Convey("--browser 对 tabs 子命令同样生效", func() { + req := stubDaemonCapturing(t, control.CallResult{OK: true, Result: []byte(`{"contentTrust":"untrusted-page-content","tabs":[]}`)}) + code, _ := runCLI("tabs", "list", "--browser", "chrome-b") + So(code, ShouldEqual, exitOK) + So(req.Browser, ShouldEqual, "chrome-b") + }) + }) +} + +// TestBrowserTargetErrorExitCodes 覆盖目标选择表的错误退出码(docs/specs 路由与目标选择; +// docs/protocol.md §3.1/§4):离线/未知/歧义退出码 3,调用中途断开(OPERATION_EXPIRED)退出码 2, +// 未知标签/窗口 ID(NOT_FOUND)退出码 3。 +func TestBrowserTargetErrorExitCodes(t *testing.T) { + Convey("浏览器目标与调用错误按错误码映射退出码", t, func() { + Convey("没有浏览器在线 → NO_BROWSER_CONNECTED,退出码 3", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "NO_BROWSER_CONNECTED", Message: "no browser connected"}}) + code, _ := runCLI("tabs", "list") + So(code, ShouldEqual, exitError) + }) + + Convey("目标已配对但离线 → BROWSER_OFFLINE,退出码 3", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "BROWSER_OFFLINE", Message: "browser \"chrome-a\" is offline"}}) + code, _ := runCLI("tabs", "list", "--browser", "chrome-a") + So(code, ShouldEqual, exitError) + }) + + Convey("目标不匹配任何已配对实例 → BROWSER_NOT_FOUND,退出码 3", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "BROWSER_NOT_FOUND", Message: "no such browser"}}) + code, _ := runCLI("tabs", "list", "--browser", "nope") + So(code, ShouldEqual, exitError) + }) + + Convey("多实例在线且方法不可合并、未指定目标 → BROWSER_AMBIGUOUS,退出码 3(候选列表由 daemon 拼入 message,见 controlapi 层测试)", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "BROWSER_AMBIGUOUS", Message: "ambiguous target: chrome-a, chrome-b are online; use --browser"}}) + code, _ := runCLI("tabs", "open", "https://example.com") + So(code, ShouldEqual, exitError) + }) + + Convey("调用进行时目标浏览器断开 → OPERATION_EXPIRED,退出码 2", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "OPERATION_EXPIRED"}}) + code, _ := runCLI("tabs", "activate", "1") + So(code, ShouldEqual, exitVoided) + }) + + Convey("标签 ID 不存在 → NOT_FOUND,退出码 3", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "NOT_FOUND", Message: "no such tab"}}) + code, _ := runCLI("tabs", "activate", "999") + So(code, ShouldEqual, exitError) + }) + + Convey("窗口 ID 不存在 → NOT_FOUND,退出码 3", func() { + stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "NOT_FOUND", Message: "no such window"}}) + code, _ := runCLI("tabs", "list", "--window", "999") + So(code, ShouldEqual, exitError) + }) + }) +} diff --git a/internal/cli/windows.go b/internal/cli/windows.go new file mode 100644 index 0000000..6b01bee --- /dev/null +++ b/internal/cli/windows.go @@ -0,0 +1,83 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +// newWindowsCmd 构造 `sctl windows`:目前只有 list 子命令(docs/specs 第 1 期命令表)。 +func newWindowsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "windows", + Short: "Manage windows on a paired sctl Browser instance", + } + addBrowserFlag(cmd) + cmd.AddCommand(newWindowsListCmd()) + return cmd +} + +func newWindowsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List windows", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return dispatchBrowser(cmd, "windows.list", browserTarget, mustInput(map[string]any{}), func(result json.RawMessage) error { + if outputFormat == outputJSON { + return printResultJSON(result) + } + return printWindowsTable(result) + }) + }, + } +} + +// windowRow 承载 windows.list 表格所需字段。Browser 只在多实例汇总时非空(docs/protocol.md §3.1)。 +type windowRow struct { + WindowId int `json:"windowId"` + Focused bool `json:"focused"` + State string `json:"state"` + TabCount int `json:"tabCount"` + Browser *browserRef `json:"browser,omitempty"` +} + +func printWindowsTable(result json.RawMessage) error { + var payload struct { + Windows []windowRow `json:"windows"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return printResultJSON(result) + } + if len(payload.Windows) == 0 { + fmt.Fprintln(os.Stdout, "(no windows)") + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + multi := anyWindowHasBrowser(payload.Windows) + if multi { + fmt.Fprintln(tw, "WINDOW ID\tFOCUSED\tSTATE\tTABS\tBROWSER") + } else { + fmt.Fprintln(tw, "WINDOW ID\tFOCUSED\tSTATE\tTABS") + } + for _, w := range payload.Windows { + if multi { + fmt.Fprintf(tw, "%d\t%v\t%s\t%d\t%s\n", w.WindowId, w.Focused, w.State, w.TabCount, browserLabel(w.Browser)) + } else { + fmt.Fprintf(tw, "%d\t%v\t%s\t%d\n", w.WindowId, w.Focused, w.State, w.TabCount) + } + } + return tw.Flush() +} + +func anyWindowHasBrowser(rows []windowRow) bool { + for _, r := range rows { + if r.Browser != nil { + return true + } + } + return false +} From 5b0c5f717e9c00e928eacc2b05bf61db72d319d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 21:52:02 +0800 Subject: [PATCH 11/25] feat: handle tab and window methods in the extension --- extension/src/handlers/index.test.ts | 287 +++++++++++++++++++++++++++ extension/src/handlers/index.ts | 89 ++++++++- 2 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 extension/src/handlers/index.test.ts diff --git a/extension/src/handlers/index.test.ts b/extension/src/handlers/index.test.ts new file mode 100644 index 0000000..4221e57 --- /dev/null +++ b/extension/src/handlers/index.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { HandlerRegistry } from "@/background/registry"; +import { + validateTabsActivateResult, + validateTabsCloseResult, + validateTabsListResult, + validateTabsOpenResult, + validateWindowsListResult, +} from "@/protocol/generated/validators.generated"; +import { registerHandlers } from "./index"; + +// chrome.tabs / chrome.windows 都是 Promise 化的(manifest 要求 Chrome 116+),伪造对象直接返回/拒绝 Promise。 +// 具体函数类型直接抄自 chrome.tabs/chrome.windows 的 Promise 重载,而不是 typeof,避免 vi.fn 泛型解析到回调重载。 +type TabsQueryFn = (queryInfo: chrome.tabs.QueryInfo) => Promise; +type TabsGetFn = (tabId: number) => Promise; +type TabsCreateFn = (createProperties: chrome.tabs.CreateProperties) => Promise; +type TabsUpdateFn = ( + tabId: number, + updateProperties: chrome.tabs.UpdateProperties, +) => Promise; +type TabsRemoveFn = (tabIds: number | number[]) => Promise; +type WindowsGetFn = (windowId: number, queryOptions?: chrome.windows.QueryOptions) => Promise; +type WindowsGetLastFocusedFn = (queryOptions?: chrome.windows.QueryOptions) => Promise; +type WindowsUpdateFn = (windowId: number, updateInfo: chrome.windows.UpdateInfo) => Promise; +type WindowsGetAllFn = (queryOptions?: chrome.windows.QueryOptions) => Promise; + +interface ChromeMock { + tabs: { + query: Mock; + get: Mock; + create: Mock; + update: Mock; + remove: Mock; + }; + windows: { + get: Mock; + getLastFocused: Mock; + update: Mock; + getAll: Mock; + }; +} + +function fakeTab(overrides: Partial & { id: number; windowId: number }): chrome.tabs.Tab { + return { + active: false, + pinned: false, + highlighted: false, + incognito: false, + selected: false, + discarded: false, + autoDiscardable: true, + index: 0, + ...overrides, + } as chrome.tabs.Tab; +} + +function fakeWindow(overrides: Partial & { id: number }): chrome.windows.Window { + return { + focused: false, + alwaysOnTop: false, + incognito: false, + state: "normal", + type: "normal", + ...overrides, + }; +} + +function createChromeMock(): ChromeMock { + return { + tabs: { + query: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + windows: { + get: vi.fn(), + getLastFocused: vi.fn(), + update: vi.fn(), + getAll: vi.fn(), + }, + }; +} + +describe("browser method handlers", () => { + let chromeMock: ChromeMock; + let registry: HandlerRegistry; + + beforeEach(() => { + chromeMock = createChromeMock(); + vi.stubGlobal("chrome", chromeMock); + registry = new HandlerRegistry(); + registerHandlers(registry); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("declares capabilities for exactly the five browser methods", () => { + expect(new Set(registry.methods())).toEqual( + new Set(["tabs.list", "tabs.open", "tabs.close", "tabs.activate", "windows.list"]), + ); + }); + + describe("tabs.list", () => { + it("lists every tab across all windows with page-controlled title and URL untouched", async () => { + const tab = fakeTab({ + id: 7, + windowId: 1, + active: true, + pinned: true, + title: "", + url: "https://example.com/a?b=1", + }); + chromeMock.tabs.query.mockResolvedValue([tab]); + + const outcome = await registry.dispatch("tabs.list", {}); + + expect(chromeMock.tabs.query).toHaveBeenCalledWith({}); + expect(outcome).toEqual({ + ok: true, + result: { + contentTrust: "untrusted-page-content", + tabs: [ + { + tabId: 7, + windowId: 1, + active: true, + pinned: true, + title: "", + url: "https://example.com/a?b=1", + }, + ], + }, + }); + if (outcome.ok) { + expect(validateTabsListResult(outcome.result)).toBe(true); + } + }); + + it("filters by window when a window filter is given", async () => { + chromeMock.windows.get.mockResolvedValue(fakeWindow({ id: 3 })); + chromeMock.tabs.query.mockResolvedValue([]); + + const outcome = await registry.dispatch("tabs.list", { windowId: 3 }); + + expect(chromeMock.windows.get).toHaveBeenCalledWith(3); + expect(chromeMock.tabs.query).toHaveBeenCalledWith({ windowId: 3 }); + expect(outcome).toEqual({ ok: true, result: { contentTrust: "untrusted-page-content", tabs: [] } }); + }); + + it("returns NOT_FOUND when the window filter names a window that does not exist", async () => { + chromeMock.windows.get.mockRejectedValue(new Error("No window with id: 999.")); + + const outcome = await registry.dispatch("tabs.list", { windowId: 999 }); + + expect(outcome).toEqual({ ok: false, code: "NOT_FOUND", message: expect.stringContaining("999") as unknown }); + expect(chromeMock.tabs.query).not.toHaveBeenCalled(); + }); + }); + + describe("tabs.open", () => { + it("opens the URL in the last-focused window and activates it by default", async () => { + chromeMock.windows.getLastFocused.mockResolvedValue(fakeWindow({ id: 5, focused: true })); + chromeMock.tabs.create.mockResolvedValue(fakeTab({ id: 42, windowId: 5 })); + + const outcome = await registry.dispatch("tabs.open", { url: "https://example.com" }); + + expect(chromeMock.windows.getLastFocused).toHaveBeenCalled(); + expect(chromeMock.tabs.create).toHaveBeenCalledWith({ url: "https://example.com", windowId: 5, active: true }); + expect(outcome).toEqual({ ok: true, result: { tabId: 42 } }); + if (outcome.ok) { + expect(validateTabsOpenResult(outcome.result)).toBe(true); + } + }); + + it("opens in the requested window in the background when asked", async () => { + chromeMock.windows.get.mockResolvedValue(fakeWindow({ id: 9 })); + chromeMock.tabs.create.mockResolvedValue(fakeTab({ id: 43, windowId: 9 })); + + const outcome = await registry.dispatch("tabs.open", { + url: "https://example.com", + windowId: 9, + background: true, + }); + + expect(chromeMock.windows.getLastFocused).not.toHaveBeenCalled(); + expect(chromeMock.tabs.create).toHaveBeenCalledWith({ url: "https://example.com", windowId: 9, active: false }); + expect(outcome).toEqual({ ok: true, result: { tabId: 43 } }); + }); + + it("returns NOT_FOUND when the requested window does not exist", async () => { + chromeMock.windows.get.mockRejectedValue(new Error("No window with id: 404.")); + + const outcome = await registry.dispatch("tabs.open", { url: "https://example.com", windowId: 404 }); + + expect(outcome).toEqual({ ok: false, code: "NOT_FOUND", message: expect.stringContaining("404") as unknown }); + expect(chromeMock.tabs.create).not.toHaveBeenCalled(); + }); + }); + + describe("tabs.close", () => { + it("closes every given tab and echoes back the closed tab IDs", async () => { + chromeMock.tabs.get.mockImplementation((tabId: number) => Promise.resolve(fakeTab({ id: tabId, windowId: 1 }))); + chromeMock.tabs.remove.mockResolvedValue(undefined); + + const outcome = await registry.dispatch("tabs.close", { tabIds: [1, 2] }); + + expect(chromeMock.tabs.remove).toHaveBeenCalledWith([1, 2]); + expect(outcome).toEqual({ ok: true, result: { tabIds: [1, 2] } }); + if (outcome.ok) { + expect(validateTabsCloseResult(outcome.result)).toBe(true); + } + }); + + it("returns NOT_FOUND without closing any tab when one of the tab IDs does not exist", async () => { + chromeMock.tabs.get.mockImplementation((tabId: number) => + tabId === 2 + ? Promise.reject(new Error("No tab with id: 2.")) + : Promise.resolve(fakeTab({ id: tabId, windowId: 1 })), + ); + + const outcome = await registry.dispatch("tabs.close", { tabIds: [1, 2] }); + + expect(outcome).toEqual({ ok: false, code: "NOT_FOUND", message: expect.stringContaining("2") as unknown }); + expect(chromeMock.tabs.remove).not.toHaveBeenCalled(); + }); + }); + + describe("tabs.activate", () => { + it("activates the tab and focuses its window", async () => { + chromeMock.tabs.get.mockResolvedValue(fakeTab({ id: 11, windowId: 6 })); + chromeMock.tabs.update.mockResolvedValue(undefined); + chromeMock.windows.update.mockResolvedValue(fakeWindow({ id: 6, focused: true })); + + const outcome = await registry.dispatch("tabs.activate", { tabId: 11 }); + + expect(chromeMock.tabs.update).toHaveBeenCalledWith(11, { active: true }); + expect(chromeMock.windows.update).toHaveBeenCalledWith(6, { focused: true }); + expect(outcome).toEqual({ ok: true, result: { tabId: 11, windowId: 6 } }); + if (outcome.ok) { + expect(validateTabsActivateResult(outcome.result)).toBe(true); + } + }); + + it("returns NOT_FOUND when the tab does not exist", async () => { + chromeMock.tabs.get.mockRejectedValue(new Error("No tab with id: 77.")); + + const outcome = await registry.dispatch("tabs.activate", { tabId: 77 }); + + expect(outcome).toEqual({ ok: false, code: "NOT_FOUND", message: expect.stringContaining("77") as unknown }); + expect(chromeMock.tabs.update).not.toHaveBeenCalled(); + }); + }); + + describe("windows.list", () => { + it("lists every window with its focus state, lifecycle state and tab count", async () => { + chromeMock.windows.getAll.mockResolvedValue([ + fakeWindow({ + id: 1, + focused: true, + state: "maximized", + tabs: [fakeTab({ id: 1, windowId: 1 }), fakeTab({ id: 2, windowId: 1 })], + }), + fakeWindow({ id: 2, focused: false, state: "minimized", tabs: [] }), + ]); + + const outcome = await registry.dispatch("windows.list", {}); + + expect(chromeMock.windows.getAll).toHaveBeenCalledWith({ populate: true }); + expect(outcome).toEqual({ + ok: true, + result: { + windows: [ + { windowId: 1, focused: true, state: "maximized", tabCount: 2 }, + { windowId: 2, focused: false, state: "minimized", tabCount: 0 }, + ], + }, + }); + if (outcome.ok) { + expect(validateWindowsListResult(outcome.result)).toBe(true); + } + }); + }); +}); diff --git a/extension/src/handlers/index.ts b/extension/src/handlers/index.ts index 36d36ec..0c9ac8d 100644 --- a/extension/src/handlers/index.ts +++ b/extension/src/handlers/index.ts @@ -1,4 +1,89 @@ -import type { HandlerRegistry } from "@/background/registry"; +import { HandlerError, type HandlerRegistry, type RpcHandler } from "@/background/registry"; + +// chrome.tabs.get/chrome.windows.get 对不存在的 ID 只会 reject,这里把它翻译成协议的 NOT_FOUND 领域错误。 +async function requireTab(tabId: number): Promise { + try { + return await chrome.tabs.get(tabId); + } catch { + throw new HandlerError("NOT_FOUND", `no tab ${tabId}`); + } +} + +async function requireWindow(windowId: number): Promise { + try { + return await chrome.windows.get(windowId); + } catch { + throw new HandlerError("NOT_FOUND", `no window ${windowId}`); + } +} + +const handleTabsList: RpcHandler<"tabs.list"> = async (params) => { + if (params.windowId !== undefined) { + await requireWindow(params.windowId); + } + const tabs = await chrome.tabs.query(params.windowId === undefined ? {} : { windowId: params.windowId }); + return { + // 标题和 URL 由网页控制,原样返回,不做任何改写。 + contentTrust: "untrusted-page-content", + tabs: tabs.map((tab) => ({ + // 持有 tabs 权限时,chrome 保证真实标签页的 id/title/url 都已填充。 + tabId: tab.id!, + windowId: tab.windowId, + active: tab.active, + pinned: tab.pinned, + title: tab.title!, + url: tab.url!, + })), + }; +}; + +const handleTabsOpen: RpcHandler<"tabs.open"> = async (params) => { + let windowId: number; + if (params.windowId !== undefined) { + await requireWindow(params.windowId); + windowId = params.windowId; + } else { + // 未指定窗口时,默认打开在最近使用(最后获得焦点)的窗口里。 + const lastFocused = await chrome.windows.getLastFocused(); + windowId = lastFocused.id!; + } + const tab = await chrome.tabs.create({ url: params.url, windowId, active: params.background !== true }); + return { tabId: tab.id! }; +}; + +const handleTabsClose: RpcHandler<"tabs.close"> = async (params) => { + // 逐个确认存在,任何一个不存在都不关闭任何标签页,避免部分成功难以解释。 + for (const tabId of params.tabIds) { + await requireTab(tabId); + } + await chrome.tabs.remove(params.tabIds); + return { tabIds: params.tabIds }; +}; + +const handleTabsActivate: RpcHandler<"tabs.activate"> = async (params) => { + const tab = await requireTab(params.tabId); + await chrome.tabs.update(params.tabId, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + return { tabId: params.tabId, windowId: tab.windowId }; +}; + +const handleWindowsList: RpcHandler<"windows.list"> = async () => { + const windows = await chrome.windows.getAll({ populate: true }); + return { + windows: windows.map((win) => ({ + windowId: win.id!, + focused: win.focused, + state: win.state!, + tabCount: win.tabs!.length, + })), + }; +}; // 标签页与窗口方法的处理函数在这里注册;注册了哪些方法,扩展就向 daemon 声明哪些能力。 -export function registerHandlers(_registry: HandlerRegistry): void {} +export function registerHandlers(registry: HandlerRegistry): void { + registry.register("tabs.list", handleTabsList); + registry.register("tabs.open", handleTabsOpen); + registry.register("tabs.close", handleTabsClose); + registry.register("tabs.activate", handleTabsActivate); + registry.register("windows.list", handleWindowsList); +} From 735accfba83458c4beb04408d5e3070f7905b70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 22:11:41 +0800 Subject: [PATCH 12/25] feat: add the sctl Browser popup --- extension/package.json | 4 + extension/pnpm-lock.yaml | 553 ++++++++++++++++- .../src/components/popup/command-line.tsx | 37 ++ extension/src/components/popup/link.ts | 18 + extension/src/components/popup/note.tsx | 37 ++ extension/src/components/popup/segmented.tsx | 39 ++ extension/src/components/popup/tether.tsx | 94 +++ extension/src/components/ui/alert-dialog.tsx | 158 +++++ extension/src/components/ui/button.tsx | 52 ++ extension/src/components/ui/input.tsx | 19 + extension/src/components/ui/label.tsx | 17 + extension/src/i18n/index.ts | 2 + extension/src/i18n/strings.test.ts | 18 + extension/src/i18n/strings.ts | 228 +++++++ extension/src/popup/App.test.ts | 387 ++++++++++++ extension/src/popup/App.tsx | 587 ++++++++++++++++++ extension/src/popup/format-duration.test.ts | 19 + extension/src/popup/format-duration.ts | 14 + extension/src/popup/main.tsx | 3 +- extension/src/popup/pairing-draft.test.ts | 50 ++ extension/src/popup/pairing-draft.ts | 23 + extension/src/popup/preferences.test.ts | 54 ++ extension/src/popup/preferences.ts | 37 ++ extension/src/popup/storage.ts | 23 + extension/src/styles/globals.css | 104 ++-- 25 files changed, 2538 insertions(+), 39 deletions(-) create mode 100644 extension/src/components/popup/command-line.tsx create mode 100644 extension/src/components/popup/link.ts create mode 100644 extension/src/components/popup/note.tsx create mode 100644 extension/src/components/popup/segmented.tsx create mode 100644 extension/src/components/popup/tether.tsx create mode 100644 extension/src/components/ui/alert-dialog.tsx create mode 100644 extension/src/components/ui/button.tsx create mode 100644 extension/src/components/ui/input.tsx create mode 100644 extension/src/components/ui/label.tsx create mode 100644 extension/src/i18n/index.ts create mode 100644 extension/src/i18n/strings.test.ts create mode 100644 extension/src/i18n/strings.ts create mode 100644 extension/src/popup/App.test.ts create mode 100644 extension/src/popup/App.tsx create mode 100644 extension/src/popup/format-duration.test.ts create mode 100644 extension/src/popup/format-duration.ts create mode 100644 extension/src/popup/pairing-draft.test.ts create mode 100644 extension/src/popup/pairing-draft.ts create mode 100644 extension/src/popup/preferences.test.ts create mode 100644 extension/src/popup/preferences.ts create mode 100644 extension/src/popup/storage.ts diff --git a/extension/package.json b/extension/package.json index 7e46eed..bb43123 100644 --- a/extension/package.json +++ b/extension/package.json @@ -27,6 +27,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.3.0", "@types/node": "^26.6.3", "@types/react": "^19.3.0", @@ -37,6 +40,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17.12.0", + "jsdom": "^25.0.1", "prettier": "^3.9.9", "typescript": "^6.0.3", "typescript-eslint": "^8.70.1", diff --git a/extension/pnpm-lock.yaml b/extension/pnpm-lock.yaml index 9b79f64..dde1683 100644 --- a/extension/pnpm-lock.yaml +++ b/extension/pnpm-lock.yaml @@ -45,6 +45,15 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0(jiti@2.7.0)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/chrome': specifier: ^0.3.0 version: 0.3.0 @@ -75,6 +84,9 @@ importers: globals: specifier: ^17.12.0 version: 17.12.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 prettier: specifier: ^3.9.9 version: 3.9.9 @@ -89,10 +101,16 @@ importers: version: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) vitest: specifier: ^5.0.2 - version: 5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) + version: 5.0.2(@types/node@26.6.3)(jsdom@25.0.1)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -259,6 +277,34 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -596,9 +642,43 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -743,6 +823,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -777,9 +861,20 @@ packages: resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -788,6 +883,9 @@ packages: resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -875,6 +973,10 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -927,14 +1029,25 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -948,6 +1061,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -979,10 +1095,18 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -991,6 +1115,12 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -1024,6 +1154,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1046,6 +1180,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1215,6 +1353,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1289,6 +1431,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hashery@1.5.1: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} @@ -1313,10 +1459,22 @@ packages: hookified@2.2.0: resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1325,6 +1483,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -1345,6 +1507,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1406,6 +1572,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1458,6 +1627,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1667,6 +1845,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1675,6 +1856,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1704,10 +1889,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -1724,6 +1917,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1758,6 +1955,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1837,6 +2037,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1907,6 +2110,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -1947,6 +2154,9 @@ packages: peerDependencies: react: ^19.3.0 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -1955,6 +2165,10 @@ packages: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1983,6 +2197,12 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -1993,6 +2213,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -2110,6 +2334,13 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.33.13: resolution: {integrity: sha512-GtyakkchTQKymM1WeAB652zi3rM1q00RKdc+jZe9KGstuc/mWUwAnQGN/RE5gVYvHIR8xYOUIUD9irVKDtA0wA==} engines: {node: '>=10.0.0'} @@ -2141,6 +2372,13 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2149,6 +2387,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2316,6 +2562,27 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2338,10 +2605,29 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@1.0.0: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2373,6 +2659,16 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2596,6 +2892,26 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -2870,12 +3186,49 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.2)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 minimatch: 10.2.6 path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3034,6 +3387,8 @@ snapshots: acorn@8.18.0: {} + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3062,14 +3417,24 @@ snapshots: ansi-regex@6.4.0: {} + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} ast-types@0.16.3: dependencies: tslib: 2.8.1 + asynckit@0.4.0: {} + atomically@1.7.0: {} balanced-match@4.0.4: {} @@ -3154,6 +3519,10 @@ snapshots: code-block-writer@13.0.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@11.1.0: {} commander@14.0.3: {} @@ -3203,10 +3572,22 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -3215,6 +3596,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -3232,12 +3615,20 @@ snapshots: define-lazy-prop@3.0.0: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} diff@8.0.4: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -3268,6 +3659,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -3284,6 +3677,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -3523,6 +3923,14 @@ snapshots: flatted@3.4.4: {} + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3587,6 +3995,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hashery@1.5.1: dependencies: hookified: 1.15.1 @@ -3607,6 +4019,10 @@ snapshots: hookified@2.2.0: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3615,10 +4031,28 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} human-signals@8.0.1: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -3634,6 +4068,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} ip-address@10.7.2: {} @@ -3668,6 +4104,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regexp@3.1.0: {} @@ -3702,6 +4140,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -3851,6 +4317,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3859,6 +4327,8 @@ snapshots: dependencies: react: 19.3.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3882,8 +4352,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -3894,6 +4370,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 @@ -3921,6 +4399,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + nwsapi@2.2.28: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4012,6 +4492,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -4057,6 +4541,12 @@ snapshots: prettier@3.9.9: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -4098,6 +4588,8 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-is@17.0.2: {} + react@19.3.0: {} recast@0.23.21: @@ -4108,6 +4600,11 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-from-string@2.0.2: {} reselect@5.3.0: {} @@ -4152,6 +4649,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -4160,6 +4661,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.28.0: {} semver@6.3.1: {} @@ -4317,6 +4822,12 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + systeminformation@5.33.13: {} tailwind-merge@3.7.0: {} @@ -4336,12 +4847,26 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -4426,7 +4951,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)): + vitest@5.0.2(@types/node@26.6.3)(jsdom@25.0.1)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) @@ -4444,9 +4969,27 @@ snapshots: why-is-node-running: 3.2.2 optionalDependencies: '@types/node': 26.6.3 + jsdom: 25.0.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4461,11 +5004,17 @@ snapshots: wrappy@1.0.2: {} + ws@8.22.0: {} + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/extension/src/components/popup/command-line.tsx b/extension/src/components/popup/command-line.tsx new file mode 100644 index 0000000..c71db56 --- /dev/null +++ b/extension/src/components/popup/command-line.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const COPY_FEEDBACK_MS = 1200; + +export function CopyButton({ text, label }: { text: string; label: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +export function CommandLine({ cmd, copyLabel }: { cmd: string; copyLabel: string }) { + const mono = "font-[family-name:var(--font-mono)]"; + return ( +
+ {cmd} + +
+ ); +} diff --git a/extension/src/components/popup/link.ts b/extension/src/components/popup/link.ts new file mode 100644 index 0000000..92f4ac8 --- /dev/null +++ b/extension/src/components/popup/link.ts @@ -0,0 +1,18 @@ +import type { ConnectionStatus } from "@/shared/state"; + +// 连线的样式表示状态(docs/specs 扩展弹窗): +// 绿色实线=已连接,琥珀色流动虚线=重连中,红色长虚线=认证被拒,灰色稀疏点线=未配对(含配对中/配对失败/连不上)。 +export type Link = "idle" | "ok" | "warn" | "bad"; + +export function linkFor(status: ConnectionStatus): Link { + switch (status) { + case "reconnecting": + return "warn"; + case "rejected": + return "bad"; + case "connected": + return "ok"; + default: + return "idle"; + } +} diff --git a/extension/src/components/popup/note.tsx b/extension/src/components/popup/note.tsx new file mode 100644 index 0000000..4edf8b2 --- /dev/null +++ b/extension/src/components/popup/note.tsx @@ -0,0 +1,37 @@ +import type { ComponentType, ReactNode, SVGProps } from "react"; +import { cn } from "@/lib/utils"; + +// 错误提示用 alert 角色;非致命提示(如重连中)用 status,两者都是 docs/specs 的无障碍要求。 +export function Note({ + tone, + icon: Icon, + title, + children, + className, +}: { + tone: "warn" | "bad"; + icon: ComponentType>; + title: string; + children: ReactNode; + className?: string; +}) { + const color = tone === "bad" ? "var(--bad)" : "var(--warn)"; + return ( + // role=alert 用于错误提示(docs/specs 无障碍要求);非致命提示(如重连倒计时)不设 live-region + // 角色,避免倒计时每秒变化都触发朗读——连接状态本身已经由 Tether 的 polite 区域播报一次。 +
+ +
+ {title} + {children} +
+
+ ); +} diff --git a/extension/src/components/popup/segmented.tsx b/extension/src/components/popup/segmented.tsx new file mode 100644 index 0000000..c83d034 --- /dev/null +++ b/extension/src/components/popup/segmented.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/lib/utils"; + +export function Segmented({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: [T, string][]; + onChange: (value: T) => void; +}) { + return ( +
+ {options.map(([v, l]) => ( + + ))} +
+ ); +} diff --git a/extension/src/components/popup/tether.tsx b/extension/src/components/popup/tether.tsx new file mode 100644 index 0000000..046b565 --- /dev/null +++ b/extension/src/components/popup/tether.tsx @@ -0,0 +1,94 @@ +import { Globe, Loader2, SquareTerminal } from "lucide-react"; +import type { ConnectionStatus } from "@/shared/state"; +import type { Strings } from "@/i18n"; +import { cn } from "@/lib/utils"; +import { linkFor, type Link } from "./link"; + +function statusKey(status: ConnectionStatus): keyof Strings["status"] { + const link = linkFor(status); + if (status === "pairing") { + return "pairing"; + } + if (link === "idle") { + return "unpaired"; + } + if (link === "warn") { + return "reconnecting"; + } + if (link === "bad") { + return "rejected"; + } + return "connected"; +} + +const LINK_COLOR: Record = { + idle: "var(--muted-foreground)", + ok: "var(--ok)", + warn: "var(--warn)", + bad: "var(--bad)", +}; + +const LINK_DASH: Record = { + idle: "2 6", + ok: undefined, + warn: "5 5", + bad: "10 14", +}; + +export function Tether({ + status, + s, + name, + address, +}: { + status: ConnectionStatus; + s: Strings; + name: string; + address: string; +}) { + const link = linkFor(status); + const paired = link !== "idle"; + const color = LINK_COLOR[link]; + const mono = "font-[family-name:var(--font-mono)]"; + return ( +
+
+ + {paired ? ( + {name} + ) : ( + {s.thisBrowser} + )} +
+
+ + {/* polite 实时区域:连接状态变化在这里播报(docs/specs 无障碍要求)。 */} + + {status === "pairing" && } + {s.status[statusKey(status)]} + +
+
+ + {address} +
+
+ ); +} diff --git a/extension/src/components/ui/alert-dialog.tsx b/extension/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..3b388a3 --- /dev/null +++ b/extension/src/components/ui/alert-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import * as React from "react"; +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { cn } from "@/lib/utils"; + +import { Button } from "@/components/ui/button"; + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return ; +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ; +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return + {s.settings} + + ) : ( +
+ +
+
{s.product}
+
{s.tagline}
+
+ +
+ )} + + {view === "settings" ? ( +
+
+ {s.appearance} + onPrefsChange({ ...prefs, appearance: v })} + options={[ + ["system", s.system], + ["light", s.light], + ["dark", s.dark], + ]} + /> +
+
+ {s.language} + onPrefsChange({ ...prefs, language: v })} + options={[ + ["browser", s.followBrowser], + ["zh", "中文"], + ["en", "English"], + ]} + /> + {s.applyNow} +
+
+
+ + { + setAddressDraft(e.target.value); + setAddressInvalid(false); + }} + spellCheck={false} + aria-invalid={addressInvalid || undefined} + className={mono} + /> + {addressInvalid && ( + + {s.addrInvalid} + + )} + {s.addrHint} + +
+
+ ) : ( +
+ + + {showPairingForm && ( + <> +
+ {s.step1} + +
+
+ + onCodeChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submitPair(); + }} + placeholder="XXXX-XXXX" + spellCheck={false} + autoComplete="off" + disabled={busy} + aria-invalid={codeInvalid || state.status === "pair-failed" || undefined} + className={`h-11 text-center text-lg tracking-[0.3em] ${mono}`} + /> +
+ {codeInvalid && ( + + {s.invalidCodeHint} + + )} + {state.status === "pair-failed" && state.reason === "code-rejected" && ( + + {s.pairFailedBody} + + )} + {state.status === "pair-failed" && state.reason === "name-taken" && ( + + {s.nameTakenBody(state.name)} + + )} + {state.status === "pair-unreachable" && ( + + {s.unreachableBody(state.address)} + + )} + +

{s.codeHint}

+ + )} + + {state.status === "reconnecting" && ( + + {state.retryAt === null + ? s.reconnectingNow + : s.reconnectBody( + state.attempt + 1, + Math.max(0, Math.ceil((state.retryAt - now()) / 1000)), + state.address, + )} + + )} + {state.status === "rejected" && !pairAgainActive && ( + + {s.rejectedBody} + + )} + + {paired && ( + <> + {view === "renaming" || view === "rename-conflict" ? ( +
+ +
+ { + setRenameDraft( + e.target.value + .toLowerCase() + .replace(/[^a-z0-9-]/g, "") + .slice(0, 32), + ); + if (view === "rename-conflict") setViewMode("renaming"); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && renameDraft) void saveRename(); + if (e.key === "Escape") cancelRename(); + }} + disabled={renameBusy} + aria-invalid={view === "rename-conflict" || undefined} + aria-describedby="rename-hint" + className={mono} + /> + + +
+

+ {view === "rename-conflict" ? s.nameTaken(renameDraft) : s.nameHint} +

+
+ ) : ( +
+ {s.instanceName} + +
+ )} + +
+
{s.id}
+
+ {state.instanceId.slice(0, 13)}… + +
+
{s.since}
+
+ {state.status === "connected" && connectedSince !== null + ? formatConnectedSince(s, connectedSince, now()) + : "—"} +
+
+ + {state.status === "connected" && ( +
+ {s.useHint} + +
+ )} + {state.status === "reconnecting" && ( + + )} + {state.status === "rejected" && !pairAgainActive && ( + + )} + + )} +
+ )} + + {view !== "settings" && ( +
+ + + {paired ? ( + + ) : ( + {chrome.runtime.getManifest().version} + )} +
+ )} + + + + + {s.forgetTitle} + + {s.forgetBefore} + + {s.forgetCommand(state.name)} + + {s.forgetAfter} + + + + {s.cancel} + void confirmForget()} + > + {s.forget} + + + + +
+ ); +} diff --git a/extension/src/popup/format-duration.test.ts b/extension/src/popup/format-duration.test.ts new file mode 100644 index 0000000..30aa087 --- /dev/null +++ b/extension/src/popup/format-duration.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { formatConnectedSince } from "./format-duration"; +import { stringsFor } from "@/i18n"; + +const s = stringsFor("en"); + +describe("formatConnectedSince", () => { + it("says 'just now' for less than a minute", () => { + expect(formatConnectedSince(s, 1000, 1000 + 30_000)).toBe("just now"); + }); + + it("formats whole minutes under an hour", () => { + expect(formatConnectedSince(s, 0, 5 * 60_000)).toBe("5 min"); + }); + + it("formats whole hours at and beyond 60 minutes", () => { + expect(formatConnectedSince(s, 0, 90 * 60_000)).toBe("1 h"); + }); +}); diff --git a/extension/src/popup/format-duration.ts b/extension/src/popup/format-duration.ts new file mode 100644 index 0000000..f3dde2c --- /dev/null +++ b/extension/src/popup/format-duration.ts @@ -0,0 +1,14 @@ +import type { Strings } from "@/i18n"; + +// ConnectionState 不带「已连接起始时间」,只能用本地观察到 status 变为 connected 的那一刻做近似 +// (弹窗每次打开都会重新计算,短于实际连接时长;已知限制,见任务报告)。 +export function formatConnectedSince(s: Strings, sinceMs: number, nowMs: number): string { + const elapsedMinutes = Math.floor(Math.max(0, nowMs - sinceMs) / 60000); + if (elapsedMinutes < 1) { + return s.sinceJustNow; + } + if (elapsedMinutes < 60) { + return s.sinceMinutes(elapsedMinutes); + } + return s.sinceHours(Math.floor(elapsedMinutes / 60)); +} diff --git a/extension/src/popup/main.tsx b/extension/src/popup/main.tsx index 8618737..c64949f 100644 --- a/extension/src/popup/main.tsx +++ b/extension/src/popup/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@/styles/globals.css"; +import { App } from "./App"; const root = document.getElementById("root"); if (!root) { @@ -9,6 +10,6 @@ if (!root) { createRoot(root).render( -
sctl Browser
+
, ); diff --git a/extension/src/popup/pairing-draft.test.ts b/extension/src/popup/pairing-draft.test.ts new file mode 100644 index 0000000..e6df392 --- /dev/null +++ b/extension/src/popup/pairing-draft.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { clearPairingDraft, loadPairingDraft, savePairingDraft } from "./pairing-draft"; +import type { SessionStorage } from "./storage"; + +function fakeSession(initial: Record = {}): SessionStorage & { data: Record } { + const data = { ...initial }; + return { + data, + get: (keys) => Promise.resolve(Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]))), + set: (items) => { + Object.assign(data, items); + return Promise.resolve(); + }, + remove: (keys) => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + delete data[key]; + } + return Promise.resolve(); + }, + }; +} + +describe("pairing code draft", () => { + it("restores an empty draft when nothing was saved", async () => { + await expect(loadPairingDraft(fakeSession())).resolves.toBe(""); + }); + + it("restores a previously saved draft", async () => { + const storage = fakeSession({ pairingCodeDraft: "K7QM-3XRD" }); + await expect(loadPairingDraft(storage)).resolves.toBe("K7QM-3XRD"); + }); + + it("round-trips through save and load", async () => { + const storage = fakeSession(); + await savePairingDraft(storage, "AB12-CD34"); + await expect(loadPairingDraft(storage)).resolves.toBe("AB12-CD34"); + }); + + it("clears the draft once the code is emptied", async () => { + const storage = fakeSession({ pairingCodeDraft: "AB12-CD34" }); + await savePairingDraft(storage, ""); + await expect(loadPairingDraft(storage)).resolves.toBe(""); + }); + + it("clears the draft explicitly after a successful pairing", async () => { + const storage = fakeSession({ pairingCodeDraft: "AB12-CD34" }); + await clearPairingDraft(storage); + await expect(loadPairingDraft(storage)).resolves.toBe(""); + }); +}); diff --git a/extension/src/popup/pairing-draft.ts b/extension/src/popup/pairing-draft.ts new file mode 100644 index 0000000..0d6e319 --- /dev/null +++ b/extension/src/popup/pairing-draft.ts @@ -0,0 +1,23 @@ +import type { SessionStorage } from "./storage"; + +// 弹窗一失去焦点就会关闭,用户输入的配对码存进会话存储(chrome.storage.session), +// 重新打开弹窗时恢复(docs/specs 扩展弹窗)。 +const KEY = "pairingCodeDraft"; + +export async function loadPairingDraft(storage: SessionStorage): Promise { + const items = await storage.get([KEY]); + const value = items[KEY]; + return typeof value === "string" ? value : ""; +} + +export async function savePairingDraft(storage: SessionStorage, code: string): Promise { + if (!code) { + await storage.remove(KEY); + return; + } + await storage.set({ [KEY]: code }); +} + +export async function clearPairingDraft(storage: SessionStorage): Promise { + await storage.remove(KEY); +} diff --git a/extension/src/popup/preferences.test.ts b/extension/src/popup/preferences.test.ts new file mode 100644 index 0000000..bc0c286 --- /dev/null +++ b/extension/src/popup/preferences.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_PREFS, isDarkMode, loadPrefs, savePrefs } from "./preferences"; +import type { KeyValueStorage } from "./storage"; + +function fakeStorage(initial: Record = {}): KeyValueStorage & { data: Record } { + const data = { ...initial }; + return { + data, + get: (keys) => Promise.resolve(Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]))), + set: (items) => { + Object.assign(data, items); + return Promise.resolve(); + }, + }; +} + +describe("loadPrefs", () => { + it("defaults to following the system and the browser when nothing is stored", async () => { + await expect(loadPrefs(fakeStorage())).resolves.toEqual(DEFAULT_PREFS); + }); + + it("returns the stored appearance and language", async () => { + const storage = fakeStorage({ appearance: "dark", language: "zh" }); + await expect(loadPrefs(storage)).resolves.toEqual({ appearance: "dark", language: "zh" }); + }); + + it("ignores corrupted values and falls back to defaults", async () => { + const storage = fakeStorage({ appearance: "purple", language: 42 }); + await expect(loadPrefs(storage)).resolves.toEqual(DEFAULT_PREFS); + }); +}); + +describe("savePrefs", () => { + it("persists both fields so a later loadPrefs sees them", async () => { + const storage = fakeStorage(); + await savePrefs(storage, { appearance: "light", language: "en" }); + await expect(loadPrefs(storage)).resolves.toEqual({ appearance: "light", language: "en" }); + }); +}); + +describe("isDarkMode", () => { + it("is dark when appearance is explicitly dark, regardless of the system", () => { + expect(isDarkMode("dark", false)).toBe(true); + }); + + it("is light when appearance is explicitly light, regardless of the system", () => { + expect(isDarkMode("light", true)).toBe(false); + }); + + it("follows the system preference when appearance is 'system'", () => { + expect(isDarkMode("system", true)).toBe(true); + expect(isDarkMode("system", false)).toBe(false); + }); +}); diff --git a/extension/src/popup/preferences.ts b/extension/src/popup/preferences.ts new file mode 100644 index 0000000..227d264 --- /dev/null +++ b/extension/src/popup/preferences.ts @@ -0,0 +1,37 @@ +import type { Appearance, LangPref } from "@/i18n"; +import type { KeyValueStorage } from "./storage"; + +export interface Prefs { + appearance: Appearance; + language: LangPref; +} + +// 外观和语言默认都跟随系统和浏览器(docs/specs 设计决策 13)。 +export const DEFAULT_PREFS: Prefs = { appearance: "system", language: "browser" }; + +const APPEARANCES: readonly Appearance[] = ["system", "light", "dark"]; +const LANG_PREFS: readonly LangPref[] = ["browser", "zh", "en"]; + +function isAppearance(value: unknown): value is Appearance { + return typeof value === "string" && (APPEARANCES as readonly string[]).includes(value); +} + +function isLangPref(value: unknown): value is LangPref { + return typeof value === "string" && (LANG_PREFS as readonly string[]).includes(value); +} + +export async function loadPrefs(storage: KeyValueStorage): Promise { + const items = await storage.get(["appearance", "language"]); + return { + appearance: isAppearance(items.appearance) ? items.appearance : DEFAULT_PREFS.appearance, + language: isLangPref(items.language) ? items.language : DEFAULT_PREFS.language, + }; +} + +export async function savePrefs(storage: KeyValueStorage, prefs: Prefs): Promise { + await storage.set({ appearance: prefs.appearance, language: prefs.language }); +} + +export function isDarkMode(appearance: Appearance, systemPrefersDark: boolean): boolean { + return appearance === "dark" || (appearance === "system" && systemPrefersDark); +} diff --git a/extension/src/popup/storage.ts b/extension/src/popup/storage.ts new file mode 100644 index 0000000..bd0c2ce --- /dev/null +++ b/extension/src/popup/storage.ts @@ -0,0 +1,23 @@ +// chrome.storage 的最小子集,弹窗只需要 get/set(会话存储还需要 remove)。两者的形状与 +// src/background/controller.ts 的 StorageLike 一致,chrome.storage.local/session 原生满足它们。 +export interface KeyValueStorage { + get(keys: string[]): Promise>; + set(items: Record): Promise; +} + +export interface SessionStorage extends KeyValueStorage { + remove(keys: string | string[]): Promise; +} + +// 用方法转发而不是直接导出 chrome.storage.local/session:后者在模块求值时就会读取全局 chrome, +// 会在任何测试有机会用 vi.stubGlobal 注入假对象之前抛错;转发到调用时才访问,模块本身可以安全导入。 +export const chromeLocalStorage: KeyValueStorage = { + get: (keys) => chrome.storage.local.get(keys), + set: (items) => chrome.storage.local.set(items), +}; + +export const chromeSessionStorage: SessionStorage = { + get: (keys) => chrome.storage.session.get(keys), + set: (items) => chrome.storage.session.set(items), + remove: (keys) => chrome.storage.session.remove(keys), +}; diff --git a/extension/src/styles/globals.css b/extension/src/styles/globals.css index 40d961a..a4190ec 100644 --- a/extension/src/styles/globals.css +++ b/extension/src/styles/globals.css @@ -33,47 +33,59 @@ --radius-xl: calc(var(--radius) * 1.4); } +/* + * 同门配色:以 ScriptCat 的天蓝色为基础(docs/specs 扩展弹窗「视觉规范」表,浅色/深色各一套)。 + * 浅色主色 #1296DB、深色主色 #3AA9E6,其余数值均取自同一张表。 + */ :root { --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --background: #ffffff; + --foreground: #1b2733; + --card: #ffffff; + --card-foreground: #1b2733; + --popover: #ffffff; + --popover-foreground: #1b2733; + --primary: #1296db; + --primary-foreground: #ffffff; + --secondary: #f2f7fb; + --secondary-foreground: #1b2733; + --muted: #f2f7fb; + --muted-foreground: #5c6b7a; + --accent: #f2f7fb; + --accent-foreground: #1b2733; + --destructive: #d6453d; + --border: #dce5ed; + --input: #dce5ed; + --ring: #1296db; + --surface: #f2f7fb; + --ok: #16a06a; + --warn: #d48806; + --bad: #d6453d; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: #131a21; + --foreground: #e4ecf3; + --card: #131a21; + --card-foreground: #e4ecf3; + --popover: #131a21; + --popover-foreground: #e4ecf3; + --primary: #3aa9e6; + --primary-foreground: #0a1622; + --secondary: #1a232c; + --secondary-foreground: #e4ecf3; + --muted: #1a232c; + --muted-foreground: #8c9ba9; + --accent: #1a232c; + --accent-foreground: #e4ecf3; + --destructive: #f06a5f; + --border: #283440; + --input: #283440; + --ring: #3aa9e6; + --surface: #1a232c; + --ok: #3cc98a; + --warn: #e9a93a; + --bad: #f06a5f; } @layer base { @@ -87,3 +99,23 @@ @apply bg-background text-foreground; } } + +/* 重连时流动的连线:琥珀色虚线沿方向流动,表示状态而不仅是颜色。 */ +@keyframes tether-flow { + to { + stroke-dashoffset: -20; + } +} +.tether-flow { + animation: tether-flow 1.2s linear infinite; +} + +/* 两个动画(流动连线、配对中的转圈)在「减少动态效果」时停止(docs/specs 无障碍要求)。 */ +@media (prefers-reduced-motion: reduce) { + .tether-flow { + animation: none; + } + .animate-spin { + animation: none; + } +} From 241d6d599da08e19182b75550c061aae328c4f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 22:25:05 +0800 Subject: [PATCH 13/25] docs: cover the sctl Browser extension across architecture, AGENTS, and user docs architecture.md now documents the second extension kind, simultaneous browser instances, routing by method ownership, the extension/ directory, and the current store/ contents (browsers.json plus per-instance keys, not just K). AGENTS.md scopes "the authority always lives on the extension side" to ScriptCat's write/source-disclosure gates and calls out that browser control has no human gate by design. README.md, docs/README_zh-CN.md, and docs/mcp.md add installing and pairing the sctl Browser extension and the new browsers/tabs/windows commands and MCP tools, cross-linking to the owning docs instead of duplicating details. --- AGENTS.md | 10 +++++++--- README.md | 22 ++++++++++++++++++---- docs/README_zh-CN.md | 24 ++++++++++++++++++------ docs/architecture.md | 33 ++++++++++++++++++++++++++------- docs/mcp.md | 43 +++++++++++++++++++++++++++++++++---------- 5 files changed, 102 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9923376..bdbffab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,15 @@ framework and [cobra](https://github.com/spf13/cobra). ```text sctl mcp / CLI verbs ──/control/* HTTP──▶ sctl serve (daemon) ──WS──▶ ScriptCat extension (approval authority) -internal/client/ internal/daemon/ internal/pkg/ (shared by both sides) +internal/client/ internal/daemon/ ├─▶ sctl Browser extension (1+ paired instances) + internal/pkg/ (shared by both sides) ``` -The authority always lives on the extension side: the daemon approves no write on its own — it forwards the -request and blocks until a human decides in the browser. Full process model and package responsibilities are +The authority always lives on the extension side for ScriptCat's write and source-disclosure gates: the daemon +approves no write and discloses no source on its own — it forwards the request and blocks until a human +decides in the browser. Browser control (`sctl browsers`/`tabs`/`windows`) is the deliberate exception: it has +no human gate by design, so any control-token holder can drive a paired `sctl Browser` instance immediately — +see [`docs/threat-model.md`](./docs/threat-model.md). Full process model and package responsibilities are in [`docs/architecture.md`](./docs/architecture.md). ## Engineering Principles diff --git a/README.md b/README.md index f1aabbc..27851b4 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ [English](./README.md) | [简体中文](./docs/README_zh-CN.md) sctl connects AI clients and command-line workflows to the -[ScriptCat](https://github.com/scriptscat/scriptcat) browser extension. One cross-platform binary provides a -local bridge daemon, a stdio MCP server, and script-management commands. +[ScriptCat](https://github.com/scriptscat/scriptcat) browser extension and to its own **sctl Browser** browser +extension. One cross-platform binary provides a local bridge daemon, a stdio MCP server, and script-management +and browser-control commands. ```text AI client ── stdio MCP ──▶ sctl mcp ── local control API ──▶ sctl serve ── WebSocket ──▶ ScriptCat @@ -16,9 +17,10 @@ confirmation UI in the extension. ## Features -- Exposes ScriptCat operations as discoverable, schema-typed MCP tools. +- Exposes ScriptCat operations and browser tab/window control as discoverable, schema-typed MCP tools. - Lists scripts and reads metadata or source, including line windows and source search. - Requests installation, content-anchored editing, enable/disable, and deletion through browser approval. +- Lists, opens, closes, and activates tabs and lists windows across one or more paired sctl Browser instances. - Uses JSON-RPC 2.0 over a WebSocket with mutual authentication; the listener defaults to loopback. - Ships as one binary; no browser automation or Native Messaging host is required. @@ -60,6 +62,13 @@ sctl status ``` Enable **External Access** in ScriptCat and enter the one-time code printed by `connect`. + +To also pair the **sctl Browser** extension (tab/window control), download +`sctl-browser-extension-.zip` from [GitHub Releases](https://github.com/scriptscat/sctl/releases), +unzip it, and load the unzipped folder as an unpacked extension from your browser's extensions page. Open its +popup and enter the same one-time code from `sctl connect`. Full steps, including the browser's "developer +mode" toggle, are in [`docs/mcp.md`](./docs/mcp.md#4-enroll-scriptcat-and-sctl-browser). + Then configure the AI client to launch: ```text @@ -85,9 +94,14 @@ troubleshooting. | `sctl edit ` | Request a content-anchored source edit. | | `sctl enable ` / `sctl disable ` | Request an enabled-state change. | | `sctl delete ` | Request script deletion. | +| `sctl browsers [list]` / `sctl browsers forget ` | List paired sctl Browser instances, or forget one. | +| `sctl tabs list\|open\|close\|activate` | List, open, close, or activate tabs on a paired sctl Browser instance. | +| `sctl windows list` | List windows on a paired sctl Browser instance. | Run `sctl --help` or `sctl --help` for usage and flags. Write operations block -until the user approves, rejects, or closes the confirmation flow in ScriptCat. +until the user approves, rejects, or closes the confirmation flow in ScriptCat; browser control commands run +immediately with no approval step (see [`docs/threat-model.md`](./docs/threat-model.md)). `tabs` and `windows` +accept `--browser ` (or `SCTL_BROWSER`) to pick an instance when more than one is paired. ## License diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index a3503c6..04eba41 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -3,8 +3,8 @@ [English](../README.md) | [简体中文](./README_zh-CN.md) sctl 用于将 AI 客户端和命令行工作流连接到 -[ScriptCat](https://github.com/scriptscat/scriptcat) 浏览器扩展。单个跨平台二进制同时提供本地桥接 -daemon、stdio MCP Server 和脚本管理命令。 +[ScriptCat](https://github.com/scriptscat/scriptcat) 浏览器扩展,以及它自己的 **sctl Browser** +浏览器扩展。单个跨平台二进制同时提供本地桥接 daemon、stdio MCP Server、脚本管理命令和浏览器控制命令。 ```text AI 客户端 ── stdio MCP ──▶ sctl mcp ── 本地控制 API ──▶ sctl serve ── WebSocket ──▶ ScriptCat @@ -15,9 +15,10 @@ CLI ───────────────────────── ## 功能 -- 将 ScriptCat 操作暴露为可发现、具有 Schema 类型的 MCP 工具。 +- 将 ScriptCat 操作与浏览器标签页/窗口控制暴露为可发现、具有 Schema 类型的 MCP 工具。 - 列出脚本并读取元数据或源码,支持按行读取和源码搜索。 - 通过浏览器确认请求安装、基于内容锚点的编辑、启用/禁用和删除。 +- 在一个或多个已配对的 sctl Browser 实例上列出、打开、关闭、激活标签页,以及列出窗口。 - 在仅监听回环地址的 WebSocket 上使用 JSON-RPC 2.0 和双向认证。 - 单二进制交付,不依赖浏览器自动化或 Native Messaging Host。 @@ -56,8 +57,14 @@ sctl connect sctl status ``` -在 ScriptCat 中启用**外部接入**,然后输入 `connect` 打印的一次性配对码。随后将 AI -客户端配置为启动: +在 ScriptCat 中启用**外部接入**,然后输入 `connect` 打印的一次性配对码。 + +若还要配对 **sctl Browser** 扩展(标签页/窗口控制),从 [GitHub Releases](https://github.com/scriptscat/sctl/releases) +下载 `sctl-browser-extension-.zip` 并解压,在浏览器的扩展管理页把解压后的目录作为"已解压的扩展程序"加载, +打开其弹窗并输入 `sctl connect` 打印的同一个一次性配对码。完整步骤(含浏览器的"开发者模式"开关)见 +[`mcp.md`](./mcp.md#4-enroll-scriptcat-and-sctl-browser)(英文)。 + +随后将 AI 客户端配置为启动: ```text /absolute/path/to/sctl mcp --name my-ai-client @@ -81,9 +88,14 @@ sctl status | `sctl edit ` | 请求基于内容锚点编辑源码。 | | `sctl enable ` / `sctl disable ` | 请求修改启用状态。 | | `sctl delete ` | 请求删除脚本。 | +| `sctl browsers [list]` / `sctl browsers forget ` | 列出已配对的 sctl Browser 实例,或忘记其中一个。 | +| `sctl tabs list\|open\|close\|activate` | 在已配对的 sctl Browser 实例上列出、打开、关闭或激活标签页。 | +| `sctl windows list` | 列出已配对的 sctl Browser 实例上的窗口。 | 运行 `sctl --help` 或 `sctl --help` 查看用法和参数。写操作会阻塞,直到用户在 -ScriptCat 中批准、拒绝或关闭确认流程。 +ScriptCat 中批准、拒绝或关闭确认流程;浏览器控制命令按设计没有审批步骤、立即执行(参见 +[`threat-model.md`](./threat-model.md))。当配对了多个实例时,`tabs` 与 `windows` 可用 +`--browser `(或环境变量 `SCTL_BROWSER`)指定目标实例。 ## 许可证 diff --git a/docs/architecture.md b/docs/architecture.md index 5fa50c4..23b53f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,21 +4,29 @@ ```text MCP client (Claude/Codex…) ─ stdio ─→ sctl mcp ─┐ (authenticated control API) -CLI verbs (sctl get / edit / install …)─────────┤ +CLI verbs (sctl get / edit / install / browsers / tabs / windows …) ┤ ▼ sctl serve (daemon; defaults to 127.0.0.1:8643) - ▲ WebSocket (extension dials in + mutual HMAC handshake) - ScriptCat browser extension (authority for approval and authorization) + ▲ WebSocket (each extension dials in + mutual HMAC handshake) + ScriptCat browser extension (authority for write approval and source disclosure) + sctl Browser extension, one or more paired instances (tab/window control) ``` `sctl mcp` and the CLI verbs are **separate processes** from `sctl serve`. They talk over the -`/control/*` HTTP/JSON control API on the daemon's listener — same port as the extension's WS surface, separate +`/control/*` HTTP/JSON control API on the daemon's listener — same port as the extensions' WS surface, separate path, authenticated with the control token described in [threat-model.md](./threat-model.md#4-the-control-channel-internal-local-connection-in-detail). Frontends never start `serve`: run it explicitly in the foreground or let an external system service manager own its lifecycle. -The authority always lives on the extension side: the daemon approves no write on its own — it forwards the -request and blocks until a human decides in the browser. Details in [threat-model.md](./threat-model.md). +The daemon accepts two kinds of extension connection at once: exactly one active ScriptCat connection (a new +one replaces the previous one), and any number of paired `sctl Browser` instances connecting simultaneously, +each identified by its own instance ID. A call is routed by which peer kind owns its method (scripts.\* to +ScriptCat, tabs.\*/windows.\* to a resolved browser instance) — see +[protocol.md](./protocol.md#31-routing-and-target-selection). For ScriptCat's methods, the daemon approves no +write on its own — it forwards the request and blocks until a human decides in the browser, and source reads +go through the same disclosure gate. Browser control methods carry no such human gate by design: any +control-token holder can drive a paired browser instance immediately. Details in +[threat-model.md](./threat-model.md). ## Directory layout @@ -37,6 +45,8 @@ internal/cli/ # subcommand definitions; spans both sides, hence to connect.go status.go version.go get.go grep.go # read verbs edit.go write.go # write verbs (edit / install / enable / disable / delete) + browsers.go # sctl browsers [list] / sctl browsers forget + tabs.go windows.go # sctl tabs list|open|close|activate, sctl windows list resource.go # the optional scripts|script|sc resource word shared by those verbs dispatch.go # action forwarding and bridge error → exit code mapping @@ -50,7 +60,9 @@ internal/daemon/ # ── sctl serve side ── envelope.go # envelope, payload structs, error codes controlapi/ # /control/* handlers (controller role), depends on the narrow Bridge interface auth/ # mutual HMAC handshake, enrollment-code derivation (HKDF), key delivery (AES-GCM) - store/ # persistence of the long-term key K (repository role) + store/ # persistence (repository role): ScriptCat's long-term key K, plus the + # browsers.json registry of paired sctl Browser instances and their own + # per-instance keys ratelimit/ # enrollment-attempt rate limiting internal/client/ # ── sctl mcp / CLI verb side ── @@ -64,6 +76,13 @@ internal/pkg/ # ── shared by both sides ── paths/ # data directory and derived paths logging/ # unified zap logging (stderr + /logs/*.log, never stdout) fsutil/ # atomic file writes + +extension/ # ── sctl Browser, the second extension kind (MV3, pnpm/Vite/React) ── + src/background/ # service worker: connection state, message routing to offscreen/popup + src/offscreen/ # holds the WebSocket, pairing and session handshake, retry/backoff + src/handlers/ # tabs.*/windows.* method implementations (chrome.tabs / chrome.windows) + src/popup/ # popup UI: pairing, rename, forget, daemon address + src/protocol/generated/ # browser-only generated protocol TS (see protocol.md §6) ``` ## Dependency direction diff --git a/docs/mcp.md b/docs/mcp.md index 537ee1c..812cae4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -97,29 +97,45 @@ sctl status If it reports that the daemon is unreachable, fix that before configuring an MCP client. -## 4. Enroll ScriptCat once +## 4. Enroll ScriptCat and sctl Browser -1. Open ScriptCat's options page and enable **External Access**. -2. Keep `sctl serve` running. -3. In another terminal, run: +ScriptCat and the **sctl Browser** extension (browser tab/window control; see +[architecture.md](./architecture.md) for how the daemon tells the two kinds of extension apart) each pair +independently, but both use the same one-time-code flow against the same running `sctl serve`: + +1. Keep `sctl serve` running. +2. In another terminal, run: ```bash sctl connect ``` -4. Enter the displayed one-time code in ScriptCat's External Access enrollment dialog. +3. Enter the displayed one-time code: + - For ScriptCat: open its options page, enable **External Access**, and enter the code in the enrollment + dialog. + - For sctl Browser: download `sctl-browser-extension-.zip` from + [GitHub Releases](https://github.com/scriptscat/sctl/releases) (its version matches the sctl release), + unzip it, open your browser's extensions page, enable developer mode, choose "Load unpacked", and select + the unzipped folder. Open the extension's popup and enter the same code. +4. Run `sctl connect` again for the second extension if you want to pair both — the code is valid only for a + short enrollment window, and either extension can consume it first. 5. Verify the connection: ```bash sctl status + sctl browsers ``` -The status output must say that the extension is connected. The one-time code is valid only for the enrollment -window and must not be pasted into an AI conversation, issue, log, or MCP configuration. After enrollment, the -extension and daemon use the persisted long-term pairing state; each AI client does not enroll separately. +`sctl status` reports whether ScriptCat is connected; `sctl browsers` lists every paired sctl Browser instance +with its online/offline state. The one-time code must not be pasted into an AI conversation, issue, log, or MCP +configuration. After enrollment, each extension and the daemon use their own persisted long-term pairing state; +each AI client does not enroll separately, and multiple sctl Browser instances can stay paired and connected at +the same time. -Disabling External Access in ScriptCat revokes the extension side of this relationship. Run `connect` again if -you intentionally revoke it and later want to reconnect. +Disabling External Access in ScriptCat, or removing the sctl Browser extension, revokes that extension's side +of the relationship. `sctl browsers forget ` removes a paired sctl Browser instance from the daemon +side and disconnects it if online. Run `connect` again if you intentionally revoke a pairing and later want to +reconnect. ## 5. Configure the MCP client @@ -179,6 +195,13 @@ Reading script source can open a source-disclosure prompt in ScriptCat. Installi disabling, and deleting scripts block until the user approves or rejects the operation in the browser. This is expected behavior, not an MCP timeout; the security model is detailed in [threat-model.md](./threat-model.md). +`sctl mcp` always also exposes `browsers_list`, `tabs_list`, `tabs_open`, `tabs_close`, `tabs_activate`, and +`windows_list`, whether or not an sctl Browser instance is paired; calling a tab/window tool with no browser +instance connected returns an error. Every tool except `browsers_list` accepts an optional `browser` argument +(name or instance-ID prefix) to pick a target when more than one instance is paired; `browsers_list` itself +lists the paired instances. Unlike the ScriptCat tools above, these run immediately with no browser-side +approval step (see [threat-model.md](./threat-model.md)). + ## Troubleshooting | Symptom | Check | From fafe52c22bcf3018a79a6c10e17cd3d4f879f6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 22:47:12 +0800 Subject: [PATCH 14/25] fix: align sctl Browser foundation with its spec - popup connected state shows browser, daemon version and the real connected-since time carried in ConnectionState, plus the instance name - pairing always registers the default name; the pairing-code draft is kept until pairing succeeds - merged list calls skip instances that answer NOT_FOUND (window IDs are per browser); forget of an unknown instance returns BROWSER_NOT_FOUND - CLI asks for --browser on BROWSER_AMBIGUOUS; offline browsers omit connectedAt in JSON - MCP browser tools describe list aggregation correctly and never report "waiting for approval"; tests cover browser argument forwarding - threat model states browser control has no human gate; Noto Sans CJK fallback; dark danger button text meets contrast --- AGENTS.md | 4 +- docs/architecture.md | 4 +- docs/mcp.md | 7 +- docs/protocol.md | 10 +- docs/threat-model.md | 14 +- extension/src/background/controller.test.ts | 13 ++ extension/src/background/controller.ts | 1 + extension/src/offscreen/connection.fixture.ts | 1 + extension/src/offscreen/connection.test.ts | 18 +++ extension/src/offscreen/connection.ts | 11 +- extension/src/popup/App.test.ts | 128 ++++++++++++++---- extension/src/popup/App.tsx | 70 ++++++---- extension/src/popup/format-duration.ts | 2 - extension/src/shared/messages.ts | 2 + extension/src/shared/state.ts | 4 +- extension/src/styles/globals.css | 7 +- internal/cli/browsers_test.go | 15 +- internal/cli/cli_test.go | 8 +- internal/cli/dispatch.go | 4 + internal/cli/tabs_test.go | 12 +- internal/client/control/protocol.go | 4 +- internal/client/mcpserver/mcpserver.go | 11 +- internal/client/mcpserver/mcpserver_test.go | 88 ++++++++++++ internal/client/mcpserver/tools.go | 23 +++- internal/daemon/bridge/call.go | 24 +++- internal/daemon/bridge/routing_test.go | 49 +++++++ internal/daemon/controlapi/controlapi.go | 2 +- internal/daemon/controlapi/controlapi_test.go | 4 +- 28 files changed, 437 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdbffab..30af69f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,8 @@ framework and [cobra](https://github.com/spf13/cobra). ```text sctl mcp / CLI verbs ──/control/* HTTP──▶ sctl serve (daemon) ──WS──▶ ScriptCat extension (approval authority) -internal/client/ internal/daemon/ ├─▶ sctl Browser extension (1+ paired instances) - internal/pkg/ (shared by both sides) + └─WS──▶ sctl Browser extension (1+ paired instances) +internal/client/ internal/daemon/ internal/pkg/ (shared by both sides) ``` The authority always lives on the extension side for ScriptCat's write and source-disclosure gates: the daemon diff --git a/docs/architecture.md b/docs/architecture.md index 23b53f4..41853ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,8 +78,8 @@ internal/pkg/ # ── shared by both sides ── fsutil/ # atomic file writes extension/ # ── sctl Browser, the second extension kind (MV3, pnpm/Vite/React) ── - src/background/ # service worker: connection state, message routing to offscreen/popup - src/offscreen/ # holds the WebSocket, pairing and session handshake, retry/backoff + src/background/ # service worker: identity and settings storage, message routing, method dispatch + src/offscreen/ # holds the WebSocket and connection state, pairing and session handshake, retry/backoff src/handlers/ # tabs.*/windows.* method implementations (chrome.tabs / chrome.windows) src/popup/ # popup UI: pairing, rename, forget, daemon address src/protocol/generated/ # browser-only generated protocol TS (see protocol.md §6) diff --git a/docs/mcp.md b/docs/mcp.md index 812cae4..080aab4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -197,9 +197,10 @@ expected behavior, not an MCP timeout; the security model is detailed in [threat `sctl mcp` always also exposes `browsers_list`, `tabs_list`, `tabs_open`, `tabs_close`, `tabs_activate`, and `windows_list`, whether or not an sctl Browser instance is paired; calling a tab/window tool with no browser -instance connected returns an error. Every tool except `browsers_list` accepts an optional `browser` argument -(name or instance-ID prefix) to pick a target when more than one instance is paired; `browsers_list` itself -lists the paired instances. Unlike the ScriptCat tools above, these run immediately with no browser-side +instance connected returns an error. Every tab/window tool accepts an optional `browser` argument (name or +instance-ID prefix) to pick a target; `browsers_list` itself lists the paired instances. Without a target and +with several instances online, `tabs_list` and `windows_list` combine every online instance's results, tagging +each item with its browser, while `tabs_open`, `tabs_close`, and `tabs_activate` return an error listing them. Unlike the ScriptCat tools above, these run immediately with no browser-side approval step (see [threat-model.md](./threat-model.md)). ## Troubleshooting diff --git a/docs/protocol.md b/docs/protocol.md index 31e4cfc..9313df2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -258,9 +258,11 @@ only if no name matches is it treated as an instance-ID prefix over every paired A combined call is sent to every online instance at once. The result is the first instance's result with its `mergeField` array replaced by the concatenation of every instance's array, in instance-name order, and each -item gains a `browser` object naming its source: `{"id": "", "name": ""}`. Any -failing instance fails the whole call; partial results are never returned. A call routed to a single instance -returns that instance's result unchanged. +item gains a `browser` object naming its source: `{"id": "", "name": ""}`. An +instance that answers `NOT_FOUND` — for example, it has no window with the requested ID — contributes no items; +the call fails with `NOT_FOUND` only when every instance answers it. Any other failing instance fails the whole +call; partial results are never returned. A call routed to a single instance returns that instance's result +unchanged. A target on a `scripts.*` call is rejected with `INVALID_REQUEST`; otherwise `scripts.*` routing and its errors do not depend on browser instances. If the target connection closes while a call is in flight — for a combined @@ -295,7 +297,7 @@ These codes are reserved for browser target selection failures ([§3.1](#31-rout |---|---| | `NO_BROWSER_CONNECTED` | a browser method was called while no browser instance is online | | `BROWSER_OFFLINE` | the target names a paired browser instance that is not connected | -| `BROWSER_NOT_FOUND` | the target matches no paired browser instance | +| `BROWSER_NOT_FOUND` | the target matches no paired browser instance; `/control/browsers/forget` returns it too when the name or ID matches none | | `BROWSER_AMBIGUOUS` | the target instance-ID prefix matches more than one paired instance, or a method without `mergeField` was called without a target while several instances are online | ## 5. Cancellation and approval diff --git a/docs/threat-model.md b/docs/threat-model.md index b0fa8cb..a4ad4ea 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -28,6 +28,11 @@ MCP; even if a malicious local process gets the control token and calls sctl, th press approve on the extension's confirmation page (unless the corresponding global policy is set to "always-allow"). +**Browser control has no human gate, by design.** The `sctl browsers` / `tabs` / `windows` commands and their +MCP tools are neither approved per operation nor limited to particular tabs: any holder of the control token can +list, open, close, and activate tabs and list windows in **every** paired sctl Browser instance, immediately. This +is a deliberate trade for low operating friction; the control token (same user on this host) is the only gate. + ## 2. Attack surface and countermeasures | Threat | Countermeasure | Residual risk | @@ -35,7 +40,7 @@ press approve on the extension's confirmation page (unless the corresponding glo | A web page connects straight to the daemon with `new WebSocket("ws://127.0.0.1:8643")` | An **Origin whitelist** rejects any connection whose `Origin` is present and not an extension origin (`chrome-extension://` etc.) — a cheap pre-filter that a browser page cannot get past (the browser stamps Origin, page JS cannot forge it). Beyond that, a connection must complete the mutual HMAC handshake before it can send or receive any business message; without credentials it fails the challenge-response and is disconnected on the 5s timeout **with no reason echoed back** (close 1008). A non-browser process can forge any Origin, so the handshake remains the real gate. Both rejections are recorded in the daemon-side audit (§6) | A page can probe that the port is open | | A web page impersonates the local frontend with `fetch("http://127.0.0.1:8643/control/…")` | Apart from `/control/health`, every control API requires an `X-Sctl-Control-Token` header, compared in constant time against the daemon's user-only token; a web page cannot read that file, so it gets a 401 and the action never runs at all | Port / health information can be probed (see below) | | A local process grabs 8643 to impersonate the daemon, or connects in while impersonating the extension | **Mutual** HMAC-SHA-256 challenge-response between the extension and the daemon ([protocol.md](./protocol.md#21-authentication)); long-term keys come from a one-time enrollment code and never travel in plaintext; nonces are regenerated per connection, so replays are useless. A browser instance's MAC also binds its peer kind and instance ID, so a recorded MAC cannot be replayed as ScriptCat or as another instance | See the "malicious same-user process" row | -| A process that reaches the daemon requests a privileged action | Flat trust deliberately grants any control-token holder full read/list and the ability to *request* writes; the gate is not per-client authorization but the **per-operation human gate**: writes need browser approval and source reads need disclosure approval, both keyed by script (extension session). There is no per-client scope or request-frequency limit | Any process that obtains the control token has the same capabilities; write requests remain browser-gated unless always-allow is enabled | +| A process that reaches the daemon requests a privileged action | Flat trust deliberately grants any control-token holder full read/list and the ability to *request* writes; the gate is not per-client authorization but the **per-operation human gate**: writes need browser approval and source reads need disclosure approval, both keyed by script (extension session). There is no per-client scope or request-frequency limit | Any process that obtains the control token has the same capabilities; write requests remain browser-gated unless always-allow is enabled, while browser control (tabs and windows in every paired sctl Browser instance) is not gated at all | | Write operations are abused (installing a malicious script / bulk deletion) | Two-phase confirmation plus a TOCTOU re-check at the moment of approval (staged `contentHash`, target `existingCodeHash`); calls are purely blocking, so a requester disconnect voids them. The install page's own enable toggle decides the enabled state (installs are usable immediately, like a normal install). "Always-allow" is an explicit security-downgrade switch (amber warning in the UI) | Under "always-allow" a write is no longer confirmed by a human — the user takes that risk | | Source code leaks | Source disclosure is gated by its own **source-read policy** (approval by default), applied to the CLI and MCP alike — the CLI is **not** exempt; reading is a privacy matter and is not covered by the write policy. Script-controlled text is always returned as structured data (`contentTrust: untrusted-user-script-source`) and must never be concatenated into a tool description | Under a "always-allow" source-read policy, reads are no longer confirmed — the user takes that risk | | The port's existence is found by scanning | Accepted: the extension is the client and cannot read a discovery file, so the default port 8643 has to be fixed; authentication is the backstop | The open port is visible | @@ -46,7 +51,7 @@ press approve on the extension's confirmation page (unless the corresponding glo - **A malicious local process with the user's full privileges**: it can read `pairing.key` / `browsers.json` / `control.token` (all 0600) and can ptrace this user's processes. No purely local scheme can stop it; browser-side human approval of write operations is the only mitigation still in effect (unless the user turned on - "always-allow"). + "always-allow"); browser control has no such gate, so it can drive every paired browser freely. - **Per-client isolation**: flat trust intentionally drops per-agent tokens, scopes, and individual revocation. Any same-user process that holds the control token has the same capabilities (full read/list, request writes). Revocation collapses to per-peer switches — discarding ScriptCat's K, or forgetting a browser @@ -76,7 +81,8 @@ on the daemon's listener (same port as the extension WS surface, separate path). - **No exemption for writes**: the control token only proves "same user on this host", it does not bypass browser-side human approval. Even holding the control token, a malicious local process still needs the user to press approve in the extension before a write happens (unless the corresponding "always-allow" policy is - on). + on). Browser control is the exception: the control token alone drives every paired sctl Browser instance + (§1). - **The health check is unauthenticated**: it returns only `{ok, version}` and leaks no key, script, or client information; it is equivalent to the already-accepted risk that an open port is probeable. - **Disconnect voids the request**: the frontend request (HTTP connection) drops → the daemon's request ctx is @@ -88,7 +94,7 @@ on the daemon's listener (same port as the extension WS surface, separate path). |---|---|---|---| | `/pairing.key` | POSIX 0600; protected current-user DACL on Windows | ScriptCat's long-term key K (hex), established by enrollment | Allows impersonating ScriptCat when connecting to the daemon; still bound by write approval | | `/browsers.json` | POSIX 0600; protected current-user DACL on Windows | The registry of paired sctl Browser instances: instance ID, unique name, each instance's long-term key (hex), and last self-reported product and versions | Allows impersonating any paired browser instance when connecting to the daemon | -| `/control.token` | POSIX 0600; protected current-user DACL on Windows | The local control-channel token (regenerated on every serve start) | Allows impersonating the local frontend to call the control API; writes still need browser approval | +| `/control.token` | POSIX 0600; protected current-user DACL on Windows | The local control-channel token (regenerated on every serve start) | Allows impersonating the local frontend to call the control API; writes still need browser approval, while tabs and windows in every paired sctl Browser instance can be controlled directly | Three iron rules: **a token's plaintext never goes over the wire, never enters a log, never enters a URL**; audit events **never record** a token, source code, or a URL containing credentials; keys are written atomically diff --git a/extension/src/background/controller.test.ts b/extension/src/background/controller.test.ts index 4da0306..7963609 100644 --- a/extension/src/background/controller.test.ts +++ b/extension/src/background/controller.test.ts @@ -94,6 +94,19 @@ describe("instance identity and configuration", () => { }); }); + it("hands over the default name for pairing even after the instance was renamed", async () => { + const { background, storage } = setup(); + const { instanceId } = (await background.handle({ target: "background", type: "offscreenReady" })) as { + instanceId: string; + }; + await storage.set({ name: "work" }); + + await expect(background.handle({ target: "background", type: "offscreenReady" })).resolves.toMatchObject({ + name: "work", + defaultName: `edge-${instanceId.slice(0, 4)}`, + }); + }); + it("reports no key before pairing", async () => { const { background } = setup(); diff --git a/extension/src/background/controller.ts b/extension/src/background/controller.ts index 6b14b8d..ec84b12 100644 --- a/extension/src/background/controller.ts +++ b/extension/src/background/controller.ts @@ -97,6 +97,7 @@ export class Background { return { instanceId, name, + defaultName: defaultName(browser.product, instanceId), address: stored(items, "address") ?? DEFAULT_ADDRESS, key: stored(items, "key") ?? null, methods: this.deps.registry.methods(), diff --git a/extension/src/offscreen/connection.fixture.ts b/extension/src/offscreen/connection.fixture.ts index 241a040..fd26b0a 100644 --- a/extension/src/offscreen/connection.fixture.ts +++ b/extension/src/offscreen/connection.fixture.ts @@ -14,6 +14,7 @@ export function config(overrides: Partial = {}): ConnectionCon return { instanceId: INSTANCE_ID, name: "chrome-3f2a", + defaultName: "chrome-3f2a", address: "127.0.0.1:8643", key: SESSION_KEY, methods: ["tabs.list"], diff --git a/extension/src/offscreen/connection.test.ts b/extension/src/offscreen/connection.test.ts index 4b83687..ac1ee19 100644 --- a/extension/src/offscreen/connection.test.ts +++ b/extension/src/offscreen/connection.test.ts @@ -39,6 +39,9 @@ describe("session handshake", () => { expect(h.state()).toEqual({ status: "connected", daemonVersion: "0.1.0", + product: "Chrome", + productVersion: "129.0.6668.58", + connectedAt: h.timers.now(), instanceId: INSTANCE_ID, name: "chrome-3f2a", address: "127.0.0.1:8643", @@ -226,6 +229,21 @@ describe("pairing", () => { expect(h.timers.pending()).toBe(0); }); + it("registers the default name when pairing again after the instance was renamed", async () => { + const h = harness({ key: null, name: "work" }); + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "pairing", code: PAIRING_CODE, deliverKey: DELIVERED_KEY } }); + const capabilities = await hello(socket); + expect(capabilities.params?.peer).toMatchObject({ name: "chrome-3f2a" }); + + socket.receive({ jsonrpc: "2.0", id: capabilities.id, result: {} }); + await until(() => h.state().status === "connected"); + expect(h.state().name).toBe("chrome-3f2a"); + expect(h.persisted.keys).toEqual([{ key: DELIVERED_KEY, name: "chrome-3f2a" }]); + }); + it("stays unpaired when forgotten while the new pairing is still being saved", async () => { const h = harness({ key: null }); h.connection.pair(PAIRING_CODE); diff --git a/extension/src/offscreen/connection.ts b/extension/src/offscreen/connection.ts index 8f00ec7..3d1f530 100644 --- a/extension/src/offscreen/connection.ts +++ b/extension/src/offscreen/connection.ts @@ -111,6 +111,8 @@ export class Connection { pair(code: string): void { this.reset(); + const config = this.cfg(); + config.name = config.defaultName; this.open({ mode: "pairing", code }); this.setState({ status: "pairing" }); } @@ -474,7 +476,14 @@ export class Connection { } attempt.phase = "connected"; this.failures = 0; - this.setState({ status: "connected", daemonVersion: attempt.daemonVersion }); + const config = this.cfg(); + this.setState({ + status: "connected", + daemonVersion: attempt.daemonVersion, + product: config.product, + productVersion: config.productVersion, + connectedAt: this.deps.timers.now(), + }); } private serve(attempt: Attempt, message: JsonRpcMessage): void { diff --git a/extension/src/popup/App.test.ts b/extension/src/popup/App.test.ts index 7228b72..dd89a3d 100644 --- a/extension/src/popup/App.test.ts +++ b/extension/src/popup/App.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { createElement } from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PairResult, RenameResult, SetAddressResult } from "@/shared/messages"; @@ -63,6 +63,13 @@ function fakeSessionStorage(initial: Record = {}): SessionStora } const BASE = { instanceId: "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b", name: "chrome-3f2a", address: "127.0.0.1:8643" }; +const CONNECTED = { + status: "connected" as const, + daemonVersion: "0.2.0", + product: "Chrome", + productVersion: "129.0.6668.58", + connectedAt: 0, +}; function renderApp( state: ConnectionState, @@ -115,6 +122,8 @@ describe("popup states (rendered from ConnectionState, zh and en)", () => { screen.getByRole("textbox", { name: lang === "zh" ? "输入终端显示的配对码" : "Enter the pairing code it shows" }), ).toBeDisabled(); expect(screen.getByRole("button", { name: lang === "zh" ? "正在配对…" : "Pairing…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: lang === "zh" ? "设置" : "Settings" })).toBeDisabled(); + expect(screen.getByRole("button", { name: lang === "zh" ? "daemon 地址" : "Daemon address" })).toBeDisabled(); }); it.each(["zh", "en"] as const)("renders pair-failed (code-rejected) in %s", async (lang) => { @@ -140,25 +149,64 @@ describe("popup states (rendered from ConnectionState, zh and en)", () => { }); it.each(["zh", "en"] as const)("renders the connected state with instance details in %s", async (lang) => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { lang }); - await screen.findByText(BASE.name); + renderApp({ ...BASE, ...CONNECTED }, { lang }); + const nameRow = await screen.findByRole("group", { name: lang === "zh" ? "实例名称" : "Instance name" }); + expect(nameRow).toHaveTextContent(BASE.name); + expect(within(nameRow).getByRole("button", { name: lang === "zh" ? "重命名" : "Rename" })).toBeEnabled(); expect(screen.getByRole("status")).toHaveTextContent(lang === "zh" ? "已连接" : "Connected"); - expect(screen.getByRole("button", { name: lang === "zh" ? "重命名" : "Rename" })).toBeEnabled(); expect(screen.getByText(new RegExp(`sctl tabs list --browser ${BASE.name}`))).toBeInTheDocument(); }); + it.each(["zh", "en"] as const)("copies the full instance ID in the connected state in %s", async (lang) => { + renderApp({ ...BASE, ...CONNECTED }, { lang }); + const idRow = await screen.findByRole("definition", { name: lang === "zh" ? "实例 ID" : "Instance ID" }); + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + await user.click(within(idRow).getByRole("button", { name: lang === "zh" ? "复制" : "Copy" })); + expect(writeText).toHaveBeenCalledWith(BASE.instanceId); + }); + + it.each(["zh", "en"] as const)( + "shows the browser, daemon version and time since the connection was established in %s", + async (lang) => { + // 弹窗在连接建立 2 小时后才打开:时长必须按连接建立的时刻计算,而不是从弹窗打开时算起。 + renderApp({ ...BASE, ...CONNECTED, connectedAt: 1_000 }, { lang, now: () => 1_000 + 2 * 60 * 60 * 1000 }); + const details = await screen.findByRole("definition", { name: lang === "zh" ? "浏览器" : "Browser" }); + expect(details).toHaveTextContent("Chrome 129.0.6668.58"); + expect(screen.getByRole("definition", { name: lang === "zh" ? "daemon" : "Daemon" })).toHaveTextContent("0.2.0"); + expect(screen.getByRole("definition", { name: lang === "zh" ? "已连接" : "Connected for" })).toHaveTextContent( + lang === "zh" ? "2 小时" : "2 h", + ); + }, + ); + it.each(["zh", "en"] as const)("renders the renaming state when Rename is clicked in %s", async (lang) => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { lang }); + renderApp({ ...BASE, ...CONNECTED }, { lang }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: lang === "zh" ? "重命名" : "Rename" })); expect(screen.getByLabelText(lang === "zh" ? "实例名称" : "Instance name")).toHaveValue(BASE.name); expect(screen.getByRole("button", { name: lang === "zh" ? "保存" : "Save" })).toBeInTheDocument(); }); + it.each(["zh", "en"] as const)("cancels renaming with Esc and keeps the name in %s", async (lang) => { + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { lang, api }); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: lang === "zh" ? "重命名" : "Rename" })); + const input = screen.getByLabelText(lang === "zh" ? "实例名称" : "Instance name"); + await user.clear(input); + await user.type(input, "home{Escape}"); + expect(screen.queryByRole("textbox", { name: lang === "zh" ? "实例名称" : "Instance name" })).toBeNull(); + expect(screen.getByRole("group", { name: lang === "zh" ? "实例名称" : "Instance name" })).toHaveTextContent( + BASE.name, + ); + expect(api.rename).not.toHaveBeenCalled(); + }); + it.each(["zh", "en"] as const)("renders the name-taken (rename conflict) state in %s", async (lang) => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); api.rename.mockResolvedValueOnce({ ok: false, error: "name-taken" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { lang, api }); + renderApp({ ...BASE, ...CONNECTED }, { lang, api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: lang === "zh" ? "重命名" : "Rename" })); const input = screen.getByLabelText(lang === "zh" ? "实例名称" : "Instance name"); @@ -199,7 +247,7 @@ describe("popup states (rendered from ConnectionState, zh and en)", () => { }); it.each(["zh", "en"] as const)("renders the forget-confirmation dialog, focused on cancel, in %s", async (lang) => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { lang }); + renderApp({ ...BASE, ...CONNECTED }, { lang }); const user = userEvent.setup(); await user.click( await screen.findByRole("button", { name: lang === "zh" ? "断开并忘记" : "Disconnect and forget" }), @@ -210,7 +258,7 @@ describe("popup states (rendered from ConnectionState, zh and en)", () => { }); it.each(["zh", "en"] as const)("renders the settings screen in %s", async (lang) => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { lang }); + renderApp({ ...BASE, ...CONNECTED }, { lang }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: lang === "zh" ? "设置" : "Settings" })); expect(screen.getByRole("radiogroup", { name: lang === "zh" ? "外观" : "Appearance" })).toBeInTheDocument(); @@ -222,7 +270,7 @@ describe("popup states (rendered from ConnectionState, zh and en)", () => { describe("settings persistence and live effect", () => { it("persists the chosen appearance and language to local storage", async () => { const localStorage = fakeLocalStorage(); - const { getByRole } = renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { localStorage }); + const { getByRole } = renderApp({ ...BASE, ...CONNECTED }, { localStorage }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Settings" })); await user.click(getByRole("radio", { name: "Dark" })); @@ -232,7 +280,7 @@ describe("settings persistence and live effect", () => { }); it("applies the dark appearance to the document immediately", async () => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); + renderApp({ ...BASE, ...CONNECTED }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Settings" })); await user.click(screen.getByRole("radio", { name: "Dark" })); @@ -240,7 +288,7 @@ describe("settings persistence and live effect", () => { }); it("applies the chosen language immediately, without reopening the popup", async () => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); + renderApp({ ...BASE, ...CONNECTED }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Settings" })); await user.click(screen.getByRole("radio", { name: "中文" })); @@ -249,10 +297,11 @@ describe("settings persistence and live effect", () => { it("restores previously saved appearance and language on the next open", async () => { const localStorage = fakeLocalStorage({ appearance: "dark", language: "zh" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { localStorage }); + renderApp({ ...BASE, ...CONNECTED }, { localStorage }); // 等语言真正切到中文,确保 loadPrefs 的 Promise 已经落地,而不仅仅是连接状态已加载。 await screen.findByText("让 sctl 控制这个浏览器"); - expect(document.documentElement.classList.contains("dark")).toBe(true); + // 深色类由 effect 在提交之后设置,文字出现时它可能还没执行。 + await vi.waitFor(() => expect(document.documentElement.classList.contains("dark")).toBe(true)); }); }); @@ -282,22 +331,43 @@ describe("pairing code draft", () => { expect(sessionStorage.data.pairingCodeDraft).toBe("K7QM3XRD"); }); - it("clears the draft once pairing is accepted by the background", async () => { + it("keeps the draft when pairing cannot reach the daemon, so the code survives reopening the popup", async () => { const sessionStorage = fakeSessionStorage({ pairingCodeDraft: "K7QM3XRD" }); - const { api } = fakeApi({ ...BASE, status: "unpaired" }); + const { api, emit } = fakeApi({ ...BASE, status: "unpaired" }); renderApp({ ...BASE, status: "unpaired" }, { sessionStorage, api }); const user = userEvent.setup(); await screen.findByDisplayValue("K7QM3XRD"); await user.click(screen.getByRole("button", { name: "Pair" })); expect(api.pair).toHaveBeenCalledWith("K7QM3XRD"); - expect(sessionStorage.data.pairingCodeDraft).toBeUndefined(); + await act(async () => { + emit({ ...BASE, status: "pairing" }); + emit({ ...BASE, status: "pair-unreachable" }); + await Promise.resolve(); + }); + await screen.findByRole("alert"); + expect(sessionStorage.data.pairingCodeDraft).toBe("K7QM3XRD"); + }); + + it("clears the draft once pairing succeeds", async () => { + const sessionStorage = fakeSessionStorage({ pairingCodeDraft: "K7QM3XRD" }); + const { api, emit } = fakeApi({ ...BASE, status: "unpaired" }); + renderApp({ ...BASE, status: "unpaired" }, { sessionStorage, api }); + const user = userEvent.setup(); + await screen.findByDisplayValue("K7QM3XRD"); + await user.click(screen.getByRole("button", { name: "Pair" })); + await act(async () => { + emit({ ...BASE, ...CONNECTED }); + await Promise.resolve(); + }); + await screen.findByText(/sctl tabs list --browser/); + await vi.waitFor(() => expect(sessionStorage.data.pairingCodeDraft).toBeUndefined()); }); }); describe("rename, forget and daemon address flows", () => { it("calls the popup API to rename and returns to the normal view on success", async () => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { api }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Rename" })); const input = screen.getByLabelText("Instance name"); @@ -313,8 +383,8 @@ describe("rename, forget and daemon address flows", () => { }); it("calls forget on confirmation and leaves the dialog", async () => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { api }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Disconnect and forget" })); const dialog = await screen.findByRole("alertdialog"); @@ -323,8 +393,8 @@ describe("rename, forget and daemon address flows", () => { }); it("does not call forget when cancel is chosen", async () => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { api }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Disconnect and forget" })); const dialog = await screen.findByRole("alertdialog"); @@ -333,8 +403,8 @@ describe("rename, forget and daemon address flows", () => { }); it("calls setAddress with the new value and shows the invalid-address error otherwise", async () => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { api }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Settings" })); const addressField = screen.getByLabelText("Daemon address"); @@ -345,8 +415,8 @@ describe("rename, forget and daemon address flows", () => { }); it("rejects an address that isn't host:port without calling the API", async () => { - const { api } = fakeApi({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }, { api }); + const { api } = fakeApi({ ...BASE, ...CONNECTED }); + renderApp({ ...BASE, ...CONNECTED }, { api }); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Settings" })); const addressField = screen.getByLabelText("Daemon address"); @@ -361,7 +431,7 @@ describe("rename, forget and daemon address flows", () => { describe("link stroke encodes the connection state", () => { it.each([ ["unpaired", { ...BASE, status: "unpaired" as const }, undefined], - ["connected", { ...BASE, status: "connected" as const, daemonVersion: "0.2.0" }, undefined], + ["connected", { ...BASE, ...CONNECTED }, undefined], ["reconnecting", { ...BASE, status: "reconnecting" as const, attempt: 0, retryAt: null }, "5 5"], ["rejected", { ...BASE, status: "rejected" as const }, "10 14"], ])("draws a distinct stroke for %s", async (_label, state, expectedDash) => { @@ -375,7 +445,7 @@ describe("link stroke encodes the connection state", () => { describe("accessibility", () => { it("announces the connection status through a polite live region", async () => { - renderApp({ ...BASE, status: "connected", daemonVersion: "0.2.0" }); + renderApp({ ...BASE, ...CONNECTED }); const status = await screen.findByRole("status"); expect(status).toHaveAttribute("aria-live", "polite"); }); diff --git a/extension/src/popup/App.tsx b/extension/src/popup/App.tsx index 44baf94..a03fb32 100644 --- a/extension/src/popup/App.tsx +++ b/extension/src/popup/App.tsx @@ -87,26 +87,15 @@ export function App({ const [addressDraft, setAddressDraft] = useState(""); const [addressInvalid, setAddressInvalid] = useState(false); const [forgetOpen, setForgetOpen] = useState(false); - const [connectedSince, setConnectedSince] = useState(null); - - // 收到新连接状态时一并推导「已连接起始时间」的近似值:ConnectionState 不带真实的连接起始时间戳, - // 记住本次弹窗会话里第一次观察到 connected 的时刻是可用信息里最接近的替代(已知局限,见任务报告)。 - const applyState = useCallback( - (s: ConnectionState) => { - setState(s); - setConnectedSince((prev) => (s.status === "connected" ? (prev ?? now()) : null)); - }, - [now], - ); // 初次加载:连接状态、外观/语言设置、配对码草稿(会话存储,重开弹窗时恢复)。 useEffect(() => { let cancelled = false; void api.getState().then((s) => { - if (!cancelled) applyState(s); + if (!cancelled) setState(s); }); const unsubscribe = api.subscribe((s) => { - if (!cancelled) applyState(s); + if (!cancelled) setState(s); }); void loadPrefs(localStorage).then((p) => { if (!cancelled) { @@ -150,6 +139,15 @@ export function App({ const status = state?.status; + // 草稿只在配对真正成功后清除:配对失败或连不上 daemon 时,用户常要先离开弹窗(去终端启动 + // sctl serve),重新打开时还要用同一个配对码。 + useEffect(() => { + if (status === "connected" && codeDraftLoaded) { + void clearPairingDraft(sessionStorage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, codeDraftLoaded]); + // 重连倒计时与已连接时长都要随时间推进,重连中每秒、已连接每分钟够用;tick 本身只用来触发重渲染。 const [, forceTick] = useState(0); useEffect(() => { @@ -191,12 +189,7 @@ export function App({ return; } const result = await api.pair(code); - if (!result.ok) { - setCodeInvalid(true); - return; - } - setCodeInvalid(false); - void clearPairingDraft(sessionStorage); + setCodeInvalid(!result.ok); }; const startRename = () => { @@ -480,8 +473,13 @@ export function App({

) : ( -
- {s.instanceName} +
+
+ + {s.instanceName} + + {state.name} +
)} {state.status === "rejected" && !pairAgainActive && ( - + )} )} diff --git a/extension/src/popup/pairing-draft.ts b/extension/src/popup/pairing-draft.ts index 0d6e319..c899739 100644 --- a/extension/src/popup/pairing-draft.ts +++ b/extension/src/popup/pairing-draft.ts @@ -21,3 +21,21 @@ export async function savePairingDraft(storage: SessionStorage, code: string): P export async function clearPairingDraft(storage: SessionStorage): Promise { await storage.remove(KEY); } + +// 被 daemon forget 之后用户点了"重新配对":弹窗关闭重开时靠它回到配对表单,而不是又落回被拒页。 +// 与草稿分开存,因为草稿也可能是普通未配对路径留下的。 +const RE_PAIR_KEY = "rePairFlow"; + +export async function loadRePairFlow(storage: SessionStorage): Promise { + const items = await storage.get([RE_PAIR_KEY]); + return items[RE_PAIR_KEY] === true; +} + +export async function saveRePairFlow(storage: SessionStorage, active: boolean): Promise { + if (active) { + await storage.set({ [RE_PAIR_KEY]: true }); + return; + } + // 标记大多数时候本就不存在,先读再删,避免每次打开弹窗都无谓写会话存储。 + if (await loadRePairFlow(storage)) await storage.remove(RE_PAIR_KEY); +} From 4afdbd2ec09fd67970632bc3fc5b96f5b875b0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 29 Sep 2026 11:02:37 +0800 Subject: [PATCH 24/25] fix: drop unused popup dictionary entries --- extension/src/i18n/strings.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/extension/src/i18n/strings.ts b/extension/src/i18n/strings.ts index 18239ba..f3cdfc7 100644 --- a/extension/src/i18n/strings.ts +++ b/extension/src/i18n/strings.ts @@ -37,7 +37,6 @@ export interface Strings { save: string; cancel: string; nameHint: string; - nameInvalid: string; nameTaken: (name: string) => string; id: string; browser: string; @@ -48,7 +47,6 @@ export interface Strings { sinceHours: (n: number) => string; useHint: string; copy: string; - copied: string; forget: string; forgetTitle: string; forgetBefore: string; @@ -106,7 +104,6 @@ export const dictionary: Record = { save: "保存", cancel: "取消", nameHint: "小写字母、数字和 -,最多 32 个字符。保存后会重新连接一次。", - nameInvalid: "名称只能包含小写字母、数字和 -,长度 1-32 个字符。", nameTaken: (name: string) => `名称 ${name} 已被另一个浏览器使用,换一个吧。`, id: "实例 ID", browser: "浏览器", @@ -117,7 +114,6 @@ export const dictionary: Record = { sinceHours: (n: number) => `${n} 小时`, useHint: "在命令里指定这个浏览器", copy: "复制", - copied: "已复制", forget: "断开并忘记", forgetTitle: "断开并忘记这个 daemon?", forgetBefore: "扩展会删除本地保存的密钥,之后需要重新运行 sctl connect 配对。daemon 那边的密钥要另外运行", @@ -175,7 +171,6 @@ export const dictionary: Record = { save: "Save", cancel: "Cancel", nameHint: "Lowercase letters, digits and -, up to 32 characters. Saving reconnects once.", - nameInvalid: "Names may only use lowercase letters, digits and -, 1-32 characters.", nameTaken: (name: string) => `Another browser is already named ${name}. Pick a different name.`, id: "Instance ID", browser: "Browser", @@ -186,7 +181,6 @@ export const dictionary: Record = { sinceHours: (n: number) => `${n} h`, useHint: "Target this browser in commands", copy: "Copy", - copied: "Copied", forget: "Disconnect and forget", forgetTitle: "Disconnect and forget this daemon?", forgetBefore: From 7074c44e771530a03ca537a3db615355ad784766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 29 Sep 2026 11:28:03 +0800 Subject: [PATCH 25/25] fix: consume pairing codes atomically and harden popup, socket and MCP edge paths --- extension/src/offscreen/connection.fixture.ts | 14 +++++ extension/src/offscreen/connection.test.ts | 51 +++++++++++++++- extension/src/offscreen/connection.ts | 34 +++++++++-- extension/src/offscreen/crypto.ts | 5 +- extension/src/popup/App.test.ts | 56 ++++++++++++++++++ extension/src/popup/App.tsx | 36 ++++++----- extension/src/popup/pairing-draft.test.ts | 8 +-- extension/src/popup/pairing-draft.ts | 4 -- extension/src/shared/hex.ts | 4 ++ extension/src/shared/identity.ts | 4 +- internal/cli/cli_test.go | 8 ++- internal/cli/dispatch.go | 16 +++-- internal/cli/tabs_test.go | 47 +++++++++++++++ internal/client/mcpserver/mcpserver.go | 9 +++ internal/client/mcpserver/mcpserver_test.go | 48 +++++++++++++++ internal/daemon/bridge/browsers_test.go | 59 +++++++++++++++++++ internal/daemon/bridge/conn.go | 8 ++- internal/daemon/bridge/pairing.go | 26 ++++---- internal/protocolgen/generate.go | 14 ++++- internal/protocolgen/generate_test.go | 14 +++++ 20 files changed, 406 insertions(+), 59 deletions(-) create mode 100644 extension/src/shared/hex.ts diff --git a/extension/src/offscreen/connection.fixture.ts b/extension/src/offscreen/connection.fixture.ts index 95d4dd2..2f80fb5 100644 --- a/extension/src/offscreen/connection.fixture.ts +++ b/extension/src/offscreen/connection.fixture.ts @@ -218,8 +218,13 @@ export function harness(start: Partial = {}) { let persistence: Promise = Promise.resolve(); // 测试可以让持久化失败,模拟 service worker 写存储出错。 let persistFailure: Error | null = null; + // 测试可以让 WebSocket 构造抛错,模拟浏览器拒绝连接某个地址(例如被屏蔽的端口)。 + let socketRefusal: Error | null = null; const connection = new Connection({ createSocket: (url, events) => { + if (socketRefusal) { + throw socketRefusal; + } const socket = new FakeSocket(url, events); sockets.push(socket); return socket; @@ -258,6 +263,9 @@ export function harness(start: Partial = {}) { setOutcome: (next: RpcOutcome) => { outcome = next; }, + refuseSockets: (error: Error | null) => { + socketRefusal = error; + }, failPersistence: (error: Error) => { persistFailure = error; }, @@ -271,6 +279,12 @@ export function harness(start: Partial = {}) { }; } +// 放行挂起的 Promise 之后,后续步骤只由已决议的 Promise 推进(没有 I/O 和计时器),在下一个宏任务之前必然全部跑完; +// 等一个宏任务边界即可,不依赖任何时长。 +export function drainMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + // 等待异步握手(WebCrypto 走线程池)推进到条件成立。 export async function until(condition: () => boolean): Promise { for (let i = 0; i < 200; i++) { diff --git a/extension/src/offscreen/connection.test.ts b/extension/src/offscreen/connection.test.ts index ebd604e..3d41d0e 100644 --- a/extension/src/offscreen/connection.test.ts +++ b/extension/src/offscreen/connection.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { LIMITS } from "@/protocol/generated/protocol.generated"; import { INSTANCE_ID, PAIRING_CODE, @@ -7,6 +8,7 @@ import { authenticate, conflict, harness, + drainMicrotasks, hello, until, } from "./connection.fixture"; @@ -107,6 +109,31 @@ describe("session handshake", () => { }); describe("reconnect backoff", () => { + it("keeps retrying when the browser refuses to open a socket to the address", () => { + const h = harness(); + h.socket().drop(1006); + h.connection.setAddress("127.0.0.1:9000"); + h.socket().drop(1006); + h.refuseSockets(new DOMException("The port 9000 is not allowed.", "SecurityError")); + h.timers.advance(1000); + + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 2, retryAt: h.timers.now() + 2000 }); + h.refuseSockets(null); + h.timers.advance(2000); + expect(h.sockets).toHaveLength(3); + }); + + it("gives up a connection that never opens within the auth timeout and retries", () => { + const h = harness(); + const socket = h.socket(); + h.timers.advance(LIMITS.authTimeoutMs); + expect(socket.closedWith).not.toBeNull(); + // 浏览器关闭一个仍在连接中的套接字时以 1006 报告关闭。 + socket.drop(1006); + + expect(h.state()).toMatchObject({ status: "reconnecting", attempt: 1 }); + }); + it("retries an unreachable daemon after 1 s, doubling each time up to 30 s", () => { const h = harness(); const delays: number[] = []; @@ -206,6 +233,28 @@ describe("pairing", () => { expect(h.persisted.keys).toEqual([]); }); + it("reports pairing as unreachable when the browser refuses to open a socket to the address", () => { + const h = harness({ key: null, address: "127.0.0.1:6000" }); + h.refuseSockets(new DOMException("The port 6000 is not allowed.", "SecurityError")); + h.connection.pair(PAIRING_CODE); + expect(h.state().status).toBe("pairing"); + h.timers.advance(0); + + expect(h.state()).toMatchObject({ status: "pair-unreachable", address: "127.0.0.1:6000" }); + expect(h.timers.pending()).toBe(0); + }); + + it("reports a pairing whose connection never opens as unreachable after the auth timeout", () => { + const h = harness({ key: null }); + h.connection.pair(PAIRING_CODE); + const socket = h.socket(); + h.timers.advance(LIMITS.authTimeoutMs); + expect(socket.closedWith).not.toBeNull(); + socket.drop(1006); + + expect(h.state().status).toBe("pair-unreachable"); + }); + it("reports an unreachable daemon as pair-unreachable with the address it tried", () => { const h = harness({ key: null, address: "127.0.0.1:9000" }); h.connection.pair(PAIRING_CODE); @@ -257,7 +306,7 @@ describe("pairing", () => { h.connection.forget(); release(); - await new Promise((resolve) => setTimeout(resolve, 5)); + await drainMicrotasks(); h.connection.setAddress("127.0.0.1:9000"); expect(h.state().status).toBe("unpaired"); diff --git a/extension/src/offscreen/connection.ts b/extension/src/offscreen/connection.ts index 2190aa0..50da0b6 100644 --- a/extension/src/offscreen/connection.ts +++ b/extension/src/offscreen/connection.ts @@ -22,6 +22,8 @@ const MAX_RETRY_MS = 30_000; // daemon 以 1008 关闭即握手被拒,原因从不回显(docs/protocol.md §2.1)。 const CLOSE_POLICY_VIOLATION = 1008; const CLOSE_NORMAL = 1000; +// 浏览器在连接没能建立时报告的关闭码。 +const CLOSE_ABNORMAL = 1006; // 应用层失败统一用 -32000,领域错误码放在 error.data.code(docs/protocol.md §4)。 const RPC_APPLICATION_ERROR = -32000; const NONCE_PATTERN = new RegExp(`^[0-9a-f]{${CRYPTO.nonceBytes * 2}}$`); @@ -233,7 +235,7 @@ export class Connection { close: (code) => this.onClose(attempt, code), }; const attempt: Attempt = { - socket: this.deps.createSocket(websocketUrl(config.address), events), + socket: this.createSocket(websocketUrl(config.address), events), auth, name: config.name, phase: "challenge", @@ -248,14 +250,26 @@ export class Connection { inFlight: 0, }; this.attempt = attempt; + // 连不上也不报错的地址(丢包、只接 TCP 不应答升级)要等浏览器自己的连接超时,远长于配对码的有效期, + // 期间配对表单一直停在"配对中"无法操作;建立连接因此同样限定在 authTimeoutMs 内。 + this.armTimeout(attempt); } - private onOpen(attempt: Attempt): void { - if (attempt !== this.attempt) { - return; + // 浏览器会同步拒绝某些地址(例如被屏蔽的端口会抛 SecurityError)。把它当作一次没能打开的连接: + // 关闭事件异步送达,与真实套接字一致,调用方在 open() 之后设置的状态不会盖过它得出的结论。 + private createSocket(url: string, events: SocketEvents): SocketLike { + try { + return this.deps.createSocket(url, events); + } catch (error) { + console.warn("the browser refused to open a WebSocket to the daemon", error); + this.deps.timers.setTimeout(() => events.close(CLOSE_ABNORMAL), 0); + return { send: () => undefined, close: () => undefined }; } - attempt.opened = true; - // 认证必须在 authTimeoutMs 内完成,否则放弃这次尝试。 + } + + // 超时后放弃这次尝试;连接打开时重新计时,认证本身仍有完整的 authTimeoutMs。 + private armTimeout(attempt: Attempt): void { + this.clearAuthTimer(attempt); attempt.authTimer = this.deps.timers.setTimeout(() => { attempt.authTimer = null; if (attempt === this.attempt) { @@ -264,6 +278,14 @@ export class Connection { }, LIMITS.authTimeoutMs); } + private onOpen(attempt: Attempt): void { + if (attempt !== this.attempt) { + return; + } + attempt.opened = true; + this.armTimeout(attempt); + } + private onMessage(attempt: Attempt, data: string): void { if (attempt !== this.attempt) { return; diff --git a/extension/src/offscreen/crypto.ts b/extension/src/offscreen/crypto.ts index eee9e72..21afcbc 100644 --- a/extension/src/offscreen/crypto.ts +++ b/extension/src/offscreen/crypto.ts @@ -1,4 +1,5 @@ import { CRYPTO } from "@/protocol/generated/protocol.generated"; +import { bytesToHex } from "@/shared/hex"; // WebCrypto 的 BufferSource 要求 ArrayBuffer 后备,所有原语统一使用这个别名。 export type Bytes = Uint8Array; @@ -21,10 +22,6 @@ function utf8(text: string): Bytes { return new Uint8Array(encoder.encode(text)); } -export function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - export function hexToBytes(hex: string): Bytes { const out = new Uint8Array(hex.length >> 1); for (let i = 0; i < out.length; i++) { diff --git a/extension/src/popup/App.test.ts b/extension/src/popup/App.test.ts index 02ff879..5dec184 100644 --- a/extension/src/popup/App.test.ts +++ b/extension/src/popup/App.test.ts @@ -62,6 +62,17 @@ function fakeSessionStorage(initial: Record = {}): SessionStora }; } +// 让会话存储的读取挂起到返回的函数被调用为止,用来安排读取与连接状态到达的先后。 +function holdReads(storage: SessionStorage): () => void { + const read = storage.get.bind(storage); + let release: () => void = () => undefined; + const released = new Promise((resolve) => { + release = resolve; + }); + storage.get = (keys) => released.then(() => read(keys)); + return release; +} + const BASE = { instanceId: "3f2a9c0e5b7d41e8a6f0c2d4e6f80a1b", name: "chrome-3f2a", address: "127.0.0.1:8643" }; const CONNECTED = { status: "connected" as const, @@ -421,6 +432,51 @@ describe("pairing code draft", () => { expect(await screen.findByRole("button", { name: "Pair again" })).toBeInTheDocument(); }); + it("waits for the saved draft before showing the pairing form, so a late read cannot replace what the user typed", async () => { + const sessionStorage = fakeSessionStorage({ pairingCodeDraft: "K7QM3XRD" }); + const releaseRead = holdReads(sessionStorage); + const { api } = renderApp({ ...BASE, status: "unpaired" }, { sessionStorage }); + // act 会把已经决议的 getState 回复及其渲染一并冲刷完。 + await act(async () => { + await Promise.resolve(); + }); + expect(api.getState).toHaveBeenCalled(); + expect(screen.queryByLabelText("Enter the pairing code it shows")).not.toBeInTheDocument(); + releaseRead(); + expect(await screen.findByDisplayValue("K7QM3XRD")).toBeInTheDocument(); + }); + + it("does not resume a re-pair flow whose rejection already ended before the popup read it", async () => { + // 上次弹窗在点下配对后立刻关闭,没来得及清掉标记;配对随后成功了。 + const sessionStorage = fakeSessionStorage({ rePairFlow: true, pairingCodeDraft: "AB12" }); + const releaseRead = holdReads(sessionStorage); + const { api, emit } = renderApp({ ...BASE, ...CONNECTED }, { sessionStorage }); + // 连接状态先到并渲染完毕,会话存储随后才读出来。 + await act(async () => { + await Promise.resolve(); + }); + expect(api.getState).toHaveBeenCalled(); + releaseRead(); + await screen.findByText(/sctl tabs list --browser/); + await vi.waitFor(() => expect(sessionStorage.data).toEqual({})); + act(() => emit({ ...BASE, status: "rejected" })); + expect(screen.getByRole("alert")).toHaveTextContent("The daemon no longer recognises this browser"); + expect(screen.getByRole("button", { name: "Pair again" })).toBeInTheDocument(); + }); + + it("offers an empty code input when the instance is forgotten after pairing in the same popup", async () => { + const sessionStorage = fakeSessionStorage({ pairingCodeDraft: "K7QM3XRD" }); + const { api, emit } = fakeApi({ ...BASE, status: "unpaired" }); + renderApp({ ...BASE, status: "unpaired" }, { sessionStorage, api }); + const user = userEvent.setup(); + await screen.findByDisplayValue("K7QM3XRD"); + await user.click(screen.getByRole("button", { name: "Pair" })); + act(() => emit({ ...BASE, status: "pairing" })); + act(() => emit({ ...BASE, ...CONNECTED })); + act(() => emit({ ...BASE, status: "unpaired" })); + expect(screen.getByLabelText("Enter the pairing code it shows")).toHaveValue(""); + }); + it("clears the draft once pairing succeeds", async () => { const sessionStorage = fakeSessionStorage({ pairingCodeDraft: "K7QM3XRD" }); const { api, emit } = fakeApi({ ...BASE, status: "unpaired" }); diff --git a/extension/src/popup/App.tsx b/extension/src/popup/App.tsx index bef3300..197311f 100644 --- a/extension/src/popup/App.tsx +++ b/extension/src/popup/App.tsx @@ -37,7 +37,7 @@ import type { PopupApi } from "@/shared/popup-api"; import { createPopupApi } from "@/shared/popup-api"; import type { ConnectionState } from "@/shared/state"; import { formatConnectedSince } from "./format-duration"; -import { clearPairingDraft, loadPairingDraft, loadRePairFlow, savePairingDraft, saveRePairFlow } from "./pairing-draft"; +import { loadPairingDraft, loadRePairFlow, savePairingDraft, saveRePairFlow } from "./pairing-draft"; import { DEFAULT_PREFS, isDarkMode, loadPrefs, savePrefs, type Prefs } from "./preferences"; import { chromeLocalStorage, chromeSessionStorage, type KeyValueStorage, type SessionStorage } from "./storage"; @@ -93,12 +93,18 @@ export function App({ let cancelled = false; // getState 的回复绕经 service worker,广播直接从 offscreen 发来,可能先到;先到的广播比回复更新,不能被回复覆盖。 let broadcastSeen = false; + // 会话存储与连接状态各自异步到达;草稿读出时要按已经收到的最新状态判断它还适不适用。 + let latest: ConnectionState["status"] | undefined; + const receive = (s: ConnectionState) => { + latest = s.status; + setState(s); + }; void api.getState().then((s) => { - if (!cancelled && !broadcastSeen) setState(s); + if (!cancelled && !broadcastSeen) receive(s); }); const unsubscribe = api.subscribe((s) => { broadcastSeen = true; - if (!cancelled) setState(s); + if (!cancelled) receive(s); }); void loadPrefs(localStorage).then((p) => { if (!cancelled) { @@ -108,9 +114,11 @@ export function App({ }); void Promise.all([loadPairingDraft(sessionStorage), loadRePairFlow(sessionStorage)]).then(([draft, rePairing]) => { if (!cancelled) { - setCode(draft); - // 只有重新配对流程里真的输入过内容才直接回到表单;没有草稿时被拒页的说明更有用。 - if (rePairing && draft) setPairAgain(true); + // 已连接时草稿是那次成功配对用掉的配对码(配对在弹窗关闭后才完成),不再恢复。 + setCode(latest === "connected" ? "" : draft); + // 只有重新配对流程里真的输入过内容才直接回到表单;没有草稿时被拒页的说明更有用。状态若已离开 + // rejected,标记属于已经结束的那次拒绝(弹窗没来得及清掉它),恢复它会让之后的新拒绝跳过说明。 + if (rePairing && draft && (latest === undefined || latest === "rejected")) setPairAgain(true); setCodeDraftLoaded(true); } }); @@ -149,6 +157,10 @@ export function App({ if (status !== pairAgainStatus) { setPairAgainStatus(status); if (status !== "rejected") setPairAgain(false); + // 草稿只在配对真正成功后清空(持久化 effect 随之删掉存储里的草稿):配对失败或连不上 daemon 时, + // 用户常要先离开弹窗(去终端启动 sctl serve),重新打开时还要用同一个配对码。成功后清空输入框本身, + // 之后被 forget 再出现的配对表单才不会带着已经用掉的码。 + if (status === "connected") setCode(""); } // 离开 rejected(含配对成功)后,持久化的重新配对标记同样作废。 @@ -157,15 +169,6 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); - // 草稿只在配对真正成功后清除:配对失败或连不上 daemon 时,用户常要先离开弹窗(去终端启动 - // sctl serve),重新打开时还要用同一个配对码。 - useEffect(() => { - if (status === "connected" && codeDraftLoaded) { - void clearPairingDraft(sessionStorage); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, codeDraftLoaded]); - // 重连倒计时与已连接时长都要随时间推进,重连中每秒、已连接每分钟够用;tick 本身只用来触发重渲染。 const [, forceTick] = useState(0); useEffect(() => { @@ -263,7 +266,8 @@ export function App({ setViewMode(prevViewMode === "settings" ? "main" : prevViewMode); }; - if (!state) { + // 草稿读出之前不显示任何可交互内容:否则迟到的读取会覆盖用户已经输入的配对码或刚清空的重新配对输入框。 + if (!state || !codeDraftLoaded) { return
{s.product}
; } diff --git a/extension/src/popup/pairing-draft.test.ts b/extension/src/popup/pairing-draft.test.ts index e6df392..607e8a2 100644 --- a/extension/src/popup/pairing-draft.test.ts +++ b/extension/src/popup/pairing-draft.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { clearPairingDraft, loadPairingDraft, savePairingDraft } from "./pairing-draft"; +import { loadPairingDraft, savePairingDraft } from "./pairing-draft"; import type { SessionStorage } from "./storage"; function fakeSession(initial: Record = {}): SessionStorage & { data: Record } { @@ -41,10 +41,4 @@ describe("pairing code draft", () => { await savePairingDraft(storage, ""); await expect(loadPairingDraft(storage)).resolves.toBe(""); }); - - it("clears the draft explicitly after a successful pairing", async () => { - const storage = fakeSession({ pairingCodeDraft: "AB12-CD34" }); - await clearPairingDraft(storage); - await expect(loadPairingDraft(storage)).resolves.toBe(""); - }); }); diff --git a/extension/src/popup/pairing-draft.ts b/extension/src/popup/pairing-draft.ts index c899739..35be789 100644 --- a/extension/src/popup/pairing-draft.ts +++ b/extension/src/popup/pairing-draft.ts @@ -18,10 +18,6 @@ export async function savePairingDraft(storage: SessionStorage, code: string): P await storage.set({ [KEY]: code }); } -export async function clearPairingDraft(storage: SessionStorage): Promise { - await storage.remove(KEY); -} - // 被 daemon forget 之后用户点了"重新配对":弹窗关闭重开时靠它回到配对表单,而不是又落回被拒页。 // 与草稿分开存,因为草稿也可能是普通未配对路径留下的。 const RE_PAIR_KEY = "rePairFlow"; diff --git a/extension/src/shared/hex.ts b/extension/src/shared/hex.ts new file mode 100644 index 0000000..1b46967 --- /dev/null +++ b/extension/src/shared/hex.ts @@ -0,0 +1,4 @@ +// 实例 ID、nonce、MAC 与密钥在线上都是小写 hex。 +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/extension/src/shared/identity.ts b/extension/src/shared/identity.ts index 99eb34f..862d435 100644 --- a/extension/src/shared/identity.ts +++ b/extension/src/shared/identity.ts @@ -1,3 +1,5 @@ +import { bytesToHex } from "./hex"; + // 实例 ID 与名称的格式由 daemon 在握手边界校验(docs/protocol.md §2.1、§2.2),这里与之保持一致。 const NAME_PATTERN = /^[a-z0-9-]{1,32}$/; const INSTANCE_ID_BYTES = 16; @@ -26,7 +28,7 @@ const CHROMIUM: ProductInfo = { brand: "Chromium", slug: "chromium", product: "C export function newInstanceId(): string { const bytes = crypto.getRandomValues(new Uint8Array(INSTANCE_ID_BYTES)); - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return bytesToHex(bytes); } export function detectProduct(brands: readonly BrandInfo[] | undefined): ProductInfo { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9e15787..116b31e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "errors" "io" @@ -56,6 +57,11 @@ func runCLIStdin(stdin io.Reader, args ...string) (int, string, string) { // runCLIResult 与 runCLIStdin 相同,另外返回命令的错误本身(其 Message 由 main 打印到 stderr)。 func runCLIResult(stdin io.Reader, args ...string) (int, string, string, error) { + return runCLIContext(context.Background(), stdin, args...) +} + +// runCLIContext 与 runCLIResult 相同,但命令在 ctx 下执行;取消 ctx 等同于用户按下 Ctrl-C。 +func runCLIContext(ctx context.Context, stdin io.Reader, args ...string) (int, string, string, error) { if address := os.Getenv("SCTL_BRIDGE_ADDR"); address != "" { args = append([]string{"--listen-address", address}, args...) } @@ -76,7 +82,7 @@ func runCLIResult(stdin io.Reader, args ...string) (int, string, string, error) root := NewRootCmd() root.SetArgs(args) root.SetIn(stdin) - err := root.Execute() + err := root.ExecuteContext(ctx) wOut.Close() wErr.Close() diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go index 50f3350..e238dcf 100644 --- a/internal/cli/dispatch.go +++ b/internal/cli/dispatch.go @@ -20,22 +20,28 @@ import ( // exitVoided。桥接业务错误按 code 映射:USER_REJECTED→exitRejected,OPERATION_EXPIRED→exitVoided, // 其余→exitError。 func dispatch(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error { - return dispatchAction(cmd, action, "", input, false, onOK) + return dispatchAction(cmd, action, "", input, false, canceledVoided, onOK) } // dispatchBlocking 与 dispatch 相同,但用于写动词:调用前告知用户正在等待浏览器裁决。 func dispatchBlocking(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error { - return dispatchAction(cmd, action, "", input, true, onOK) + return dispatchAction(cmd, action, "", input, true, canceledVoided, onOK) } // dispatchBrowser 与 dispatch 相同,但携带一个显式的目标浏览器(名称或实例-ID 前缀;空串交给 // daemon 按在线实例解析,见 docs/protocol.md §3.1)。用于 tabs.*/windows.* 等浏览器方法命令; // 这些方法不做逐次人工审批(spec 设计决策 3),所以从不走 blocking 提示。 func dispatchBrowser(cmd *cobra.Command, action, browser string, input json.RawMessage, onOK func(result json.RawMessage) error) error { - return dispatchAction(cmd, action, browser, input, false, onOK) + return dispatchAction(cmd, action, browser, input, false, canceledUnconfirmed, onOK) } -func dispatchAction(cmd *cobra.Command, action, browser string, input json.RawMessage, blocking bool, onOK func(result json.RawMessage) error) error { +const ( + canceledVoided = "canceled, operation voided" + // 浏览器方法收到即执行,没有可作废的挂起操作(扩展忽略 $/cancelRequest):取消只是不再等结果。 + canceledUnconfirmed = "canceled before the result arrived; the browser may already have carried out the operation" +) + +func dispatchAction(cmd *cobra.Command, action, browser string, input json.RawMessage, blocking bool, canceled string, onOK func(result json.RawMessage) error) error { ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) defer stop() @@ -51,7 +57,7 @@ func dispatchAction(cmd *cobra.Command, action, browser string, input json.RawMe res, err := client.Call(ctx, action, browser, input) if err != nil { if errors.Is(err, context.Canceled) { - return &ExitError{Code: exitVoided, Message: "canceled, operation voided"} + return &ExitError{Code: exitVoided, Message: canceled} } return &ExitError{Code: exitError, Message: err.Error()} } diff --git a/internal/cli/tabs_test.go b/internal/cli/tabs_test.go index 475ba35..4770e76 100644 --- a/internal/cli/tabs_test.go +++ b/internal/cli/tabs_test.go @@ -1,6 +1,12 @@ package cli import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -288,3 +294,44 @@ func TestBrowserTargetErrorExitCodes(t *testing.T) { }) }) } + +// stubDaemonHolding 起一个收到 /control/call 后一直不回复的假 daemon,返回请求抵达时关闭的通道。 +func stubDaemonHolding(t *testing.T) <-chan struct{} { + t.Helper() + arrived := make(chan struct{}) + mux := http.NewServeMux() + mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(control.PathCall, func(_ http.ResponseWriter, r *http.Request) { + // 读完请求体服务端才会察觉客户端断开并取消 r.Context()。 + _, _ = io.Copy(io.Discard, r.Body) + close(arrived) + <-r.Context().Done() + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://")) + dir := t.TempDir() + t.Setenv("SCTL_DATA_DIR", dir) + So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil) + return arrived +} + +func TestCancelingABrowserAction(t *testing.T) { + Convey("Ctrl-C 取消浏览器操作:退出码 2,但不声称操作已作废,因为浏览器操作即时生效、无法撤回", t, func() { + arrived := stubDaemonHolding(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + <-arrived + cancel() + }() + code, _, _, err := runCLIContext(ctx, strings.NewReader(""), "tabs", "close", "5") + So(code, ShouldEqual, exitVoided) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldNotContainSubstring, "voided") + So(err.Error(), ShouldContainSubstring, "may already") + }) +} diff --git a/internal/client/mcpserver/mcpserver.go b/internal/client/mcpserver/mcpserver.go index 772e072..e400d47 100644 --- a/internal/client/mcpserver/mcpserver.go +++ b/internal/client/mcpserver/mcpserver.go @@ -82,6 +82,13 @@ func compileInputSchema(name, inputSchema string) *jsonschema.Schema { return schema } +// defaultArguments 把省略的 arguments(MCP 允许)当作空对象,之后的校验与转发都只面对一个 JSON 对象。 +func defaultArguments(req *mcp.CallToolRequest) { + if len(req.Params.Arguments) == 0 { + req.Params.Arguments = json.RawMessage(`{}`) + } +} + // validateArguments 在转发之前按工具 schema 校验 MCP 客户端给的参数。 func validateArguments(name string, schema *jsonschema.Schema, arguments json.RawMessage) error { input, err := jsonschema.UnmarshalJSON(bytes.NewReader(arguments)) @@ -121,6 +128,7 @@ func registerTool(srv *mcp.Server, td toolDef, browserMethod bool, caller Bridge } action := td.action srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + defaultArguments(req) if err := validateArguments(td.name, schema, req.Params.Arguments); err != nil { return nil, err } @@ -162,6 +170,7 @@ func registerBrowsersListTool(srv *mcp.Server, caller BridgeCaller) { InputSchema: json.RawMessage(schemaEmpty), } srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + defaultArguments(req) if err := validateArguments(toolName, schema, req.Params.Arguments); err != nil { return nil, err } diff --git a/internal/client/mcpserver/mcpserver_test.go b/internal/client/mcpserver/mcpserver_test.go index 1654c9d..9ba85ef 100644 --- a/internal/client/mcpserver/mcpserver_test.go +++ b/internal/client/mcpserver/mcpserver_test.go @@ -3,12 +3,14 @@ package mcpserver import ( "context" "encoding/json" + "fmt" "reflect" "sync" "sync/atomic" "testing" "time" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" . "github.com/smartystreets/goconvey/convey" @@ -544,3 +546,49 @@ func TestBrowserToolsNeverReportWaitingForApproval(t *testing.T) { So(browserProgress.Load(), ShouldEqual, 0) }) } + +func TestToolsWithoutRequiredArgumentsAcceptAnOmittedArgumentsObject(t *testing.T) { + Convey("MCP 允许省略 arguments:没有必填参数的工具照常执行,按空参数对象转发", t, func() { + p := loadProto(t) + caller := &fakeCaller{ + result: control.CallResult{OK: true, Result: json.RawMessage(`{"windows":[]}`)}, + browsersList: []control.BrowserInfo{}, + } + // Go SDK 的 client 会把缺省的 arguments 补成 {},其他客户端不会;这里直接写原始 JSON-RPC 帧。 + ctx := context.Background() + st, ct := mcp.NewInMemoryTransports() + _, err := New(Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}).Connect(ctx, st, nil) + So(err, ShouldBeNil) + conn, err := ct.Connect(ctx) + So(err, ShouldBeNil) + t.Cleanup(func() { _ = conn.Close() }) + send := func(frame string) { + msg, err := jsonrpc.DecodeMessage([]byte(frame)) + So(err, ShouldBeNil) + So(conn.Write(ctx, msg), ShouldBeNil) + } + roundTrip := func(frame string) *jsonrpc.Response { + send(frame) + reply, err := conn.Read(ctx) + So(err, ShouldBeNil) + resp, ok := reply.(*jsonrpc.Response) + So(ok, ShouldBeTrue) + return resp + } + roundTrip(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"raw","version":"v0"}}}`) + send(`{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`) + + for i, name := range []string{"windows_list", "tabs_list", "scripts_list", "browsers_list"} { + resp := roundTrip(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q}}`, i+2, name)) + So(resp.Error, ShouldBeNil) + var result mcp.CallToolResult + So(json.Unmarshal(resp.Result, &result), ShouldBeNil) + So(result.IsError, ShouldBeFalse) + } + So(caller.actions, ShouldResemble, []string{"windows.list", "tabs.list", "scripts.list"}) + So(caller.browserParams, ShouldResemble, []string{"", "", ""}) + for _, input := range caller.inputs { + So(string(input), ShouldEqual, `{}`) + } + }) +} diff --git a/internal/daemon/bridge/browsers_test.go b/internal/daemon/bridge/browsers_test.go index 681a608..364e377 100644 --- a/internal/daemon/bridge/browsers_test.go +++ b/internal/daemon/bridge/browsers_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" "github.com/google/uuid" . "github.com/smartystreets/goconvey/convey" "go.uber.org/zap" @@ -198,6 +199,64 @@ func TestBrowserPairing(t *testing.T) { }) } +// pairingResponse 为实例 id 构造一个用配对码 display 签名的正确配对应答。 +func (h *testHarness) pairingResponse(display, id, nonceD string) authResponseResult { + kpMac, _, err := h.crypto.DerivePairingKeys(display) + So(err, ShouldBeNil) + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.BrowserExtHMAC(auth.ModePairing, id, kpMac, nonceD, nonceE) + return authResponseResult{Mode: modePairing, NonceE: nonceE, HMAC: mac, Peer: browserPeer(id)} +} + +// authenticatedOrClosed 读出握手应答:收到 $session.authenticated 为 true,daemon 关闭连接为 false。 +func (e *extClient) authenticatedOrClosed() bool { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, data, err := e.ws.Read(ctx) + if err != nil { + So(errors.Is(err, context.DeadlineExceeded), ShouldBeFalse) + return false + } + var msg Message + So(json.Unmarshal(data, &msg), ShouldBeNil) + So(msg.Method, ShouldEqual, methodAuthenticated) + return true +} + +func TestOnePairingCodeEnrollsOneBrowser(t *testing.T) { + Convey("两个浏览器同时用同一个配对码配对,只有一个拿到实例密钥", t, func() { + // 竞争窗口只有一次网络写那么宽,单轮未必命中;多轮里任何一轮两边都成功即说明码被用了两次。 + for range 40 { + h := startTestServer(t) + display, err := h.srv.BeginEnrollment() + So(err, ShouldBeNil) + a, challengeA, nonceA := h.challenge() + b, challengeB, nonceB := h.challenge() + respA := h.pairingResponse(display, instanceA, nonceA) + respB := h.pairingResponse(display, instanceB, nonceB) + + written := make(chan error, 1) + go func() { + message, err := newResult(challengeA.ID, respA) + if err == nil { + err = wsjson.Write(context.Background(), a.ws, message) + } + written <- err + }() + b.writeResult(challengeB.ID, respB) + So(<-written, ShouldBeNil) + + authenticated := 0 + for _, e := range []*extClient{a, b} { + if e.authenticatedOrClosed() { + authenticated++ + } + } + So(authenticated, ShouldEqual, 1) + } + }) +} + func TestBrowserInstancesCoexist(t *testing.T) { Convey("ScriptCat 与两个浏览器实例同时在线;同一实例重连只替换它自己", t, func() { h := startTestServer(t) diff --git a/internal/daemon/bridge/conn.go b/internal/daemon/bridge/conn.go index 57ebc4b..359c4c0 100644 --- a/internal/daemon/bridge/conn.go +++ b/internal/daemon/bridge/conn.go @@ -188,11 +188,11 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes if !s.enrollAttempts.Allow("enrollment") { return authFail(audit.TypePairingRateLimited, audit.ReasonPairExhausted, "too many enrollment attempts") } - code, err := s.activeEnrollmentCode() + enrollment, err := s.activeEnrollment() if err != nil { return authFail(audit.TypePairingFailed, audit.ReasonPairExpired, "%w", err) } - kpMac, kpEnc, err := s.crypto.DerivePairingKeys(code) + kpMac, kpEnc, err := s.crypto.DerivePairingKeys(enrollment.code) if err != nil { return fmt.Errorf("derive enrollment keys: %w", err) } @@ -200,6 +200,9 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes s.failEnrollmentAttempt() return authFail(audit.TypePairingFailed, audit.ReasonHMACMismatch, "enrollment handshake HMAC verification failed") } + if !s.takeEnrollment(enrollment) { + return authFail(audit.TypePairingFailed, audit.ReasonPairExpired, "enrollment window already used") + } k, err := auth.NewLongTermKey() if err != nil { @@ -220,7 +223,6 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes } else if err := s.keys.Save(k); err != nil { return fmt.Errorf("persist long-term key: %w", err) } - s.clearEnrollment() c.key = k return nil } diff --git a/internal/daemon/bridge/pairing.go b/internal/daemon/bridge/pairing.go index 0720bfc..2deed6c 100644 --- a/internal/daemon/bridge/pairing.go +++ b/internal/daemon/bridge/pairing.go @@ -32,17 +32,29 @@ func (s *Server) BeginEnrollment() (display string, err error) { return display, nil } -func (s *Server) activeEnrollmentCode() (string, error) { +func (s *Server) activeEnrollment() (*pendingEnrollment, error) { s.mu.Lock() defer s.mu.Unlock() if s.enrollment == nil { - return "", errors.New("no enrollment window in progress") + return nil, errors.New("no enrollment window in progress") } if time.Now().After(s.enrollment.expiresAt) { s.enrollment = nil - return "", errors.New("enrollment window expired") + return nil, errors.New("enrollment window expired") } - return s.enrollment.code, nil + return s.enrollment, nil +} + +// takeEnrollment 在下发密钥之前消费 e 所代表的接入窗口:配对码是一次性的,同时通过校验的两条连接 +// 只有先消费到的一条能拿到密钥。窗口已被另一条连接用掉、或已换成 sctl connect 新开的窗口时返回 false。 +func (s *Server) takeEnrollment(e *pendingEnrollment) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.enrollment != e { + return false + } + s.enrollment = nil + return true } // failEnrollmentAttempt 记一次接入失败,达到协议定义的尝试上限后作废接入窗口。 @@ -57,9 +69,3 @@ func (s *Server) failEnrollmentAttempt() { s.enrollment = nil } } - -func (s *Server) clearEnrollment() { - s.mu.Lock() - s.enrollment = nil - s.mu.Unlock() -} diff --git a/internal/protocolgen/generate.go b/internal/protocolgen/generate.go index 33dd19f..752a172 100644 --- a/internal/protocolgen/generate.go +++ b/internal/protocolgen/generate.go @@ -268,6 +268,10 @@ func validateDefinition(def definition) error { return fmt.Errorf("rpc %q has invalid blocking mode %q", name, method.Blocking) } if method.MergeField != "" { + // 只有浏览器方法会有多个同时在线的目标,daemon 也只为它们合并。 + if method.Peer != protocol.PeerBrowser { + return fmt.Errorf("rpc %q: mergeField is only meaningful for browser methods", name) + } if err := validateMergeField(def.Types[method.Result], method.MergeField); err != nil { return fmt.Errorf("rpc %q: %w", name, err) } @@ -303,13 +307,21 @@ func validateDefinition(def definition) error { return nil } -// validateMergeField 要求合并字段是结果类型中必填的数组属性,daemon 才能无条件地拼接各实例的列表。 +// validateMergeField 要求合并字段是结果类型中必填的对象数组属性,daemon 才能无条件地拼接各实例的列表; +// 合并时每一项都会加上来源浏览器的 browser 字段,项本身不能已有同名属性。 func validateMergeField(result json.RawMessage, field string) error { schema := parseSchema(result) property, ok := schema.Properties[field] if !ok || !requiredSet(schema)[field] || parseSchema(property).Type != "array" { return fmt.Errorf("mergeField %q is not a required array property of the result", field) } + items := parseSchema(parseSchema(property).Items) + if items.Type != "object" { + return fmt.Errorf("mergeField %q items must be objects", field) + } + if _, taken := items.Properties["browser"]; taken { + return fmt.Errorf("mergeField %q items must not declare the browser property the merge adds", field) + } return nil } diff --git a/internal/protocolgen/generate_test.go b/internal/protocolgen/generate_test.go index 01ae041..71c61c7 100644 --- a/internal/protocolgen/generate_test.go +++ b/internal/protocolgen/generate_test.go @@ -283,6 +283,16 @@ func TestGenerateRejectsInvalidPeerAnnotations(t *testing.T) { "merge field missing from result": func(def map[string]any) { method(def, "windows.list")["mergeField"] = "tabs" }, + "merge field whose items are not objects": func(def map[string]any) { + resultProperty(def, "TabsListResult", "tabs")["items"] = map[string]any{"type": "integer"} + }, + "merge field whose items already carry a browser property": func(def map[string]any) { + items := resultProperty(def, "TabsListResult", "tabs")["items"].(map[string]any) + items["properties"].(map[string]any)["browser"] = map[string]any{"type": "string"} + }, + "merge field on a ScriptCat method": func(def map[string]any) { + method(def, "scripts.list")["mergeField"] = "scripts" + }, "error code without peers": func(def map[string]any) { def["errorCodes"].([]any)[0].(map[string]any)["peers"] = []any{} }, @@ -326,6 +336,10 @@ func TestGenerateRejectsInvalidPeerAnnotations(t *testing.T) { } } +func resultProperty(def map[string]any, typeName, property string) map[string]any { + return def["types"].(map[string]any)[typeName].(map[string]any)["properties"].(map[string]any)[property].(map[string]any) +} + func method(def map[string]any, name string) map[string]any { return def["methods"].(map[string]any)[name].(map[string]any) }