From 16f671f6a52b1d14ab38ff660aa00e42a07ff1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:05:22 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=93=9D=20spec=EF=BC=9Adaemon=20?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=8E=A5=E5=85=A5=E5=A4=9A=E4=B8=AA=20Script?= =?UTF-8?q?Cat=20=E5=AE=9E=E4=BE=8B=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/2026-11-02-scriptcat-instances.md | 156 +++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/specs/2026-11-02-scriptcat-instances.md diff --git a/docs/specs/2026-11-02-scriptcat-instances.md b/docs/specs/2026-11-02-scriptcat-instances.md new file mode 100644 index 0000000..9a76492 --- /dev/null +++ b/docs/specs/2026-11-02-scriptcat-instances.md @@ -0,0 +1,156 @@ +# daemon 同时接入多个 ScriptCat 实例 + + + +> Status: Approved +> Owner: sctl maintainers +> Last updated: 2026-11-02 + +**目标:** 装在不同浏览器或不同浏览器配置里的 ScriptCat 能同时连上同一个 daemon,各自独立配对、独立命名、可被 +CLI 与 MCP 单独指定,互不顶替。 + +**不可退化的约束:** + +- 已发布、握手不声明实例身份的 ScriptCat 继续可用,已有配对不失效。 +- 写操作与源码披露的审批权仍在收到请求的那个 ScriptCat 实例上;daemon 不替任何实例批准写入或披露源码。 +- sctl Browser 的配对、命名、目标选择和错误码行为不变;配对或忘记一个 ScriptCat 实例不影响任何 sctl Browser + 实例,反之亦然。 +- 密钥只经 `fsutil.WriteFileAtomic` 落盘;daemon 侧不向 stdout 写任何东西。 + +## 问题 + +1. **daemon 只保留一条 ScriptCat 连接,第二个 ScriptCat 会和第一个互相顶替。** 已验证。 + `internal/daemon/bridge/server.go` 的 `setScriptCat` 只存一个连接,新连接完成登记时以 + `replaced by new connection` 关闭旧连接;`docs/protocol.md` §2.2 也写明 “A new ScriptCat connection replaces the + previous ScriptCat connection”。ScriptCat 每次握手成功都把重连间隔重置为 2 秒 + (ScriptCat `src/app/service/offscreen/external-access-connect.ts` 的 `verifyAuthOk`),被顶掉的一方 2 秒后重连 + 又顶掉另一方。issue #14 报告了普通与隐身(`"incognito": "split"`)两个进程每 2 秒互踢;本地用真实 daemon 加 + 照抄 ScriptCat 重连策略的模拟客户端复现:单实例 12 秒内 `handshake.ok` 1 次,两实例 12 秒内 7 次,`sctl status` + 显示 `handshake.ok×8`。 +2. **不同浏览器里的 ScriptCat 无法同时被管理。** 已验证。ScriptCat 只有一把全局长期密钥(`/pairing.key`), + 重新配对会覆盖它(`internal/daemon/bridge/conn.go` 中配对成功后 `s.keys.Save`),所以同一时刻只有最后配对的 + ScriptCat 能连上;`scripts.*` 调用带目标会被拒(`internal/daemon/bridge/call.go`,`a target browser does not + apply to`)。 +3. **sctl Browser 已有一套多实例模型,ScriptCat 没有对应能力。** 已验证。sctl Browser 实例按实例 ID 独立配对和 + 登记(`browsers.json`),按名称或 ID 前缀指定,列表方法可跨实例合并(`docs/protocol.md` §2.1、§3.1)。 + +## 角色与用户故事 + +1. 作为在 Chrome 和 Edge 里都装了 ScriptCat 的用户,我希望两边都能同时连着 daemon,这样我能用一个 sctl 同时管理 + 两边的脚本。 +2. 作为让 agent 管理脚本的用户,我希望 agent 能看清每个脚本来自哪个浏览器,并只对我指定的那个浏览器写入,这样 + 不会误改另一个浏览器的脚本。 +3. 作为已经配对过老版 ScriptCat 的用户,我希望升级 sctl 或 ScriptCat 后不用重新配对,这样日常使用不被打断。 + +## 设计决策 + +| # | 决策 | 依据与被否方案 | +|---|---|---| +| 1 | 按“安装”划分 ScriptCat 实例:每个浏览器/配置里的 ScriptCat 是一个实例。同一配置里的隐身进程不算独立实例,由 ScriptCat 一侧让它不连接 daemon(不在本 spec 内)。 | 用户决定。普通与隐身进程共用同一份 `chrome.storage.local`(脚本、配对密钥都在里面,issue #14 中两者用同一密钥都能握手成功即为证据),隐身进程看到的数据与普通进程完全相同。被否:隐身进程单列一个实例——列表合并出现重复,审批窗口弹进隐身窗口、窗口一关实例就下线;同一实例下按普通/隐身各留一条连接——CLI 与协议多一个维度却没有可见收益。 | +| 2 | 新版 ScriptCat 在认证响应里声明 `peer: {kind: "scriptcat", instanceId}`,HMAC 用新增的 ScriptCat 实例上下文并绑定实例 ID;能力声明带名称与自报信息。规则与 sctl Browser 对称。 | 复用已有、已在威胁模型中论证过的身份绑定方式(`docs/threat-model.md` 中 “A browser instance's MAC also binds its peer kind and instance ID”)。被否:沿用全局密钥只加一个实例标签——一个实例录下的 MAC 可被另一个实例重放,也无法单独撤销。 | +| 3 | 老版 ScriptCat(认证响应不带 `peer`)照常用 `pairing.key` 连接,作为一个“老版实例”出现;新版 ScriptCat 带实例 ID 的会话握手若未登记,就用 `pairing.key` 校验,通过后把这把密钥归到该实例下并删除 `pairing.key`。 | 用户决定(旧版照常连,升级后免重新配对)。ScriptCat 配对会覆盖 `pairing.key`,同一时刻只有一个 ScriptCat 持有有效的旧密钥,不存在两个实例争夺接管。被否:升级后要求重新配对;不兼容旧版。 | +| 4 | scripts 类命令新增 `--scriptcat <名称|ID前缀>`(环境变量 `SCTL_SCRIPTCAT`),MCP 的 scripts 类工具新增 `scriptcat` 字段;目标解析、合并与错误规则照搬 sctl Browser;`scripts.list` 可合并。 | 用户决定。规则一致降低学习成本,现有合并实现可复用。被否:与 sctl Browser 共用 `--browser`——daemon 判断不出两个扩展是否在同一配置里;多实例时一律要求指定——`list` 的常见用法变得啰嗦。 | +| 5 | ScriptCat 实例名称与 sctl Browser 名称是两套独立命名空间;名称由扩展首次配对时提议,在扩展里改名后重连生效,重名由 daemon 拒绝。 | 用户决定(照搬)。被否:合并进 `sctl browsers`——“browsers” 里出现 ScriptCat 会让人误解,两类名称也得挤进同一命名空间;固定名称不可改。 | +| 6 | 新增 `sctl scriptcats [list]` 与 `sctl scriptcats forget `;`sctl status` 逐个列出 ScriptCat 实例。 | 用户决定。与 `sctl browsers` 对称。 | +| 7 | ScriptCat 实例的登记与密钥存在新文件 `/scriptcats.json`,与 `browsers.json` 分开。 | 两套命名空间分开存放,忘记或损坏一方不波及另一方。被否:在 `browsers.json` 里加类型字段——两类实例的名称唯一性规则要在同一文件里分区处理,容易误判。 | + +## 握手与身份 + +**新版 ScriptCat 会话握手。** 前提:实例已登记。ScriptCat 在 `$session.authenticate` 的应答里带 +`peer: {kind: "scriptcat", instanceId}`,`instanceId` 为 32 位小写十六进制,HMAC 使用新增的 ScriptCat 实例会话上下文, +签名材料包含实例 ID;daemon 用该实例的密钥校验,回应同样绑定实例 ID。结果:连接以该实例身份进入能力声明。 +失败:实例 ID 格式非法、`kind` 未知、MAC 不符,或实例未登记且不满足下文的接管条件,按握手失败处理(以 1008 关闭、 +记 `handshake.failed`)。 + +**新版 ScriptCat 首次配对。** 前提:`sctl connect` 打开了接入窗口。ScriptCat 以配对模式带 `peer` 完成握手, +HMAC 使用新增的 ScriptCat 实例配对上下文并绑定实例 ID。结果:daemon 下发该实例专属的新密钥;登记在能力声明被接受 +时才落盘。失败:能力声明因重名被拒时这次配对不落盘。配对一个 ScriptCat 实例不改动 `pairing.key`、其他 ScriptCat +实例或任何 sctl Browser 实例的密钥。 + +**能力声明。** 新版 ScriptCat 在 `$session.capabilities` 里带 `peer: {name, product, productVersion, +extensionVersion}`,字段格式与 sctl Browser 相同(名称 1–32 位小写字母、数字和 `-`,其余为可选的可打印文本, +最长 64 字节)。daemon 的登记为准:名称在全部已配对 ScriptCat 实例(在线与否)以及老版实例的保留名 `scriptcat` +之间唯一;冲突时以应用错误 `CONFLICT` 应答、保留原名并断开连接。缺失或非法的 `peer` 断开连接。 + +**老版 ScriptCat。** 前提:认证响应不带 `peer`。行为与现在一致:用 `pairing.key` 校验,作为“老版实例”在线, +保留名为 `scriptcat`、没有实例 ID;新的老版连接顶替旧的老版连接,但从不顶替任何新版实例的连接。 + +**升级接管。** 前提:新版 ScriptCat 以会话模式带一个未登记的实例 ID 握手,且 `pairing.key` 存在。daemon 改用 +`pairing.key` 的密钥按 ScriptCat 实例会话上下文校验。结果:校验通过、能力声明被接受后,daemon 先把这把密钥登记到 +该实例名下,再删除 `pairing.key`;此后老版握手不再成立,若此时有老版连接在线则将其断开。失败:校验不通过按握手 +失败处理,`pairing.key` 不变;能力声明因重名被拒时不登记、不删除 `pairing.key`;登记写入成功但删除失败时, +登记保留,下次启动或下次接管检查时再删除,不出现两边都失效的状态。 + +**审计。** 声明了合法 ScriptCat 实例身份的连接,审计事件带 `client: scriptcat:`;老版连接仍为空。 + +## 路由与目标选择 + +`scripts.*` 调用可带一个 ScriptCat 目标(实例名称或实例 ID 前缀),先按名称精确匹配,没有命中时才按 ID 前缀匹配 +全部已配对实例。保留名 `scriptcat` 只按名称匹配老版实例;老版实例不参与 ID 前缀匹配。 + +| 目标 | 结果 | +|---|---| +| 未指定,没有实例在线 | 与现在 ScriptCat 未连接时相同的错误 | +| 未指定,恰好一个实例在线 | 发给该实例 | +| 未指定,多个在线,`scripts.list` | 发给全部在线实例并合并结果 | +| 未指定,多个在线,其他 `scripts.*` | `SCRIPTCAT_AMBIGUOUS`,消息列出在线实例 | +| 不匹配任何已配对实例 | `SCRIPTCAT_NOT_FOUND` | +| ID 前缀匹配多个已配对实例 | `SCRIPTCAT_AMBIGUOUS`,消息列出匹配的实例 | +| 匹配到一个未在线的实例 | `SCRIPTCAT_OFFLINE` | +| 匹配到一个在线实例 | 发给该实例 | + +合并的 `scripts.list` 规则与 sctl Browser 的合并相同:并发发给每个在线实例,结果数组按实例名称顺序拼接,每条脚本 +增加 `scriptcat: {id, name}` 标明来源(老版实例没有 `id`);任一实例失败则整个调用失败,不返回部分结果;任一实例 +`hasMore` 为真则合并结果为真。单实例调用原样返回该实例的结果。 + +`scripts.*` 调用带 sctl Browser 目标、浏览器方法带 ScriptCat 目标,都以 `INVALID_REQUEST` 拒绝。调用进行中目标连接 +断开(合并调用中任一实例断开)时调用作废,请求方收到 `OPERATION_EXPIRED`。写审批与源码披露审批由收到请求的那个 +实例弹出并决定。 + +## CLI 与 MCP + +- `get`、`grep`、`edit`、`install`、`enable`、`disable`、`delete` 接受 `--scriptcat `,未给时读 + `SCTL_SCRIPTCAT`。新增错误码与对应的 `BROWSER_*` 错误一样按一般错误退出;未给目标时的 `SCRIPTCAT_AMBIGUOUS` + 在消息后提示 `pass --scriptcat or set SCTL_SCRIPTCAT`,与 `BROWSER_AMBIGUOUS` 的提示对称。 +- MCP 中对应 `scripts.*` 的工具接受可选字符串字段 `scriptcat`,含义与 `--scriptcat` 相同。 +- 多实例合并时,`sctl get` 的表格输出增加来源实例一列;只有一个实例时输出与现在相同。 +- `sctl scriptcats [list]` 列出全部已配对 ScriptCat 实例(名称、ID、是否在线、自报产品与版本),以及存在 + `pairing.key` 时的老版实例;`-o json` 输出完整列表。 +- `sctl scriptcats forget ` 删除该实例的密钥与登记,若在线则断开;对 `scriptcat` 删除 `pairing.key` 并断开 + 老版连接。不匹配时报 `SCRIPTCAT_NOT_FOUND`。 +- `sctl status` 逐个列出 ScriptCat 实例是否在线。`-o json` 保留 `extConnected`(任一 ScriptCat 实例在线即为 + `true`),并新增 ScriptCat 实例列表。 +- `sctl connect` 的提示文案不变:同一个一次性配对码可以配对 ScriptCat 或 sctl Browser。 + +## 数据与安全 + +`/scriptcats.json` 保存每个 ScriptCat 实例的 ID、唯一名称、长期密钥和最近一次自报的产品与版本,权限与 +`browsers.json` 相同,经 `fsutil.WriteFileAtomic` 写入。`pairing.key` 降为老版实例专用,接管后删除。 +`docs/threat-model.md` 的凭据清单与审计说明、`docs/protocol.md` 的握手、能力声明与路由章节、`docs/cli.md` 的目标 +选择与退出码、`skills/sctl/` 的相应说明在同一变更中更新。 + +## 不在范围内 + +- ScriptCat 一侧的实现:声明实例身份、名称设置界面、隐身进程不连接、重连退避调整。分别在 ScriptCat 仓库中完成; + 其中“隐身进程不连接”可独立先行发布,用于修复 issue #14。 +- 把 ScriptCat 实例与同一浏览器中的 sctl Browser 实例关联为同一个目标。 +- 修改审计事件的日志级别或环形缓冲区容量(issue #14 中提到的 `handshake.ok` 占满缓冲区问题另行处理)。 + +## 测试决策 + +| 接缝 | 验证什么 | 已有先例 | +|---|---|---| +| bridge 层(模拟扩展 + 真实 `Server`) | 两个不同实例 ID 同时在线互不顶替;老版与新版共存;升级接管成功后老版握手失败、接管失败时 `pairing.key` 不变;重名 `CONFLICT` 且首次配对不落盘;forget 后会话握手失败;目标解析各分支与错误码;`scripts.list` 合并与来源标注;合并调用中一方断开时作废;审计 `client` 标签 | `internal/daemon/bridge/server_test.go`、`routing_test.go`、`merge_test.go`、`browsers_test.go` | +| 存储层 | `scriptcats.json` 的读写、名称唯一、原子写入与权限 | `internal/daemon/store` 中 `BrowserRegistry` 的测试 | +| controlapi | `/control/call` 透传 ScriptCat 目标;新错误码与 HTTP 映射;状态与实例列表、forget 接口 | `internal/daemon/controlapi/routing_test.go`、`controlapi_test.go` | +| CLI | `--scriptcat` 与 `SCTL_SCRIPTCAT`;新错误码到退出码;`sctl scriptcats` 与 `sctl status` 的输出 | `internal/cli/browsers_test.go`、`cli_test.go` | +| MCP | scripts 类工具 schema 含 `scriptcat` 并透传 | `internal/client/mcpserver` 中带 `browser` 字段的工具测试 | +| 协议 | `protocol.json` 新增上下文、错误码、`mergeField` 后生成文件一致 | `make protocol-check` | + +不提交依赖真实浏览器的自动化测试。运行层面用 `e2e/scratch/` 下的模拟客户端驱动真实 daemon,观察:两个不同实例 ID +同时在线时 `handshake.ok` 不再周期增长;老版客户端仍能连接;接管后 `pairing.key` 消失且老版握手失败。真实 ScriptCat +的端到端联调在 ScriptCat 一侧实现完成后进行。 + +## 未决问题 + +无。 From ea3b2f341461a11fb71022456b1c556da5b18e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:21:08 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=93=9D=20spec=EF=BC=9A=E5=A4=9A=20S?= =?UTF-8?q?criptCat=20=E5=AE=9E=E4=BE=8B=E8=A1=A5=E5=85=85=E4=B8=8E=20Scri?= =?UTF-8?q?ptCat=20=E7=9A=84=E5=8D=8F=E8=AE=AE=E5=90=8C=E6=AD=A5=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/2026-11-02-scriptcat-instances.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/specs/2026-11-02-scriptcat-instances.md b/docs/specs/2026-11-02-scriptcat-instances.md index 9a76492..f3452d6 100644 --- a/docs/specs/2026-11-02-scriptcat-instances.md +++ b/docs/specs/2026-11-02-scriptcat-instances.md @@ -129,10 +129,18 @@ extensionVersion}`,字段格式与 sctl Browser 相同(名称 1–32 位小 `docs/threat-model.md` 的凭据清单与审计说明、`docs/protocol.md` 的握手、能力声明与路由章节、`docs/cli.md` 的目标 选择与退出码、`skills/sctl/` 的相应说明在同一变更中更新。 +## 与 ScriptCat 的协议同步 + +新增的 ScriptCat 实例握手上下文和 `SCRIPTCAT_*` 错误码在 `protocol.json` 中标注 `peers: ["scriptcat"]`(生成器不接受 +空 `peers`),因此生成给 ScriptCat 的 TypeScript 会变化。sctl CI 要求它与固定 ScriptCat 版本中的副本逐字节一致, +ScriptCat 的一致性测试要求其错误码列表与生成结果完全一致。本变更因此包含一个配对的 ScriptCat 提交:只含 +`make protocol-sync-scriptcat` 的生成文件和错误码列表中新增的三个码,不改变 ScriptCat 的任何行为;sctl 中钉住 +ScriptCat 版本与生成摘要的地方指向这个提交。合并顺序为先 ScriptCat 同步提交,后 sctl。 + ## 不在范围内 -- ScriptCat 一侧的实现:声明实例身份、名称设置界面、隐身进程不连接、重连退避调整。分别在 ScriptCat 仓库中完成; - 其中“隐身进程不连接”可独立先行发布,用于修复 issue #14。 +- ScriptCat 一侧的行为实现:声明实例身份、名称设置界面、隐身进程不连接、重连退避调整。分别在 ScriptCat 仓库中完成; + 其中“隐身进程不连接”可独立先行发布,用于修复 issue #14。上一节的协议同步提交除外。 - 把 ScriptCat 实例与同一浏览器中的 sctl Browser 实例关联为同一个目标。 - 修改审计事件的日志级别或环形缓冲区容量(issue #14 中提到的 `handshake.ok` 占满缓冲区问题另行处理)。 From 9b4e6e3fe91ac58a2b8516ae3f11367b1344e7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:33:15 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20feat(bridge)=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E7=89=88=20ScriptCat=20=E4=BB=A5=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=8E=A5=E5=85=A5=EF=BC=8C=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E4=B8=8E=E8=80=81=E7=89=88=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=8F=AF=E5=90=8C=E6=97=B6=E5=9C=A8=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protocol.json 新增四个 ScriptCat 实例握手上下文(peers ["scriptcat"]),MAC 绑定实例 ID - auth 新增 ScriptCat*HMAC / VerifyScriptCat*HMAC - store 抽出共用的实例登记表实现,新增 /scriptcats.json(ScriptCatRegistry,保留名 scriptcat) - bridge 按实例 ID 保存新版 ScriptCat 连接,另保留一条老版连接;重名与保留名回 CONFLICT 且不落盘; 审计标注 scriptcat:;scripts.* 暂时只在恰好一个 ScriptCat 在线时路由 - 更新 ScriptCat 生成 TS 的 SHA-256 摘要,protocol.md、threat-model.md、architecture.md 同步 --- docs/architecture.md | 15 +- docs/protocol.md | 47 ++- docs/threat-model.md | 13 +- internal/daemon/auth/crypto.go | 37 ++ internal/daemon/auth/crypto_test.go | 46 +++ internal/daemon/bridge/browsers_test.go | 4 +- internal/daemon/bridge/call.go | 30 +- internal/daemon/bridge/conn.go | 73 ++-- internal/daemon/bridge/envelope.go | 11 +- internal/daemon/bridge/instances.go | 44 ++- internal/daemon/bridge/scriptcats_test.go | 366 ++++++++++++++++++ internal/daemon/bridge/server.go | 99 +++-- internal/daemon/bridge/server_test.go | 38 +- internal/daemon/component.go | 7 +- internal/daemon/controlapi/harness_test.go | 4 +- internal/daemon/store/browsers.go | 195 +--------- internal/daemon/store/registry.go | 203 ++++++++++ internal/daemon/store/scriptcats.go | 23 ++ internal/daemon/store/scriptcats_test.go | 60 +++ internal/pkg/paths/paths.go | 5 + .../protocol/generated/protocol.generated.go | 26 +- .../protocol/generated/protocol.generated.ts | 4 + internal/pkg/protocol/protocol.json | 4 + internal/protocolgen/generate_test.go | 8 +- 24 files changed, 1030 insertions(+), 332 deletions(-) create mode 100644 internal/daemon/bridge/scriptcats_test.go create mode 100644 internal/daemon/store/registry.go create mode 100644 internal/daemon/store/scriptcats.go create mode 100644 internal/daemon/store/scriptcats_test.go diff --git a/docs/architecture.md b/docs/architecture.md index acfb07a..de8735b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,10 +22,11 @@ explicitly in the foreground or let the user's system service manager own its li (`internal/client/service`) writes that service definition and drives the manager's own commands; it talks to the daemon only through the health check and the control token, to tell whether the service's daemon came up. -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 +The daemon accepts two kinds of extension connection at once: ScriptCat — any number of paired ScriptCat +instances, each identified by its own instance ID, plus at most one legacy ScriptCat connection that declares no +instance ID (a new legacy connection replaces only the previous legacy 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 @@ -170,9 +171,9 @@ internal/daemon/ # ── sctl serve side ── cdpendpoint/ # raw CDP endpoint: per-browser endpoints, /cdp/ HTTP and WS, Host/Origin/secret checks, # expiry, hand-over to and from page, the per-client Hook and Browser auth/ # mutual HMAC handshake, enrollment-code derivation (HKDF), key delivery (AES-GCM) - 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 + store/ # persistence (repository role): the legacy ScriptCat's long-term key K, plus the + # browsers.json and scriptcats.json registries of paired sctl Browser and + # ScriptCat instances and their own per-instance keys (one shared implementation) ratelimit/ # enrollment-attempt rate limiting internal/client/ # ── sctl mcp / CLI verb side ── diff --git a/docs/protocol.md b/docs/protocol.md index 6aae073..14632f9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -56,7 +56,8 @@ The daemon starts authentication with a request: } ``` -For an enrolled session, the extension answers the same `id`: +For an enrolled session, a legacy ScriptCat — one that declares no `peer`, see +[ScriptCat instances](#scriptcat-instances) — answers the same `id`: ```json { @@ -91,9 +92,11 @@ 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. +Every handshake failure — including the instance failures below — closes the connection with 1008 and no reason. + #### Browser instances -An sctl Browser instance identifies itself in the authentication response; a response without `peer` is +An sctl Browser instance identifies itself in the authentication response; a response without `peer` is a legacy ScriptCat. The instance ID is 32 lowercase hex digits, generated randomly when the extension is installed: ```json @@ -115,6 +118,24 @@ Each instance has its own session key, issued by its pairing; pairing a browser 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. +#### ScriptCat instances + +A current ScriptCat identifies itself the same way, with `"peer": {"kind": "scriptcat", "instanceId": "<32 +lowercase hex>"}`, one instance per ScriptCat installation. It uses `context.scriptcatSessionExt` / +`context.scriptcatSessionDaemon` for sessions and `context.scriptcatPairExt` / `context.scriptcatPairDaemon` for +pairing, with the same `instanceId` term, nonce order, key derivation and delivery as a browser instance. These +contexts differ from the legacy ScriptCat and `browser*` contexts, so a MAC recorded for a ScriptCat instance never +verifies for another instance, for a legacy ScriptCat, or for a browser instance. An unknown `kind`, a malformed +`instanceId`, or a MAC that does not verify fails the handshake. + +Each ScriptCat instance has its own session key, issued by its pairing; pairing one never touches the legacy key, +another ScriptCat instance's key, or any browser instance's key. A session handshake from a ScriptCat instance +that is not paired fails. + +A ScriptCat that declares no `peer` is a legacy ScriptCat: it authenticates with the formulas at the top of this +section and the single legacy key, and has no instance ID. Any number of ScriptCat instances and one legacy +ScriptCat may be connected at once. + ### 2.2 Hello and capabilities After authentication the daemon announces its product version for diagnostics. The extension does not use it @@ -144,10 +165,10 @@ The extension then declares the generated schema it uses and the business method 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 ScriptCat instance's or browser instance's new connection replaces only that instance's previous connection; a +new legacy ScriptCat connection replaces only the previous legacy ScriptCat connection. -A browser instance also declares its name and self-reported product details: +A browser instance or ScriptCat instance also declares its name and self-reported product details: ```json "params": { @@ -166,9 +187,10 @@ A browser instance also declares its name and self-reported product details: 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. +instance of the same kind, online or offline — browser and ScriptCat names are separate namespaces — and a ScriptCat +instance may not take `scriptcat`, the name reserved for the legacy ScriptCat. When the name is taken, 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 @@ -407,8 +429,10 @@ 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`. +calls go to the ScriptCat connection, browser methods go to a browser instance. A `scripts.*` call cannot yet +name a ScriptCat instance: it goes to the only connected ScriptCat, legacy or instance, and fails with `CONFLICT` +while several are connected. 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 @@ -744,7 +768,8 @@ The sctl Browser extension gates its L2 methods the same way, in its own approva `protocol.json` annotates ownership: each method and each entry of `notifications` has one `peer` (`scriptcat` or `browser`), and each `sessionMethods` entry, 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. A notification's name may not +for one peer kind never verifies as the other, and the `scriptcat*` context strings are reserved for ScriptCat-instance +handshakes, distinct from the legacy ScriptCat strings; the pairing KDF strings are shared. A notification's name may not start with `$` or reuse a method name, because both travel in the JSON-RPC `method` field. `internal` on a method is daemon-side metadata and does not reach the TypeScript output. The generator emits one set of bindings per peer: diff --git a/docs/threat-model.md b/docs/threat-model.md index 4ace450..a9c65fd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -120,7 +120,7 @@ holds or observes the address controls the browser. | 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 web page drives the raw CDP endpoint, directly or by rebinding its domain to 127.0.0.1 | `/cdp/` refuses any request carrying an `Origin` header and any request whose `Host` is not an IP literal or `localhost` (`403`); a wrong secret answers the same `404` as an unknown path, and the secret is 128 random bits | A process that is not a browser can send any `Host` and no `Origin`, so the secret in the address is the real gate | -| 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 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 or ScriptCat instance's MAC also binds its peer kind and instance ID, so a recorded MAC cannot be replayed as the legacy ScriptCat, as the other peer kind, 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, while browser control (tabs, windows, tab groups, reading list, bookmarks, history, recently closed, downloads, cookies, browsing data, and extensions in every paired sctl Browser instance), page automation (reading pages and running scripts in them), and page debugging (console and network records, headers and bodies included) are not gated at all except for L2 bookmark deletion and extension uninstall, which need approval in that browser | | 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 | @@ -130,7 +130,7 @@ holds or observes the address controls the browser. ## 3. Explicit non-goals - **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 + `scriptcats.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"); browser control and page automation have no such gate, so it can drive every paired browser and every debuggable page in it freely. @@ -175,7 +175,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 | ScriptCat's long-term key K (hex), established by enrollment | Allows impersonating ScriptCat when connecting to the daemon; still bound by write approval | +| `/pairing.key` | POSIX 0600; protected current-user DACL on Windows | The legacy ScriptCat's long-term key K (hex), established by enrollment of a ScriptCat that declares no instance ID | Allows impersonating the legacy ScriptCat when connecting to the daemon; still bound by write approval | +| `/scriptcats.json` | POSIX 0600; protected current-user DACL on Windows | The registry of paired ScriptCat instances: instance ID, unique name, each instance's long-term key (hex), and last self-reported product and versions | Allows impersonating any paired ScriptCat instance 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, while tabs and windows in every paired sctl Browser instance can be controlled directly | @@ -200,13 +201,13 @@ 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 / a browser instance that is not paired (or was forgotten) tried a session handshake | +| `handshake.failed` | Handshake HMAC verification failed / the 5s timeout elapsed / an invalid response to `$session.authenticate` was sent (including an unknown peer kind or a malformed instance ID) / a browser or ScriptCat 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. +Events for a connection that declared a well-formed instance identity carry `client: browser:` or +`client: scriptcat:`; legacy 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 2ed703b..d919cd9 100644 --- a/internal/daemon/auth/crypto.go +++ b/internal/daemon/auth/crypto.go @@ -112,6 +112,43 @@ func (c *Crypto) VerifyBrowserDaemonHMAC(mode Mode, instanceID string, key []byt return constantTimeHexEqual(c.BrowserDaemonHMAC(mode, instanceID, key, nonceD, nonceE), got) } +func (c *Crypto) scriptCatExtContext(mode Mode) string { + if mode == ModePairing { + return c.ctx[generated.CryptoContextScriptcatPairExt] + } + return c.ctx[generated.CryptoContextScriptcatSessionExt] +} + +func (c *Crypto) scriptCatDaemonContext(mode Mode) string { + if mode == ModePairing { + return c.ctx[generated.CryptoContextScriptcatPairDaemon] + } + return c.ctx[generated.CryptoContextScriptcatSessionDaemon] +} + +// ScriptCatExtHMAC 计算新版 ScriptCat 实例对 $session.authenticate 的应答 HMAC: +// context=scriptcat*Ext || instanceID || nonceD || nonceE。上下文与老版 ScriptCat、浏览器实例都不同, +// 绑定实例 ID 的理由与 BrowserExtHMAC 相同:一个实例录下的 MAC 不能冒充另一实例或老版连接重放。 +func (c *Crypto) ScriptCatExtHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE string) string { + return handshakeHMAC(key, c.scriptCatExtContext(mode)+instanceID, nonceD, nonceE) +} + +// ScriptCatDaemonHMAC 计算 daemon 对新版 ScriptCat 实例的 $session.authenticated HMAC: +// context=scriptcat*Daemon || instanceID || nonceE || nonceD。 +func (c *Crypto) ScriptCatDaemonHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE string) string { + return handshakeHMAC(key, c.scriptCatDaemonContext(mode)+instanceID, nonceE, nonceD) +} + +// VerifyScriptCatExtHMAC 恒定时间校验新版 ScriptCat 实例应答的 HMAC。 +func (c *Crypto) VerifyScriptCatExtHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE, got string) bool { + return constantTimeHexEqual(c.ScriptCatExtHMAC(mode, instanceID, key, nonceD, nonceE), got) +} + +// VerifyScriptCatDaemonHMAC 恒定时间校验 daemon 对新版 ScriptCat 实例的 HMAC(供扩展/测试使用)。 +func (c *Crypto) VerifyScriptCatDaemonHMAC(mode Mode, instanceID string, key []byte, nonceD, nonceE, got string) bool { + return constantTimeHexEqual(c.ScriptCatDaemonHMAC(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 3a80983..f423464 100644 --- a/internal/daemon/auth/crypto_test.go +++ b/internal/daemon/auth/crypto_test.go @@ -114,6 +114,52 @@ func TestBrowserHandshakeBindsInstanceIdentity(t *testing.T) { }) } +func TestScriptCatInstanceHandshakeBindsInstanceIdentity(t *testing.T) { + p, err := protocol.Load() + if err != nil { + t.Fatalf("加载协议失败: %v", err) + } + cfg := NewCrypto(p) + + Convey("新版 ScriptCat 实例握手 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 按线上公式计算:ScriptCat 实例专用 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.ScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE), ShouldEqual, want("scriptcat-rpc-v1/instance-ext", nonceD, nonceE)) + So(cfg.ScriptCatDaemonHMAC(ModeSession, idA, key, nonceD, nonceE), ShouldEqual, want("scriptcat-rpc-v1/instance-daemon", nonceE, nonceD)) + So(cfg.ScriptCatExtHMAC(ModePairing, idA, key, nonceD, nonceE), ShouldEqual, want("scriptcat-rpc-v1/instance-pair-ext", nonceD, nonceE)) + So(cfg.ScriptCatDaemonHMAC(ModePairing, idA, key, nonceD, nonceE), ShouldEqual, want("scriptcat-rpc-v1/instance-pair-daemon", nonceE, nonceD)) + }) + + Convey("为实例 A 计算的 MAC 不能冒充实例 B 通过校验", func() { + macA := cfg.ScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE, macA), ShouldBeTrue) + So(cfg.VerifyScriptCatExtHMAC(ModeSession, idB, key, nonceD, nonceE, macA), ShouldBeFalse) + okA := cfg.ScriptCatDaemonHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyScriptCatDaemonHMAC(ModeSession, idA, key, nonceD, nonceE, okA), ShouldBeTrue) + So(cfg.VerifyScriptCatDaemonHMAC(ModeSession, idB, key, nonceD, nonceE, okA), ShouldBeFalse) + }) + + Convey("ScriptCat 实例的 MAC 与老版 ScriptCat、浏览器实例的 MAC 互不通用", func() { + instanceMAC := cfg.ScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE) + So(cfg.VerifyExtHMAC(ModeSession, key, nonceD, nonceE, instanceMAC), ShouldBeFalse) + So(cfg.VerifyBrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE, instanceMAC), ShouldBeFalse) + So(cfg.VerifyScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE, cfg.ExtHMAC(ModeSession, key, nonceD, nonceE)), ShouldBeFalse) + So(cfg.VerifyScriptCatExtHMAC(ModeSession, idA, key, nonceD, nonceE, cfg.BrowserExtHMAC(ModeSession, idA, key, nonceD, nonceE)), 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 index d5c4ffa..75730ec 100644 --- a/internal/daemon/bridge/browsers_test.go +++ b/internal/daemon/bridge/browsers_test.go @@ -337,7 +337,7 @@ func (h *testHarness) busyConnection(t *testing.T, instanceID string) *extClient h.srv.mu.Lock() defer h.srv.mu.Unlock() if kind == protocol.PeerScriptCat { - h.srv.scriptCat = busy + h.srv.legacyScriptCat = busy } else { h.srv.online[instanceID] = busy } @@ -575,7 +575,7 @@ func TestBrowserPeerValidation(t *testing.T) { for _, peer := range []*authPeer{ {Kind: peerKindBrowser, InstanceID: "0123456789ABCDEF0123456789ABCDEF"}, {Kind: peerKindBrowser, InstanceID: "0123"}, - {Kind: "scriptcat", InstanceID: instanceA}, + {Kind: "firefox", InstanceID: instanceA}, } { e, challenge, nonceD := h.challenge() nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) diff --git a/internal/daemon/bridge/call.go b/internal/daemon/bridge/call.go index c8e474c..4174bae 100644 --- a/internal/daemon/bridge/call.go +++ b/internal/daemon/bridge/call.go @@ -45,11 +45,9 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { 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() - if active == nil { - return Response{}, ErrNotConnected + active, err := s.soleScriptCat() + if err != nil { + return Response{}, err } return s.callConn(ctx, active, req) case protocol.PeerBrowser: @@ -66,6 +64,28 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { } } +// soleScriptCat 返回唯一在线的 ScriptCat 连接(老版或新版实例)。调用还不能指定 ScriptCat 实例, +// 多个同时在线时任选一个会把脚本操作发到用户没想到的浏览器里,因此拒绝而不是猜。 +func (s *Server) soleScriptCat() (*conn, error) { + s.mu.Lock() + online := make([]*conn, 0, len(s.scriptCats)+1) + if s.legacyScriptCat != nil { + online = append(online, s.legacyScriptCat) + } + for _, c := range s.scriptCats { + online = append(online, c) + } + s.mu.Unlock() + switch len(online) { + case 0: + return nil, ErrNotConnected + case 1: + return online[0], nil + default: + return nil, &Error{Code: CodeConflict, Message: "several ScriptCat instances are connected; disconnect all but one"} + } +} + // callConn 把调用发给连接 c 并阻塞等待它的应答。 func (s *Server) callConn(ctx context.Context, c *conn, req Request) (Response, error) { if _, supported := c.capabilities[req.Action]; !supported { diff --git a/internal/daemon/bridge/conn.go b/internal/daemon/bridge/conn.go index 6b4dbb5..6b18692 100644 --- a/internal/daemon/bridge/conn.go +++ b/internal/daemon/bridge/conn.go @@ -38,13 +38,14 @@ type conn struct { pingID string pingAck chan struct{} - key []byte // 握手确立的长期共享密钥(ScriptCat 的 K 或浏览器实例密钥) + key []byte // 握手确立的长期共享密钥(老版 ScriptCat 的 K 或实例密钥) capabilities map[string]struct{} - // kind 默认是 ScriptCat;认证响应声明浏览器身份并通过校验后才改为浏览器。 + // kind 默认是 ScriptCat,认证响应声明的 peer 通过校验后取其类型。instanceID 为空 + // 只可能是不声明 peer 的老版 ScriptCat;声明了 peer 的连接(浏览器或新版 ScriptCat)必有实例 ID。 kind protocol.Peer instanceID string - // paired 表示浏览器实例在本连接完成了首次配对,其登记在能力声明被接受时才落盘。 + // paired 表示实例在本连接完成了首次配对,其登记在能力声明被接受时才落盘。 paired bool peer capabilitiesPeer connectedAt time.Time @@ -95,7 +96,7 @@ func (c *conn) handshake() error { return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "parse authentication response: %w", err) } if resp.Peer != nil { - if err := c.claimBrowser(*resp.Peer); err != nil { + if err := c.claimPeer(*resp.Peer); err != nil { return err } } @@ -110,48 +111,66 @@ func (c *conn) handshake() error { } } -// claimBrowser 在边界校验认证响应里声明的浏览器身份;通过后本连接按该实例选用密钥与 MAC 上下文。 -func (c *conn) claimBrowser(peer authPeer) error { - if peer.Kind != peerKindBrowser { +// claimPeer 在边界校验认证响应里声明的实例身份;通过后本连接按该实例选用密钥与 MAC 上下文。 +// 声明了 peer 却不合法的连接不会退回老版 ScriptCat 身份。 +func (c *conn) claimPeer(peer authPeer) error { + var kind protocol.Peer + switch peer.Kind { + case peerKindBrowser: + kind = protocol.PeerBrowser + case peerKindScriptCat: + kind = protocol.PeerScriptCat + default: return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "unknown peer kind") } if !instanceIDPattern.MatchString(peer.InstanceID) { - return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "malformed browser instance ID") + return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "malformed instance ID") } - c.kind = protocol.PeerBrowser + c.kind = kind c.instanceID = peer.InstanceID return nil } -// auditClient 是审计事件的客户端标注:浏览器实例标注为 browser:<实例 ID>,ScriptCat 留空。 +// isInstance 报告本连接是否声明了实例身份(浏览器实例或新版 ScriptCat);否则是老版 ScriptCat。 +func (c *conn) isInstance() bool { return c.instanceID != "" } + +// auditClient 是审计事件的客户端标注:实例标注为 <对端类型>:<实例 ID>,老版 ScriptCat 留空。 func (c *conn) auditClient() string { - if c.kind == protocol.PeerBrowser { - return "browser:" + c.instanceID + if !c.isInstance() { + return "" } - return "" + return string(c.kind) + ":" + c.instanceID } -// extMACValid 按对端类型校验扩展应答的 HMAC;浏览器实例的 MAC 绑定其实例 ID。 +// extMACValid 按对端身份校验扩展应答的 HMAC;实例的 MAC 绑定其对端类型与实例 ID。 func (c *conn) extMACValid(mode auth.Mode, key []byte, nonceD string, resp authResponseResult) bool { - if c.kind == protocol.PeerBrowser { + switch { + case !c.isInstance(): + return c.srv.crypto.VerifyExtHMAC(mode, key, nonceD, resp.NonceE, resp.HMAC) + case c.kind == protocol.PeerBrowser: return c.srv.crypto.VerifyBrowserExtHMAC(mode, c.instanceID, key, nonceD, resp.NonceE, resp.HMAC) + default: + return c.srv.crypto.VerifyScriptCatExtHMAC(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 { + switch { + case !c.isInstance(): + return c.srv.crypto.DaemonHMAC(mode, key, nonceD, nonceE) + case c.kind == protocol.PeerBrowser: return c.srv.crypto.BrowserDaemonHMAC(mode, c.instanceID, key, nonceD, nonceE) + default: + return c.srv.crypto.ScriptCatDaemonHMAC(mode, c.instanceID, key, nonceD, nonceE) } - return c.srv.crypto.DaemonHMAC(mode, key, nonceD, nonceE) } -// sessionKey 取会话握手所用的长期密钥:ScriptCat 用 pairing.key,浏览器实例用登记表里的实例密钥。 +// sessionKey 取会话握手所用的长期密钥:老版 ScriptCat 用 pairing.key,实例用其登记表里的实例密钥。 func (c *conn) sessionKey() ([]byte, error) { - if c.kind == protocol.PeerBrowser { - inst, ok := c.srv.browsers.Get(c.instanceID) + if c.isInstance() { + inst, ok := c.srv.registryFor(c.kind).Get(c.instanceID) if !ok { - return nil, authFail(audit.TypeHandshakeFailed, audit.ReasonUnknownInstance, "browser instance is not paired") + return nil, authFail(audit.TypeHandshakeFailed, audit.ReasonUnknownInstance, "%s instance is not paired", c.kind) } return inst.Key, nil } @@ -217,9 +236,9 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes 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) } - // 下发成功后再落盘,避免下发失败却持久化了扩展拿不到的密钥。浏览器实例的密钥连同名称 - // 一起写入登记表,名称随能力声明才到达,因此推迟到登记时;pairing.key 只属于 ScriptCat。 - if c.kind == protocol.PeerBrowser { + // 下发成功后再落盘,避免下发失败却持久化了扩展拿不到的密钥。实例的密钥连同名称 + // 一起写入登记表,名称随能力声明才到达,因此推迟到登记时;pairing.key 只属于老版 ScriptCat。 + if c.isInstance() { c.paired = true } else if err := s.keys.Save(k); err != nil { return fmt.Errorf("persist long-term key: %w", err) @@ -334,9 +353,9 @@ func (c *conn) receiveCapabilities() (string, error) { if payload.SchemaVersion != c.srv.proto.SchemaVersion { return "", fmt.Errorf("incompatible protocol schema version %q", payload.SchemaVersion) } - if c.kind == protocol.PeerBrowser { + if c.isInstance() { if !validCapabilitiesPeer(payload.Peer) { - return "", errors.New("browser instance declared a missing or invalid peer") + return "", fmt.Errorf("%s instance declared a missing or invalid peer", c.kind) } c.peer = *payload.Peer } diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go index 6475e96..58e0c67 100644 --- a/internal/daemon/bridge/envelope.go +++ b/internal/daemon/bridge/envelope.go @@ -69,10 +69,13 @@ type authResponseResult struct { Peer *authPeer `json:"peer,omitempty"` } -// peerKindBrowser 是浏览器实例在认证响应里声明的对端类型;不声明 peer 的连接是 ScriptCat。 -const peerKindBrowser = string(protocol.PeerBrowser) +// peerKindBrowser 与 peerKindScriptCat 是实例在认证响应里声明的对端类型;不声明 peer 的连接是老版 ScriptCat。 +const ( + peerKindBrowser = string(protocol.PeerBrowser) + peerKindScriptCat = string(protocol.PeerScriptCat) +) -// authPeer 是浏览器实例在认证响应里声明的身份,参与握手 MAC 计算并决定选用哪把实例密钥。 +// authPeer 是浏览器实例或新版 ScriptCat 在认证响应里声明的身份,参与握手 MAC 计算并决定选用哪把实例密钥。 type authPeer struct { Kind string `json:"kind"` InstanceID string `json:"instanceId"` @@ -98,7 +101,7 @@ type capabilitiesParams struct { Peer *capabilitiesPeer `json:"peer,omitempty"` } -// capabilitiesPeer 是浏览器实例在能力声明里给出的名称与自报信息;名称以 daemon 登记为准。 +// capabilitiesPeer 是实例(浏览器或新版 ScriptCat)在能力声明里给出的名称与自报信息;名称以 daemon 登记为准。 type capabilitiesPeer struct { Name string `json:"name"` Product string `json:"product,omitempty"` diff --git a/internal/daemon/bridge/instances.go b/internal/daemon/bridge/instances.go index f1a369f..4c4c46a 100644 --- a/internal/daemon/bridge/instances.go +++ b/internal/daemon/bridge/instances.go @@ -8,11 +8,19 @@ import ( "github.com/coder/websocket" "github.com/scriptscat/sctl/internal/daemon/store" + "github.com/scriptscat/sctl/internal/pkg/protocol" ) // ErrInstanceNotFound 表示引用不匹配任何已配对的浏览器实例。 var ErrInstanceNotFound = store.ErrInstanceNotFound +// instanceRegistry 是 bridge 对一类实例登记表所需的操作,BrowserRegistry 与 ScriptCatRegistry 都满足。 +type instanceRegistry interface { + Get(id string) (store.Instance, bool) + Pair(inst store.Instance) error + Update(inst store.Instance) error +} + var ( // instanceIDPattern 限定实例 ID 为定长小写 hex:它直接拼进握手 MAC 输入,定长保证拼接无歧义。 instanceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) @@ -106,9 +114,7 @@ func (s *Server) resolveRegistered(ref string) (string, bool) { return "", false } -// registerBrowser 把完成能力声明的浏览器实例写入登记表并标记在线,替换同一实例的旧连接。 -// 新配对在此首次落盘(名称随能力声明才到达);会话连接只能更新仍以同一密钥登记的实例, -// 与 ForgetInstance 由 regMu 串行,被忘记的实例无法借一条进行中的握手重新登记。 +// registerBrowser 把完成能力声明的浏览器实例登记为在线(见 registerOnline),并在替换了旧连接时通知监听者。 func (s *Server) registerBrowser(c *conn) error { replaced, err := s.registerOnline(c) if err != nil { @@ -121,11 +127,30 @@ func (s *Server) registerBrowser(c *conn) error { return nil } -// registerOnline 在 regMu 下完成登记并把 c 置为在线,报告是否替换了同一实例的旧连接。 +// registryFor 返回对端类型 kind 的实例登记表。 +func (s *Server) registryFor(kind protocol.Peer) instanceRegistry { + if kind == protocol.PeerBrowser { + return s.browsers + } + return s.scriptcats +} + +// onlineFor 返回对端类型 kind 的在线实例表;调用方须持有 s.mu。 +func (s *Server) onlineFor(kind protocol.Peer) map[string]*conn { + if kind == protocol.PeerBrowser { + return s.online + } + return s.scriptCats +} + +// registerOnline 在 regMu 下把完成能力声明的实例写入其登记表并置为在线,报告是否替换了同一实例的旧连接。 +// 新配对在此首次落盘(名称随能力声明才到达);会话连接只能更新仍以同一密钥登记的实例, +// 与忘记操作由 regMu 串行,被忘记的实例无法借一条进行中的握手重新登记。 func (s *Server) registerOnline(c *conn) (replaced bool, err error) { s.regMu.Lock() defer s.regMu.Unlock() - inst := store.BrowserInstance{ + registry := s.registryFor(c.kind) + inst := store.Instance{ ID: c.instanceID, Name: c.peer.Name, Key: c.key, @@ -134,9 +159,9 @@ func (s *Server) registerOnline(c *conn) (replaced bool, err error) { ExtensionVersion: c.peer.ExtensionVersion, } if c.paired { - err = s.browsers.Pair(inst) + err = registry.Pair(inst) } else { - err = s.browsers.Update(inst) + err = registry.Update(inst) } if err != nil { return false, err @@ -144,8 +169,9 @@ func (s *Server) registerOnline(c *conn) (replaced bool, err error) { c.connectedAt = time.Now() s.mu.Lock() - old := s.online[c.instanceID] - s.online[c.instanceID] = c + online := s.onlineFor(c.kind) + old := online[c.instanceID] + online[c.instanceID] = c s.mu.Unlock() if old != nil && old != c { old.closeInBackground(websocket.StatusNormalClosure, "replaced by new connection") diff --git a/internal/daemon/bridge/scriptcats_test.go b/internal/daemon/bridge/scriptcats_test.go new file mode 100644 index 0000000..9b9631c --- /dev/null +++ b/internal/daemon/bridge/scriptcats_test.go @@ -0,0 +1,366 @@ +package bridge + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "github.com/coder/websocket" + "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" + "github.com/scriptscat/sctl/internal/pkg/protocol" +) + +func scriptCatPeer(id string) *authPeer { + return &authPeer{Kind: string(protocol.PeerScriptCat), InstanceID: id} +} + +func scriptCatCapabilities(name string) capabilitiesParams { + return capabilitiesParams{ + SchemaVersion: "1.0.0", + Methods: []string{"scripts.list", "scripts.get"}, + Peer: &capabilitiesPeer{Name: name, Product: "Chrome", ProductVersion: "129.0", ExtensionVersion: "1.3.0"}, + } +} + +// registerScriptCat 直接在 ScriptCat 登记表里放一个已配对实例,返回其密钥。 +func (h *testHarness) registerScriptCat(id, name string) []byte { + key, _ := auth.NewLongTermKey() + So(h.scriptcats.Pair(store.ScriptCatInstance{ID: id, Name: name, Key: key}), ShouldBeNil) + return key +} + +// authenticateScriptCatInstance 以实例密钥完成新版 ScriptCat 的会话认证并消费 hello;能力声明留给调用方。 +func (h *testHarness) authenticateScriptCatInstance(id string, key []byte) *extClient { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.ScriptCatExtHMAC(auth.ModeSession, id, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: scriptCatPeer(id)}) + + ok := e.read() + So(ok.Method, ShouldEqual, methodAuthenticated) + var okp authenticatedParams + So(json.Unmarshal(ok.Params, &okp), ShouldBeNil) + So(h.crypto.VerifyScriptCatDaemonHMAC(auth.ModeSession, id, key, nonceD, nonceE, okp.HMAC), ShouldBeTrue) + So(e.read().Method, ShouldEqual, methodHello) + return e +} + +// connectScriptCatInstance 以实例密钥和名称连接一个已配对的新版 ScriptCat,要求 daemon 接受。 +func (h *testHarness) connectScriptCatInstance(id string, key []byte, name string) *extClient { + e := h.authenticateScriptCatInstance(id, key) + So(e.declare(scriptCatCapabilities(name)).Error, ShouldBeNil) + return e +} + +// enrollScriptCatInstance 用 sctl connect 打开的配对码为新版 ScriptCat 实例 id 完成配对握手并消费 hello, +// 返回连接与下发的实例密钥;能力声明留给调用方。 +func (h *testHarness) enrollScriptCatInstance(id 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.ScriptCatExtHMAC(auth.ModePairing, id, kpMac, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modePairing, NonceE: nonceE, HMAC: mac, Peer: scriptCatPeer(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.VerifyScriptCatDaemonHMAC(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) + return e, key +} + +// pairScriptCatInstance 完成新版 ScriptCat 实例的首次配对并以 name 声明能力,要求 daemon 接受。 +func (h *testHarness) pairScriptCatInstance(id, name string) (*extClient, []byte) { + e, key := h.enrollScriptCatInstance(id) + So(e.declare(scriptCatCapabilities(name)).Error, ShouldBeNil) + return e, key +} + +// closedWithPolicyViolation 断言 daemon 以 1008 关闭了这条连接(握手失败的统一关闭码)。 +func (e *extClient) closedWithPolicyViolation() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _, err := e.ws.Read(ctx) + So(err, ShouldNotBeNil) + So(errors.Is(err, context.DeadlineExceeded), ShouldBeFalse) + So(websocket.CloseStatus(err), ShouldEqual, websocket.StatusPolicyViolation) +} + +// scriptCatOnline 报告新版 ScriptCat 实例 id 当前是否有可调用的连接;连接断开由 daemon 异步发现,因此等待片刻。 +func (h *testHarness) scriptCatOnline(id string, want bool) bool { + deadline := time.Now().Add(3 * time.Second) + for { + h.srv.mu.Lock() + online := h.srv.scriptCats[id] != nil + h.srv.mu.Unlock() + if online == want || time.Now().After(deadline) { + return online + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestScriptCatInstancesCoexist(t *testing.T) { + Convey("两个不同实例 ID 的新版 ScriptCat 各自配对后同时在线,同一实例重连只替换它自己", t, func() { + h := startTestServer(t) + a, keyA := h.pairScriptCatInstance(instanceA, "work") + b, keyB := h.pairScriptCatInstance(instanceB, "home") + + a.alive() + b.alive() + So(h.srv.ExtConnected(), ShouldBeTrue) + gotA, ok := h.scriptcats.Get(instanceA) + So(ok, ShouldBeTrue) + So(gotA.Name, ShouldEqual, "work") + So(gotA.Key, ShouldResemble, keyA) + So(gotA.ExtensionVersion, ShouldEqual, "1.3.0") + gotB, _ := h.scriptcats.Get(instanceB) + So(gotB.Key, ShouldResemble, keyB) + // 新版实例的密钥只进 scriptcats.json:pairing.key 留给老版 ScriptCat。 + _, err := os.Stat(h.keyPath) + So(os.IsNotExist(err), ShouldBeTrue) + + a2 := h.connectScriptCatInstance(instanceA, keyA, "work") + a.closedByDaemon() + a2.alive() + b.alive() + + Convey("多个 ScriptCat 同时在线时 scripts 调用不随意选一个发出,而是报错", func() { + _, err := h.srv.Call(context.Background(), Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + var bridgeErr *Error + So(errors.As(err, &bridgeErr), ShouldBeTrue) + So(bridgeErr.Code, ShouldEqual, CodeConflict) + }) + + Convey("只剩一个 ScriptCat 在线时 scripts 调用发给它", func() { + b.ws.CloseNow() + So(h.scriptCatOnline(instanceB, false), ShouldBeFalse) + h.scriptsListWorks(a2) + }) + }) +} + +func TestScriptCatInstanceIsTheOnlyConnection(t *testing.T) { + Convey("只有一个新版 ScriptCat 在线(无老版连接)时 ExtConnected 为真,scripts 调用发给它", t, func() { + h := startTestServer(t) + So(h.srv.ExtConnected(), ShouldBeFalse) + key := h.registerScriptCat(instanceA, "work") + a := h.connectScriptCatInstance(instanceA, key, "work") + So(h.srv.ExtConnected(), ShouldBeTrue) + h.scriptsListWorks(a) + + a.ws.CloseNow() + So(h.scriptCatOnline(instanceA, false), ShouldBeFalse) + So(h.srv.ExtConnected(), ShouldBeFalse) + _, err := h.srv.Call(context.Background(), Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + So(err, ShouldEqual, ErrNotConnected) + }) +} + +func TestLegacyScriptCatCoexistsWithInstances(t *testing.T) { + Convey("不带 peer 的老版 ScriptCat 与新版实例同时在线;老版重连只顶替老版,新版重连不顶替老版", t, func() { + h := startTestServer(t) + legacy := h.connectScriptCat() + key := h.registerScriptCat(instanceA, "work") + a := h.connectScriptCatInstance(instanceA, key, "work") + legacy.alive() + a.alive() + + legacyKey, _, err := h.keys.Load() + So(err, ShouldBeNil) + legacy2 := h.doSessionHandshake(legacyKey) + legacy.closedByDaemon() + a.alive() + + a2 := h.connectScriptCatInstance(instanceA, key, "work") + a.closedByDaemon() + legacy2.alive() + a2.alive() + }) +} + +func TestScriptCatInstanceNameConflict(t *testing.T) { + Convey("ScriptCat 实例名称在已配对 ScriptCat 实例与老版保留名 scriptcat 之间唯一", t, func() { + h := startTestServer(t) + keyA := h.registerScriptCat(instanceA, "work") + + conflicts := func(e *extClient, name string) { + reply := e.declare(scriptCatCapabilities(name)) + So(reply.Error, ShouldNotBeNil) + So(reply.Error.Data, ShouldNotBeNil) + So(reply.Error.Data.Code, ShouldEqual, CodeConflict) + e.closedByDaemon() + } + + for _, name := range []string{"work", "scriptcat"} { + Convey("首次配对声明名称 "+name+":回复 CONFLICT、断开,登记与 pairing.key 都不落盘", func() { + e, _ := h.enrollScriptCatInstance(instanceB) + conflicts(e, name) + _, ok := h.scriptcats.Get(instanceB) + So(ok, ShouldBeFalse) + So(h.scriptcats.List(), ShouldHaveLength, 1) + _, err := os.Stat(h.keyPath) + So(os.IsNotExist(err), ShouldBeTrue) + So(h.scriptCatOnline(instanceB, false), ShouldBeFalse) + }) + } + + Convey("已配对实例改名为保留名 scriptcat:回复 CONFLICT、断开并保留原名", func() { + conflicts(h.authenticateScriptCatInstance(instanceA, keyA), "scriptcat") + got, _ := h.scriptcats.Get(instanceA) + So(got.Name, ShouldEqual, "work") + So(h.scriptCatOnline(instanceA, false), ShouldBeFalse) + }) + + Convey("与 sctl Browser 实例同名不算冲突", func() { + h.registerBrowser(instanceB, "home") + e, _ := h.enrollScriptCatInstance(instanceB) + So(e.declare(scriptCatCapabilities("home")).Error, ShouldBeNil) + got, ok := h.scriptcats.Get(instanceB) + So(ok, ShouldBeTrue) + So(got.Name, ShouldEqual, "home") + }) + }) +} + +func TestScriptCatInstanceCapabilitiesRequirePeer(t *testing.T) { + Convey("新版 ScriptCat 的能力声明缺失或带非法 peer 时连接不进入可用状态", t, func() { + h := startTestServer(t) + key := h.registerScriptCat(instanceA, "work") + for _, peer := range []*capabilitiesPeer{nil, {Name: "Work"}} { + e := h.authenticateScriptCatInstance(instanceA, key) + e.writeRequest(methodCapabilities, uuid.NewString(), capabilitiesParams{SchemaVersion: "1.0.0", Methods: []string{"scripts.list"}, Peer: peer}) + e.closedByDaemon() + } + So(h.srv.ExtConnected(), ShouldBeFalse) + }) +} + +func TestScriptCatInstanceHandshakeFailures(t *testing.T) { + Convey("新版 ScriptCat 握手失败以 1008 关闭并记 handshake.failed", t, func() { + h := startTestServer(t) + key := h.registerScriptCat(instanceA, "work") + + fail := func(peer *authPeer, mac func(nonceD, nonceE string) string) audit.Event { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac(nonceD, nonceE), Peer: peer}) + e.closedWithPolicyViolation() + So(h.srv.ExtConnected(), ShouldBeFalse) + return waitForAuditEvent(h, audit.TypeHandshakeFailed) + } + instanceMAC := func(id string, key []byte) func(string, string) string { + return func(nonceD, nonceE string) string { + return h.crypto.ScriptCatExtHMAC(auth.ModeSession, id, key, nonceD, nonceE) + } + } + + for _, id := range []string{"0123456789ABCDEF0123456789ABCDEF", "0123", ""} { + Convey("实例 ID 格式非法("+id+")", func() { + ev := fail(scriptCatPeer(id), instanceMAC(id, key)) + So(ev.Reason, ShouldEqual, audit.ReasonProtocol) + So(ev.Client, ShouldBeBlank) + }) + } + + Convey("kind 未知", func() { + ev := fail(&authPeer{Kind: "firefox", InstanceID: instanceA}, instanceMAC(instanceA, key)) + So(ev.Reason, ShouldEqual, audit.ReasonProtocol) + So(ev.Client, ShouldBeBlank) + }) + + Convey("MAC 用错密钥", func() { + wrong, _ := auth.NewLongTermKey() + ev := fail(scriptCatPeer(instanceA), instanceMAC(instanceA, wrong)) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + So(ev.Client, ShouldEqual, "scriptcat:"+instanceA) + }) + + Convey("MAC 为另一实例计算", func() { + ev := fail(scriptCatPeer(instanceA), instanceMAC(instanceB, key)) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + }) + + Convey("MAC 按老版 ScriptCat 公式计算", func() { + ev := fail(scriptCatPeer(instanceA), func(nonceD, nonceE string) string { + return h.crypto.ExtHMAC(auth.ModeSession, key, nonceD, nonceE) + }) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + }) + + Convey("MAC 按浏览器实例公式计算", func() { + ev := fail(scriptCatPeer(instanceA), func(nonceD, nonceE string) string { + return h.crypto.BrowserExtHMAC(auth.ModeSession, instanceA, key, nonceD, nonceE) + }) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + }) + + Convey("实例未登记且没有 pairing.key", func() { + ev := fail(scriptCatPeer(instanceB), instanceMAC(instanceB, key)) + So(ev.Reason, ShouldEqual, audit.ReasonUnknownInstance) + So(ev.Client, ShouldEqual, "scriptcat:"+instanceB) + }) + + Convey("实例的 MAC 去掉身份声明后不能以老版 ScriptCat 身份通过", func() { + So(h.keys.Save(key), ShouldBeNil) + ev := fail(nil, instanceMAC(instanceA, key)) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + So(ev.Client, ShouldBeBlank) + }) + }) +} + +func TestScriptCatInstanceAudit(t *testing.T) { + Convey("新版 ScriptCat 成功握手的审计事件标注 scriptcat:<实例 ID>", t, func() { + h := startTestServer(t) + + Convey("首次配对", func() { + h.pairScriptCatInstance(instanceA, "work") + So(waitForAuditEvent(h, audit.TypeHandshakeOK).Client, ShouldEqual, "scriptcat:"+instanceA) + }) + + Convey("会话握手", func() { + key := h.registerScriptCat(instanceB, "home") + h.connectScriptCatInstance(instanceB, key, "home") + So(waitForAuditEvent(h, audit.TypeHandshakeOK).Client, ShouldEqual, "scriptcat:"+instanceB) + }) + }) +} + +func TestPairingScriptCatInstanceLeavesOtherKeysAlone(t *testing.T) { + Convey("配对一个 ScriptCat 实例不改动 pairing.key、其他 ScriptCat 实例或浏览器实例的密钥", t, func() { + h := startTestServer(t) + legacy := h.connectScriptCat() + pairingKeyBefore, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + keyA := h.registerScriptCat(instanceA, "work") + browserKey := h.registerBrowser(instanceA, "chrome-0123") + + h.pairScriptCatInstance(instanceB, "home") + + pairingKeyAfter, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + So(pairingKeyAfter, ShouldResemble, pairingKeyBefore) + gotA, _ := h.scriptcats.Get(instanceA) + So(gotA.Key, ShouldResemble, keyA) + gotBrowser, _ := h.browsers.Get(instanceA) + So(gotBrowser.Key, ShouldResemble, browserKey) + legacy.alive() + }) +} diff --git a/internal/daemon/bridge/server.go b/internal/daemon/bridge/server.go index 2190d53..493d2eb 100644 --- a/internal/daemon/bridge/server.go +++ b/internal/daemon/bridge/server.go @@ -30,20 +30,21 @@ var ( ) // Server 是桥接 daemon 的 WS 服务核心:accept、双向认证握手、envelope 路由与阻塞写模型。 -// ScriptCat 只保留一条连接,新完成握手的 ScriptCat 连接替换旧连接;浏览器实例按实例 ID -// 各保留一条连接,同一实例重连只替换它自己。 +// 新版 ScriptCat 与浏览器实例按实例 ID 各保留一条连接,同一实例重连只替换它自己;不带实例 ID 的 +// 老版 ScriptCat 只保留一条连接,新的老版连接只替换旧的老版连接。 // // 信任模型是扁平的:接入(enrollment)为每个对端建立长期密钥即确立信任,CLI 与所有 MCP agent 都 // 经这些可信通道继承信任,不再逐客户端配对/铸令牌/撤销(docs/threat-model.md)。 type Server struct { - version string - proto *protocol.Protocol - crypto *auth.Crypto - keys *store.KeyStore - browsers *store.BrowserRegistry - log *zap.Logger - - // regMu 串行化浏览器实例的登记与忘记,使登记表与在线表的联合修改原子。 + version string + proto *protocol.Protocol + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + scriptcats *store.ScriptCatRegistry + log *zap.Logger + + // regMu 串行化实例的登记与忘记,使登记表与在线表的联合修改原子。 regMu sync.Mutex audit *audit.Recorder @@ -56,13 +57,16 @@ type Server struct { pingInterval time.Duration maxFrameBytes int64 - mu sync.Mutex - conns map[*conn]struct{} - scriptCat *conn - online map[string]*conn - pending map[string]*pendingCall - enrollment *pendingEnrollment - listener BrowserListener + mu sync.Mutex + conns map[*conn]struct{} + // legacyScriptCat 是不带实例 ID 的老版 ScriptCat 连接;scriptCats 与 online 分别按实例 ID + // 保存新版 ScriptCat 与浏览器实例的连接。 + legacyScriptCat *conn + scriptCats map[string]*conn + online map[string]*conn + pending map[string]*pendingCall + enrollment *pendingEnrollment + listener BrowserListener // extraListeners 是 AddBrowserListener 追加的监听者,与 listener 一同受 mu 保护。 extraListeners []BrowserListener @@ -72,8 +76,9 @@ type Server struct { shutdownOnce sync.Once } -// NewServer 用协议定义的常量与持久化后端构造服务:keys 存 ScriptCat 的长期密钥,browsers 存浏览器实例的登记与密钥。 -func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, browsers *store.BrowserRegistry, log *zap.Logger) *Server { +// NewServer 用协议定义的常量与持久化后端构造服务:keys 存老版 ScriptCat 的长期密钥(pairing.key), +// browsers 与 scriptcats 分别存浏览器实例与新版 ScriptCat 实例的登记与密钥。 +func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, browsers *store.BrowserRegistry, scriptcats *store.ScriptCatRegistry, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } @@ -83,6 +88,7 @@ func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, brows crypto: auth.NewCrypto(p), keys: keys, browsers: browsers, + scriptcats: scriptcats, log: log, audit: audit.NewRecorder(auditCapacity, log), enrollAttempts: ratelimit.NewLimiter(5, time.Minute), @@ -92,6 +98,7 @@ func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, brows pingInterval: time.Duration(p.Limits.PingIntervalMs) * time.Millisecond, maxFrameBytes: int64(p.Limits.MaxFrameBytes), conns: make(map[*conn]struct{}), + scriptCats: make(map[string]*conn), online: make(map[string]*conn), pending: make(map[string]*pendingCall), } @@ -106,11 +113,11 @@ func (s *Server) Action(name string) (protocol.Action, bool) { return a, ok } -// ExtConnected 报告当前是否有完成握手的 ScriptCat 连接。 +// ExtConnected 报告当前是否有完成握手的 ScriptCat 连接(老版或任一新版实例)。 func (s *Server) ExtConnected() bool { s.mu.Lock() defer s.mu.Unlock() - return s.scriptCat != nil + return s.legacyScriptCat != nil || len(s.scriptCats) > 0 } // AuditSnapshot 返回守卫侧安全事件的快照。 @@ -146,9 +153,12 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener, mux *http.ServeMux) func (s *Server) shutdown() { s.shutdownOnce.Do(func() { s.mu.Lock() - established := make([]*conn, 0, len(s.online)+1) - if s.scriptCat != nil { - established = append(established, s.scriptCat) + established := make([]*conn, 0, len(s.online)+len(s.scriptCats)+1) + if s.legacyScriptCat != nil { + established = append(established, s.legacyScriptCat) + } + for _, c := range s.scriptCats { + established = append(established, c) } for _, c := range s.online { established = append(established, c) @@ -248,11 +258,16 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { // register 使完成能力声明的连接可被调用。 func (s *Server) register(c *conn) error { - if c.kind == protocol.PeerBrowser { + switch { + case !c.isInstance(): + s.setLegacyScriptCat(c) + return nil + case c.kind == protocol.PeerBrowser: return s.registerBrowser(c) + default: + _, err := s.registerOnline(c) + return err } - s.setScriptCat(c) - return nil } // rejectRegistration 处理登记失败:名称冲突在认证后的通道上回 CONFLICT(握手失败本身不回显原因), @@ -260,14 +275,14 @@ func (s *Server) register(c *conn) error { 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 { + if sendErr := c.sendError(capabilitiesID, CodeConflict, "name "+c.peer.Name+" is used by another "+string(c.kind)); 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)) + s.log.Warn("failed to register extension instance", zap.String("peer", string(c.kind)), zap.Error(err)) c.close(websocket.StatusInternalError, "") } } @@ -292,26 +307,30 @@ func (s *Server) newConn(ws *websocket.Conn) *conn { func (s *Server) removeConn(c *conn) { s.mu.Lock() delete(s.conns, c) - if s.scriptCat == c { - s.scriptCat = nil + if s.legacyScriptCat == c { + s.legacyScriptCat = nil } - wasOnline := c.kind == protocol.PeerBrowser && s.online[c.instanceID] == c - if wasOnline { - delete(s.online, c.instanceID) + wasOnline := false + if c.isInstance() { + online := s.onlineFor(c.kind) + if wasOnline = online[c.instanceID] == c; wasOnline { + delete(online, c.instanceID) + } } s.mu.Unlock() c.close(websocket.StatusNormalClosure, "") - // 被替换或被忘记的连接早已移出在线表并通知过,这里只处理自己断开的那一条。 - if wasOnline { + // 被替换或被忘记的连接早已移出在线表并通知过,这里只处理自己断开的那一条; + // 浏览器监听者只关心浏览器实例。 + if wasOnline && c.kind == protocol.PeerBrowser { s.instanceGone(c.instanceID) } } -// setScriptCat 将 c 设为唯一的 ScriptCat 连接,替换并断开旧的 ScriptCat 连接。 -func (s *Server) setScriptCat(c *conn) { +// setLegacyScriptCat 将 c 设为唯一的老版 ScriptCat 连接,替换并断开旧的老版连接;新版实例的连接不受影响。 +func (s *Server) setLegacyScriptCat(c *conn) { s.mu.Lock() - old := s.scriptCat - s.scriptCat = c + old := s.legacyScriptCat + s.legacyScriptCat = c s.mu.Unlock() if old != nil && old != c { old.closeInBackground(websocket.StatusNormalClosure, "replaced by new connection") diff --git a/internal/daemon/bridge/server_test.go b/internal/daemon/bridge/server_test.go index e6c9f5e..f28e50b 100644 --- a/internal/daemon/bridge/server_test.go +++ b/internal/daemon/bridge/server_test.go @@ -24,13 +24,14 @@ import ( // testHarness 承载一个运行中的 daemon 与用于对拍的密码学助手/存储。 type testHarness struct { - srv *Server - crypto *auth.Crypto - keys *store.KeyStore - browsers *store.BrowserRegistry - keyPath string - url string - proto *protocol.Protocol + srv *Server + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + scriptcats *store.ScriptCatRegistry + keyPath string + url string + proto *protocol.Protocol } func startTestServer(t *testing.T) *testHarness { @@ -42,7 +43,9 @@ func startTestServer(t *testing.T) *testHarness { keys := store.NewKeyStore(keyPath) browsers, err := store.LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) So(err, ShouldBeNil) - srv := NewServer("0.1.0", p, keys, browsers, zap.NewNop()) + scriptcats, err := store.LoadScriptCatRegistry(filepath.Join(dir, "scriptcats.json")) + So(err, ShouldBeNil) + srv := NewServer("0.1.0", p, keys, browsers, scriptcats, zap.NewNop()) // daemon 首次校验帧时才编译 schema,这笔一次性开销在高负载下能拖过读消息的 3s 时限;先在时限外付掉。 So(protocolschema.ValidateWireFrame([]byte(`{"jsonrpc":"2.0","id":"warm-up","method":"$session.ping","params":{}}`)), ShouldBeNil) @@ -53,13 +56,14 @@ func startTestServer(t *testing.T) *testHarness { go func() { _ = srv.Serve(ctx, ln, nil) }() return &testHarness{ - srv: srv, - crypto: auth.NewCrypto(p), - keys: keys, - browsers: browsers, - keyPath: keyPath, - url: "ws://" + ln.Addr().String() + "/", - proto: p, + srv: srv, + crypto: auth.NewCrypto(p), + keys: keys, + browsers: browsers, + scriptcats: scriptcats, + keyPath: keyPath, + url: "ws://" + ln.Addr().String() + "/", + proto: p, } } @@ -177,7 +181,7 @@ func TestConnectionIsRegisteredBeforeCapabilitiesReply(t *testing.T) { early, reply := h.declareWithRegistrationFrozen(e, capabilitiesParams{SchemaVersion: "1.0.0", Methods: h.allMethods()}, - func() bool { return h.srv.scriptCat != nil }) + func() bool { return h.srv.legacyScriptCat != nil }) So(early, ShouldBeFalse) So(reply.Error, ShouldBeNil) So(reply.ID, ShouldNotBeBlank) @@ -232,7 +236,7 @@ func TestSessionHandshakeFlow(t *testing.T) { _, _, err := e.ws.Read(ctx) So(err, ShouldNotBeNil) h.srv.mu.Lock() - active := h.srv.scriptCat + active := h.srv.legacyScriptCat h.srv.mu.Unlock() So(active, ShouldBeNil) }) diff --git a/internal/daemon/component.go b/internal/daemon/component.go index 00ed42b..bb0e4d1 100644 --- a/internal/daemon/component.go +++ b/internal/daemon/component.go @@ -60,7 +60,12 @@ func (b *daemonComponent) StartCancel(ctx context.Context, cancel context.Cancel logger.Ctx(ctx).Error("failed to load the browser instance registry", zap.Error(err)) return err } - b.srv = bridge.NewServer(b.version, p, keys, browsers, logger.Ctx(ctx)) + scriptcats, err := store.LoadScriptCatRegistry(paths.ScriptCatsFile()) + if err != nil { + logger.Ctx(ctx).Error("failed to load the ScriptCat instance registry", zap.Error(err)) + return err + } + b.srv = bridge.NewServer(b.version, p, keys, browsers, scriptcats, logger.Ctx(ctx)) // 同步 net.Listen 使绑定失败在启动阶段即暴露(cago 会 panic,符合 fail-fast 约定)。 ln, err := net.Listen("tcp", b.address) diff --git a/internal/daemon/controlapi/harness_test.go b/internal/daemon/controlapi/harness_test.go index 0be0400..eb99f2d 100644 --- a/internal/daemon/controlapi/harness_test.go +++ b/internal/daemon/controlapi/harness_test.go @@ -89,7 +89,9 @@ func startTestServer(t *testing.T) *testHarness { keys := store.NewKeyStore(filepath.Join(dir, "pairing.key")) browsers, err := store.LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) So(err, ShouldBeNil) - srv := bridge.NewServer(testVersion, p, keys, browsers, zap.NewNop()) + scriptcats, err := store.LoadScriptCatRegistry(filepath.Join(dir, "scriptcats.json")) + So(err, ShouldBeNil) + srv := bridge.NewServer(testVersion, p, keys, browsers, scriptcats, zap.NewNop()) // daemon 首次校验帧时才编译 schema,这笔一次性开销在高负载下能拖过读消息的 3s 时限;先在时限外付掉。 So(protocolschema.ValidateWireFrame([]byte(`{"jsonrpc":"2.0","id":"warm-up","method":"$session.ping","params":{}}`)), ShouldBeNil) diff --git a/internal/daemon/store/browsers.go b/internal/daemon/store/browsers.go index 8e6d2a5..b3a0719 100644 --- a/internal/daemon/store/browsers.go +++ b/internal/daemon/store/browsers.go @@ -1,197 +1,18 @@ package store -import ( - "bytes" - "crypto/subtle" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "sync" +// BrowserInstance 是一个已配对的 sctl Browser 实例。 +type BrowserInstance = Instance - "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 是该文件唯一的写者, -// 因此启动时加载一次、之后由内存副本应答读取;每次修改先原子落盘成功再更新内存。 +// BrowserRegistry 以 0600 文件(browsers.json)持久化已配对的 sctl Browser 实例及其密钥。 type BrowserRegistry struct { - path string - - mu sync.Mutex - instances map[string]BrowserInstance + *instanceRegistry } -// LoadBrowserRegistry 加载 path 处的登记表;文件不存在视为空表,内容损坏则报错。 +// 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 - } + r, err := loadInstanceRegistry(path, "browser", "") 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) + return nil, 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 + return &BrowserRegistry{r}, nil } diff --git a/internal/daemon/store/registry.go b/internal/daemon/store/registry.go new file mode 100644 index 0000000..04223fd --- /dev/null +++ b/internal/daemon/store/registry.go @@ -0,0 +1,203 @@ +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("instance name is already taken") + // ErrInstanceNotFound 表示实例未登记,或已不再以调用方持有的密钥登记。 + ErrInstanceNotFound = errors.New("instance not found") +) + +// Instance 是一个已配对的扩展实例(sctl Browser 或新版 ScriptCat):每实例一把长期密钥, +// 名称在同一登记表的已配对实例中唯一。产品与版本是最近一次连接时的自报信息,离线时仍可列出。 +type Instance struct { + ID string + Name string + Key []byte + Product string + ProductVersion string + ExtensionVersion string +} + +type instanceRecord 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 instanceFile struct { + Instances []instanceRecord `json:"instances"` +} + +// instanceRegistry 以 0600 文件持久化一类已配对实例及其密钥,是 BrowserRegistry 与 ScriptCatRegistry +// 的共同实现。daemon 是该文件唯一的写者,因此启动时加载一次、之后由内存副本应答读取; +// 每次修改先原子落盘成功再更新内存。 +type instanceRegistry struct { + path string + label string + // reserved 是任何实例都不能占用的名称(空表示没有)。 + reserved string + + mu sync.Mutex + instances map[string]Instance +} + +func loadInstanceRegistry(path, label, reserved string) (*instanceRegistry, error) { + r := &instanceRegistry{path: path, label: label, reserved: reserved, instances: make(map[string]Instance)} + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return r, nil + } + if err != nil { + return nil, fmt.Errorf("read %s registry: %w", label, err) + } + var file instanceFile + if err := json.Unmarshal(raw, &file); err != nil { + return nil, fmt.Errorf("parse %s registry: %w", label, err) + } + for _, rec := range file.Instances { + key, err := hex.DecodeString(rec.Key) + if err != nil { + return nil, fmt.Errorf("parse key of %s instance %s: %w", label, rec.ID, err) + } + r.instances[rec.ID] = Instance{ + ID: rec.ID, + Name: rec.Name, + Key: key, + Product: rec.Product, + ProductVersion: rec.ProductVersion, + ExtensionVersion: rec.ExtensionVersion, + } + } + return r, nil +} + +// Get 返回已登记实例的副本。 +func (r *instanceRegistry) Get(id string) (Instance, bool) { + r.mu.Lock() + defer r.mu.Unlock() + inst, ok := r.instances[id] + return cloneInstance(inst), ok +} + +// List 返回按名称排序的全部已登记实例副本。 +func (r *instanceRegistry) List() []Instance { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Instance, 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 *instanceRegistry) Pair(inst Instance) 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 *instanceRegistry) Update(inst Instance) 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 *instanceRegistry) 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 *instanceRegistry) nameTakenLocked(id, name string) bool { + if r.reserved != "" && name == r.reserved { + return true + } + for otherID, other := range r.instances { + if otherID != id && other.Name == name { + return true + } + } + return false +} + +// commitLocked 以 inst 替换(nil 则删除)id 的登记,先原子落盘,成功后才改内存。 +func (r *instanceRegistry) commitLocked(id string, inst *Instance) error { + next := make(map[string]Instance, 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 *instanceRegistry) write(instances map[string]Instance) error { + file := instanceFile{Instances: make([]instanceRecord, 0, len(instances))} + for _, inst := range instances { + file.Instances = append(file.Instances, instanceRecord{ + 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 %s registry: %w", r.label, err) + } + if err := os.MkdirAll(filepath.Dir(r.path), 0o700); err != nil { + return fmt.Errorf("create %s registry directory: %w", r.label, err) + } + return fsutil.WriteFileAtomic(r.path, raw, 0o600) +} + +func cloneInstance(inst Instance) Instance { + inst.Key = bytes.Clone(inst.Key) + return inst +} diff --git a/internal/daemon/store/scriptcats.go b/internal/daemon/store/scriptcats.go new file mode 100644 index 0000000..df44bba --- /dev/null +++ b/internal/daemon/store/scriptcats.go @@ -0,0 +1,23 @@ +package store + +// ReservedScriptCatName 是不带实例 ID 的老版 ScriptCat 的保留名:它不在登记表里, +// 但与新版实例出现在同一份列表与目标选择中,新版实例不能占用这个名称。 +const ReservedScriptCatName = "scriptcat" + +// ScriptCatInstance 是一个已配对的新版 ScriptCat 实例。 +type ScriptCatInstance = Instance + +// ScriptCatRegistry 以 0600 文件(scriptcats.json)持久化已配对的新版 ScriptCat 实例及其密钥。 +// 与 browsers.json 分开存放:两类实例的名称是独立的命名空间,忘记或损坏一方不波及另一方。 +type ScriptCatRegistry struct { + *instanceRegistry +} + +// LoadScriptCatRegistry 加载 path 处的 ScriptCat 实例登记表;文件不存在视为空表,内容损坏则报错。 +func LoadScriptCatRegistry(path string) (*ScriptCatRegistry, error) { + r, err := loadInstanceRegistry(path, "ScriptCat", ReservedScriptCatName) + if err != nil { + return nil, err + } + return &ScriptCatRegistry{r}, nil +} diff --git a/internal/daemon/store/scriptcats_test.go b/internal/daemon/store/scriptcats_test.go new file mode 100644 index 0000000..f45e7ed --- /dev/null +++ b/internal/daemon/store/scriptcats_test.go @@ -0,0 +1,60 @@ +package store + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestScriptCatRegistry(t *testing.T) { + Convey("ScriptCat 实例登记表", t, func() { + dir := t.TempDir() + path := filepath.Join(dir, "scriptcats.json") + reg, err := LoadScriptCatRegistry(path) + So(err, ShouldBeNil) + + Convey("配对登记后重新加载仍能读回实例与密钥,文件权限 0600", func() { + a := newInstance("0123456789abcdef0123456789abcdef", "work") + So(reg.Pair(a), ShouldBeNil) + + reloaded, err := LoadScriptCatRegistry(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("老版实例的保留名 scriptcat 不能被新版实例占用,配对与改名都不落盘", func() { + So(reg.Pair(newInstance("0123456789abcdef0123456789abcdef", ReservedScriptCatName)), ShouldEqual, ErrNameTaken) + _, statErr := os.Stat(path) + So(os.IsNotExist(statErr), ShouldBeTrue) + + a := newInstance("0123456789abcdef0123456789abcdef", "work") + So(reg.Pair(a), ShouldBeNil) + renamed := a + renamed.Name = ReservedScriptCatName + So(reg.Update(renamed), ShouldEqual, ErrNameTaken) + got, _ := reg.Get(a.ID) + So(got.Name, ShouldEqual, "work") + }) + + Convey("名称只在 ScriptCat 实例之间唯一,与浏览器实例的名称互不影响", func() { + browsers, err := LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) + So(err, ShouldBeNil) + So(browsers.Pair(newInstance("0123456789abcdef0123456789abcdef", "work")), ShouldBeNil) + So(reg.Pair(newInstance("fedcba9876543210fedcba9876543210", "work")), ShouldBeNil) + So(reg.Pair(newInstance("00000000000000000000000000000000", "work")), ShouldEqual, ErrNameTaken) + So(reg.List(), ShouldHaveLength, 1) + So(browsers.List(), ShouldHaveLength, 1) + }) + }) +} diff --git a/internal/pkg/paths/paths.go b/internal/pkg/paths/paths.go index 437e572..01dff6c 100644 --- a/internal/pkg/paths/paths.go +++ b/internal/pkg/paths/paths.go @@ -53,6 +53,11 @@ func BrowsersFile() string { return filepath.Join(DataDir(), "browsers.json") } +// ScriptCatsFile 返回已配对新版 ScriptCat 实例登记表(含每实例密钥)的落盘路径(0600)。 +func ScriptCatsFile() string { + return filepath.Join(DataDir(), "scriptcats.json") +} + // ControlTokenFile 返回本机内部控制通道凭据的落盘路径(0600)。daemon 绑定端口后写入, // 同用户的前端(sctl mcp / CLI 动词)读取后作为控制 API 的鉴权凭据。 func ControlTokenFile() string { diff --git a/internal/pkg/protocol/generated/protocol.generated.go b/internal/pkg/protocol/generated/protocol.generated.go index aaf2371..2ebcf59 100644 --- a/internal/pkg/protocol/generated/protocol.generated.go +++ b/internal/pkg/protocol/generated/protocol.generated.go @@ -1081,15 +1081,19 @@ const ( // 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" + CryptoContextBrowserPairDaemon = "browserPairDaemon" + CryptoContextBrowserPairExt = "browserPairExt" + CryptoContextBrowserSessionDaemon = "browserSessionDaemon" + CryptoContextBrowserSessionExt = "browserSessionExt" + CryptoContextPairDaemon = "pairDaemon" + CryptoContextPairExt = "pairExt" + CryptoContextPairKdfInfoEnc = "pairKdfInfoEnc" + CryptoContextPairKdfInfoMac = "pairKdfInfoMac" + CryptoContextPairKdfSalt = "pairKdfSalt" + CryptoContextScriptcatPairDaemon = "scriptcatPairDaemon" + CryptoContextScriptcatPairExt = "scriptcatPairExt" + CryptoContextScriptcatSessionDaemon = "scriptcatSessionDaemon" + CryptoContextScriptcatSessionExt = "scriptcatSessionExt" + CryptoContextSessionDaemon = "sessionDaemon" + CryptoContextSessionExt = "sessionExt" ) diff --git a/internal/pkg/protocol/generated/protocol.generated.ts b/internal/pkg/protocol/generated/protocol.generated.ts index 79da6d3..4631a48 100644 --- a/internal/pkg/protocol/generated/protocol.generated.ts +++ b/internal/pkg/protocol/generated/protocol.generated.ts @@ -37,6 +37,10 @@ export const CRYPTO = { "sessionDaemon": "scriptcat-rpc-v1/daemon", "pairExt": "scriptcat-rpc-v1/pair-ext", "pairDaemon": "scriptcat-rpc-v1/pair-daemon", + "scriptcatSessionExt": "scriptcat-rpc-v1/instance-ext", + "scriptcatSessionDaemon": "scriptcat-rpc-v1/instance-daemon", + "scriptcatPairExt": "scriptcat-rpc-v1/instance-pair-ext", + "scriptcatPairDaemon": "scriptcat-rpc-v1/instance-pair-daemon", "pairKdfSalt": "scriptcat-rpc-v1/pair-salt", "pairKdfInfoMac": "scriptcat-rpc-v1/pair-mac", "pairKdfInfoEnc": "scriptcat-rpc-v1/pair-enc" diff --git a/internal/pkg/protocol/protocol.json b/internal/pkg/protocol/protocol.json index c57718f..60ba3e6 100644 --- a/internal/pkg/protocol/protocol.json +++ b/internal/pkg/protocol/protocol.json @@ -3927,6 +3927,10 @@ "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"] }, + "scriptcatSessionExt": { "value": "scriptcat-rpc-v1/instance-ext", "peers": ["scriptcat"] }, + "scriptcatSessionDaemon": { "value": "scriptcat-rpc-v1/instance-daemon", "peers": ["scriptcat"] }, + "scriptcatPairExt": { "value": "scriptcat-rpc-v1/instance-pair-ext", "peers": ["scriptcat"] }, + "scriptcatPairDaemon": { "value": "scriptcat-rpc-v1/instance-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"] }, diff --git a/internal/protocolgen/generate_test.go b/internal/protocolgen/generate_test.go index 75b9365..d1da49a 100644 --- a/internal/protocolgen/generate_test.go +++ b/internal/protocolgen/generate_test.go @@ -164,10 +164,10 @@ func TestGenerateProducesStronglyTypedRPCContracts(t *testing.T) { } } -// 这两个摘要是 ScriptCat 固定版本(CI 的 SCRIPTCAT_REF)所用生成文件的逐字节身份; -// 只有 ScriptCat 协议真的变化并同步到 ScriptCat 时才允许改动。 +// 这两个摘要是生成给 ScriptCat 的文件的逐字节身份,必须与 ScriptCat 固定版本(CI 的 SCRIPTCAT_REF) +// 里的副本一致;只有 ScriptCat 协议真的变化(如新增 ScriptCat 实例握手上下文)并同步到 ScriptCat 时才允许改动。 const ( - scriptCatProtocolSHA256 = "df47fd341242836501b2497b665a406f98152ce1baa9d6955981e003527117e8" + scriptCatProtocolSHA256 = "7e4b47356310a28ae181a08260d294a57b5fc264deeb410b7d5847a01e7d0e2d" scriptCatValidatorsSHA256 = "2b55453161a593907759c4be7d9a2caa09f71aed7155b3c840f93d33d04896bb" ) @@ -223,7 +223,7 @@ func TestGenerateWritesBrowserTypeScriptWithOnlyBrowserContract(t *testing.T) { } } // 未解析的 schema 渲染为裸 unknown;开放对象声明的 Record 是有意的,不在此列。 - for _, unwanted := range []string{"scripts.list", "ScriptsListParams", `"sessionExt"`, ": unknown;", "", "unknown[]"} { + for _, unwanted := range []string{"scripts.list", "ScriptsListParams", `"sessionExt"`, `"scriptcatSessionExt"`, ": unknown;", "", "unknown[]"} { if strings.Contains(typescript, unwanted) { t.Errorf("browser TypeScript contains ScriptCat-only or unresolved %q", unwanted) } From 45c8d7842a0e9af9b5b6a9690c109de0e3cc5f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:41:58 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20feat(bridge)=EF=BC=9A?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=90=8E=E7=9A=84=20ScriptCat=20=E4=BB=A5?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E8=BA=AB=E4=BB=BD=E6=8E=A5=E7=AE=A1=20pairin?= =?UTF-8?q?g.key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未登记的新版 ScriptCat 实例在 pairing.key 存在时用其密钥按 ScriptCat 实例会话上下文校验; 能力声明被接受后先登记到 scriptcats.json,再删除 pairing.key,并断开在线的老版连接 - MAC 不符按握手失败处理、CONFLICT 不登记,两者都保留 pairing.key - 删除失败时登记保留;pairing.key 的密钥已属于某个实例即视为不存在,在启动与之后每次读取老版密钥时补删 - 接管、老版登记与老版密钥写入都在 regMu 下串行,接管前已握手的老版连接无法上线 - 新增 KeyStore.Delete(文件不存在不算错误);protocol.md §2.1 与 threat-model.md §5 同步 --- docs/protocol.md | 13 +- docs/threat-model.md | 2 +- internal/daemon/bridge/conn.go | 30 ++- internal/daemon/bridge/instances.go | 19 +- internal/daemon/bridge/scriptcats_test.go | 6 +- internal/daemon/bridge/server.go | 18 +- internal/daemon/bridge/server_test.go | 9 +- internal/daemon/bridge/takeover.go | 95 ++++++++++ internal/daemon/bridge/takeover_test.go | 218 ++++++++++++++++++++++ internal/daemon/component.go | 4 + internal/daemon/store/keystore.go | 8 + internal/daemon/store/store_test.go | 14 ++ 12 files changed, 414 insertions(+), 22 deletions(-) create mode 100644 internal/daemon/bridge/takeover.go create mode 100644 internal/daemon/bridge/takeover_test.go diff --git a/docs/protocol.md b/docs/protocol.md index 14632f9..5544235 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -130,7 +130,18 @@ verifies for another instance, for a legacy ScriptCat, or for a browser instance Each ScriptCat instance has its own session key, issued by its pairing; pairing one never touches the legacy key, another ScriptCat instance's key, or any browser instance's key. A session handshake from a ScriptCat instance -that is not paired fails. +that is not paired fails, unless it is an upgrade takeover. + +**Upgrade takeover.** A ScriptCat that was paired as a legacy ScriptCat keeps the legacy key after it upgrades. When +a ScriptCat instance whose `instanceId` is not paired sends a session handshake while `/pairing.key` exists, +the daemon verifies its MAC with the legacy key under the `scriptcat*` session contexts above (never the legacy +contexts). A MAC that does not verify fails the handshake and leaves `pairing.key` untouched. Once the instance's +capabilities are accepted, the daemon registers the legacy key as that instance's key in `scriptcats.json`, then +deletes `pairing.key` and closes a connected legacy ScriptCat. A name `CONFLICT` registers nothing and keeps +`pairing.key`. From then on a legacy handshake does not verify, and a legacy connection that authenticated before the +takeover but had not yet declared capabilities is closed when it does. If the registry write succeeds but the +delete fails, the registration stands, the leftover file is treated as absent, and the delete is retried at the next +daemon start and at every later handshake that reads the legacy key. A ScriptCat that declares no `peer` is a legacy ScriptCat: it authenticates with the formulas at the top of this section and the single legacy key, and has no instance ID. Any number of ScriptCat instances and one legacy diff --git a/docs/threat-model.md b/docs/threat-model.md index a9c65fd..85b868e 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -175,7 +175,7 @@ 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 legacy ScriptCat's long-term key K (hex), established by enrollment of a ScriptCat that declares no instance ID | Allows impersonating the legacy ScriptCat when connecting to the daemon; still bound by write approval | +| `/pairing.key` | POSIX 0600; protected current-user DACL on Windows | The legacy ScriptCat's long-term key K (hex), established by enrollment of a ScriptCat that declares no instance ID; it authenticates only the legacy ScriptCat, plus one takeover by that ScriptCat after it upgrades. When the upgraded ScriptCat takes it over ([protocol §2.1](./protocol.md#scriptcat-instances)), K moves into `scriptcats.json` under that instance and this file is deleted (a failed delete is retried, and the leftover file no longer authenticates anyone) | Allows impersonating the legacy ScriptCat when connecting to the daemon, or taking over as a not-yet-paired ScriptCat instance while the file exists; still bound by write approval | | `/scriptcats.json` | POSIX 0600; protected current-user DACL on Windows | The registry of paired ScriptCat instances: instance ID, unique name, each instance's long-term key (hex), and last self-reported product and versions | Allows impersonating any paired ScriptCat instance 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, while tabs and windows in every paired sctl Browser instance can be controlled directly | diff --git a/internal/daemon/bridge/conn.go b/internal/daemon/bridge/conn.go index 6b18692..f4fe7f8 100644 --- a/internal/daemon/bridge/conn.go +++ b/internal/daemon/bridge/conn.go @@ -46,7 +46,10 @@ type conn struct { kind protocol.Peer instanceID string // paired 表示实例在本连接完成了首次配对,其登记在能力声明被接受时才落盘。 - paired bool + paired bool + // takeover 表示未登记的新版 ScriptCat 实例改用 pairing.key 的密钥做会话握手(升级接管), + // 接管在能力声明被接受时才落盘。 + takeover bool peer capabilitiesPeer connectedAt time.Time } @@ -166,17 +169,28 @@ func (c *conn) daemonMAC(mode auth.Mode, key []byte, nonceD, nonceE string) stri } // sessionKey 取会话握手所用的长期密钥:老版 ScriptCat 用 pairing.key,实例用其登记表里的实例密钥。 +// 未登记的新版 ScriptCat 实例在 pairing.key 存在时改用它的密钥:这是升级前以老版身份配对的同一个 ScriptCat, +// 免去重新配对;MAC 仍按 ScriptCat 实例上下文校验,老版录下的 MAC 不能借此通过。 func (c *conn) sessionKey() ([]byte, error) { if c.isInstance() { - inst, ok := c.srv.registryFor(c.kind).Get(c.instanceID) - if !ok { - return nil, authFail(audit.TypeHandshakeFailed, audit.ReasonUnknownInstance, "%s instance is not paired", c.kind) + if inst, ok := c.srv.registryFor(c.kind).Get(c.instanceID); ok { + return inst.Key, nil } - return inst.Key, nil + if c.kind == protocol.PeerScriptCat { + key, ok, err := c.srv.legacyKey() + if err != nil { + return nil, err + } + if ok { + c.takeover = true + return key, nil + } + } + return nil, authFail(audit.TypeHandshakeFailed, audit.ReasonUnknownInstance, "%s instance is not paired", c.kind) } - key, ok, err := c.srv.keys.Load() + key, ok, err := c.srv.legacyKey() if err != nil { - return nil, fmt.Errorf("load long-term key: %w", err) + return nil, err } if !ok { return nil, errors.New("not paired yet, no long-term key") @@ -240,7 +254,7 @@ func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authRes // 一起写入登记表,名称随能力声明才到达,因此推迟到登记时;pairing.key 只属于老版 ScriptCat。 if c.isInstance() { c.paired = true - } else if err := s.keys.Save(k); err != nil { + } else if err := s.saveLegacyKey(k); err != nil { return fmt.Errorf("persist long-term key: %w", err) } c.key = k diff --git a/internal/daemon/bridge/instances.go b/internal/daemon/bridge/instances.go index 4c4c46a..f22f361 100644 --- a/internal/daemon/bridge/instances.go +++ b/internal/daemon/bridge/instances.go @@ -144,8 +144,9 @@ func (s *Server) onlineFor(kind protocol.Peer) map[string]*conn { } // registerOnline 在 regMu 下把完成能力声明的实例写入其登记表并置为在线,报告是否替换了同一实例的旧连接。 -// 新配对在此首次落盘(名称随能力声明才到达);会话连接只能更新仍以同一密钥登记的实例, -// 与忘记操作由 regMu 串行,被忘记的实例无法借一条进行中的握手重新登记。 +// 新配对与升级接管在此首次落盘(名称随能力声明才到达);会话连接只能更新仍以同一密钥登记的实例, +// 与忘记操作由 regMu 串行,被忘记的实例无法借一条进行中的握手重新登记。接管之后老版密钥作废, +// 在线的老版连接随之断开。 func (s *Server) registerOnline(c *conn) (replaced bool, err error) { s.regMu.Lock() defer s.regMu.Unlock() @@ -158,9 +159,12 @@ func (s *Server) registerOnline(c *conn) (replaced bool, err error) { ProductVersion: c.peer.ProductVersion, ExtensionVersion: c.peer.ExtensionVersion, } - if c.paired { + switch { + case c.paired: err = registry.Pair(inst) - } else { + case c.takeover: + err = s.takeOverLocked(inst) + default: err = registry.Update(inst) } if err != nil { @@ -168,14 +172,21 @@ func (s *Server) registerOnline(c *conn) (replaced bool, err error) { } c.connectedAt = time.Now() + var legacy *conn s.mu.Lock() online := s.onlineFor(c.kind) old := online[c.instanceID] online[c.instanceID] = c + if c.takeover { + legacy, s.legacyScriptCat = s.legacyScriptCat, nil + } s.mu.Unlock() if old != nil && old != c { old.closeInBackground(websocket.StatusNormalClosure, "replaced by new connection") } + if legacy != nil { + legacy.closeInBackground(websocket.StatusPolicyViolation, "") + } return old != nil && old != c, nil } diff --git a/internal/daemon/bridge/scriptcats_test.go b/internal/daemon/bridge/scriptcats_test.go index 9b9631c..c97f987 100644 --- a/internal/daemon/bridge/scriptcats_test.go +++ b/internal/daemon/bridge/scriptcats_test.go @@ -318,8 +318,10 @@ func TestScriptCatInstanceHandshakeFailures(t *testing.T) { }) Convey("实例的 MAC 去掉身份声明后不能以老版 ScriptCat 身份通过", func() { - So(h.keys.Save(key), ShouldBeNil) - ev := fail(nil, instanceMAC(instanceA, key)) + // pairing.key 与已登记实例的密钥相同意味着它已被接管(见 takeover_test.go),这里用只在 pairing.key 里的密钥。 + legacyKey, _ := auth.NewLongTermKey() + So(h.keys.Save(legacyKey), ShouldBeNil) + ev := fail(nil, instanceMAC(instanceA, legacyKey)) So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) So(ev.Client, ShouldBeBlank) }) diff --git a/internal/daemon/bridge/server.go b/internal/daemon/bridge/server.go index 493d2eb..bc2a696 100644 --- a/internal/daemon/bridge/server.go +++ b/internal/daemon/bridge/server.go @@ -260,8 +260,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { func (s *Server) register(c *conn) error { switch { case !c.isInstance(): - s.setLegacyScriptCat(c) - return nil + return s.registerLegacy(c) case c.kind == protocol.PeerBrowser: return s.registerBrowser(c) default: @@ -326,8 +325,18 @@ func (s *Server) removeConn(c *conn) { } } -// setLegacyScriptCat 将 c 设为唯一的老版 ScriptCat 连接,替换并断开旧的老版连接;新版实例的连接不受影响。 -func (s *Server) setLegacyScriptCat(c *conn) { +// registerLegacy 将 c 设为唯一的老版 ScriptCat 连接,替换并断开旧的老版连接;新版实例的连接不受影响。 +// 与接管由 regMu 串行:握手之后 pairing.key 已被接管或被重新接入替换时,这条连接的密钥已作废,不能上线。 +func (s *Server) registerLegacy(c *conn) error { + s.regMu.Lock() + defer s.regMu.Unlock() + held, err := s.holdsLegacyKeyLocked(c.key) + if err != nil { + return err + } + if !held { + return store.ErrInstanceNotFound + } s.mu.Lock() old := s.legacyScriptCat s.legacyScriptCat = c @@ -335,4 +344,5 @@ func (s *Server) setLegacyScriptCat(c *conn) { if old != nil && old != c { old.closeInBackground(websocket.StatusNormalClosure, "replaced by new connection") } + return nil } diff --git a/internal/daemon/bridge/server_test.go b/internal/daemon/bridge/server_test.go index f28e50b..2deee9c 100644 --- a/internal/daemon/bridge/server_test.go +++ b/internal/daemon/bridge/server_test.go @@ -35,11 +35,16 @@ type testHarness struct { } func startTestServer(t *testing.T) *testHarness { + t.Helper() + dir := t.TempDir() + return startTestServerAt(t, dir, filepath.Join(dir, "pairing.key")) +} + +// startTestServerAt 以 dir 下的实例登记表与 keyPath 处的 pairing.key 启动 daemon。 +func startTestServerAt(t *testing.T, dir, keyPath string) *testHarness { t.Helper() p, err := protocol.Load() So(err, ShouldBeNil) - dir := t.TempDir() - keyPath := filepath.Join(dir, "pairing.key") keys := store.NewKeyStore(keyPath) browsers, err := store.LoadBrowserRegistry(filepath.Join(dir, "browsers.json")) So(err, ShouldBeNil) diff --git a/internal/daemon/bridge/takeover.go b/internal/daemon/bridge/takeover.go new file mode 100644 index 0000000..70b2202 --- /dev/null +++ b/internal/daemon/bridge/takeover.go @@ -0,0 +1,95 @@ +package bridge + +import ( + "crypto/subtle" + "fmt" + + "go.uber.org/zap" + + "github.com/scriptscat/sctl/internal/daemon/store" +) + +// RetireTakenOverPairingKey 补做一次没删成 pairing.key 的升级接管,daemon 启动时调用。仍删不掉时返回错误; +// 在此期间老版握手照样不成立(见 legacyKeyLocked)。 +func (s *Server) RetireTakenOverPairingKey() error { + s.regMu.Lock() + defer s.regMu.Unlock() + _, _, err := s.legacyKeyLocked() + return err +} + +// legacyKey 取仍归老版 ScriptCat 所有的 pairing.key 密钥,ok=false 表示没有。 +func (s *Server) legacyKey() (key []byte, ok bool, err error) { + s.regMu.Lock() + defer s.regMu.Unlock() + return s.legacyKeyLocked() +} + +// legacyKeyLocked 同 legacyKey,调用方须持有 regMu。接管先登记再删 pairing.key,删除失败时文件留下、 +// 密钥却已属于一个新版实例:此时补删并视为没有老版密钥,这把密钥既不能再走老版握手,也不能被另一个实例再接管。 +// 补删与写 pairing.key 都在 regMu 下,不会删掉老版重新接入刚写入的新密钥。 +func (s *Server) legacyKeyLocked() (key []byte, ok bool, err error) { + key, ok, err = s.keys.Load() + if err != nil { + return nil, false, fmt.Errorf("load long-term key: %w", err) + } + if !ok || !s.registeredScriptCatKey(key) { + return key, ok, nil + } + if err := s.keys.Delete(); err != nil { + return nil, false, fmt.Errorf("delete taken-over pairing.key: %w", err) + } + return nil, false, nil +} + +// registeredScriptCatKey 报告 key 是否已登记为某个新版 ScriptCat 实例的密钥。老版重新接入总是生成新的随机密钥, +// 因此 pairing.key 与登记表里的密钥相同只可能来自一次接管。 +func (s *Server) registeredScriptCatKey(key []byte) bool { + for _, inst := range s.scriptcats.List() { + if subtle.ConstantTimeCompare(inst.Key, key) == 1 { + return true + } + } + return false +} + +// saveLegacyKey 写入老版 ScriptCat 新接入的密钥;与接管的检查和补删由 regMu 串行。 +func (s *Server) saveLegacyKey(key []byte) error { + s.regMu.Lock() + defer s.regMu.Unlock() + return s.keys.Save(key) +} + +// holdsLegacyKeyLocked 报告 key 是否仍是 pairing.key 中的老版密钥,调用方须持有 regMu。握手之后 +// pairing.key 可能已被接管或被老版重新接入替换,持有旧密钥的连接不能再据此上线或接管。 +func (s *Server) holdsLegacyKeyLocked(key []byte) (bool, error) { + current, ok, err := s.legacyKeyLocked() + if err != nil { + return false, err + } + return ok && subtle.ConstantTimeCompare(current, key) == 1, nil +} + +// takeOverLocked 把这条连接用来握手的 pairing.key 密钥登记到实例 inst 名下,再删除 pairing.key;调用方须持有 regMu。 +// 实例可能在握手之后已另行配对或被另一条连接接管,这时它已有自己的密钥,不再接管。名称冲突时什么都不登记、 +// pairing.key 原样保留。先登记再删除:删除失败时登记保留,由 legacyKeyLocked 之后补删,不会出现两把密钥都失效的状态。 +func (s *Server) takeOverLocked(inst store.Instance) error { + if _, ok := s.scriptcats.Get(inst.ID); ok { + return store.ErrInstanceNotFound + } + held, err := s.holdsLegacyKeyLocked(inst.Key) + if err != nil { + return err + } + if !held { + return store.ErrInstanceNotFound + } + if err := s.scriptcats.Pair(inst); err != nil { + return err + } + if err := s.keys.Delete(); err != nil { + s.log.Warn("ScriptCat instance took over pairing.key but deleting it failed; retrying at the next check", + zap.String("instance", inst.ID), zap.Error(err)) + } + return nil +} diff --git a/internal/daemon/bridge/takeover_test.go b/internal/daemon/bridge/takeover_test.go new file mode 100644 index 0000000..a1a3702 --- /dev/null +++ b/internal/daemon/bridge/takeover_test.go @@ -0,0 +1,218 @@ +package bridge + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/google/uuid" + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/daemon/auth" + "github.com/scriptscat/sctl/internal/pkg/audit" +) + +// legacyKey 读出当前 pairing.key 中的老版密钥。 +func (h *testHarness) legacyKey() []byte { + key, ok, err := h.keys.Load() + So(err, ShouldBeNil) + So(ok, ShouldBeTrue) + return key +} + +// pairingKeyRemoved 报告 pairing.key 是否已不在磁盘上。 +func (h *testHarness) pairingKeyRemoved() bool { + _, err := os.Stat(h.keyPath) + return os.IsNotExist(err) +} + +// legacyHandshakeFails 断言不带 peer 的老版会话握手以 key 被拒(1008 关闭)。 +func (h *testHarness) legacyHandshakeFails(key []byte) { + 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}) + e.closedWithPolicyViolation() +} + +// takeoverFails 以未登记的实例 id 和按 key 计算的 ScriptCat 实例 MAC 发起会话握手,断言握手失败并返回审计事件。 +func (h *testHarness) takeoverFails(id string, key []byte) audit.Event { + e, challenge, nonceD := h.challenge() + nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes) + mac := h.crypto.ScriptCatExtHMAC(auth.ModeSession, id, key, nonceD, nonceE) + e.writeResult(challenge.ID, authResponseResult{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: scriptCatPeer(id)}) + e.closedWithPolicyViolation() + return waitForAuditEvent(h, audit.TypeHandshakeFailed) +} + +func TestUpgradedScriptCatTakesOverPairingKey(t *testing.T) { + Convey("未登记的新版 ScriptCat 以 pairing.key 的密钥会话握手:密钥归到该实例、pairing.key 删除、在线老版连接断开", t, func() { + h := startTestServer(t) + legacy := h.connectScriptCat() + key := h.legacyKey() + + a := h.connectScriptCatInstance(instanceA, key, "work") + + got, ok := h.scriptcats.Get(instanceA) + So(ok, ShouldBeTrue) + So(got.Name, ShouldEqual, "work") + So(got.Key, ShouldResemble, key) + So(h.pairingKeyRemoved(), ShouldBeTrue) + legacy.closedByDaemon() + h.scriptsListWorks(a) + + Convey("此后老版握手不再成立", func() { + h.legacyHandshakeFails(key) + So(h.scriptCatOnline(instanceA, true), ShouldBeTrue) + }) + + Convey("接管后的实例按登记的密钥重连", func() { + a2 := h.connectScriptCatInstance(instanceA, key, "work") + a.closedByDaemon() + a2.alive() + }) + }) + + Convey("没有老版连接在线时接管同样登记密钥并删除 pairing.key", t, func() { + h := startTestServer(t) + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + + h.connectScriptCatInstance(instanceA, key, "work") + + got, ok := h.scriptcats.Get(instanceA) + So(ok, ShouldBeTrue) + So(got.Key, ShouldResemble, key) + So(h.pairingKeyRemoved(), ShouldBeTrue) + }) +} + +func TestScriptCatTakeoverFailuresKeepPairingKey(t *testing.T) { + Convey("接管失败时 pairing.key 不变、不登记,老版连接照常在线", t, func() { + h := startTestServer(t) + legacy := h.connectScriptCat() + before, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + unchanged := func() { + after, err := os.ReadFile(h.keyPath) + So(err, ShouldBeNil) + So(after, ShouldResemble, before) + _, ok := h.scriptcats.Get(instanceA) + So(ok, ShouldBeFalse) + legacy.alive() + } + + Convey("MAC 不是用 pairing.key 的密钥计算的:按握手失败处理", func() { + wrong, _ := auth.NewLongTermKey() + ev := h.takeoverFails(instanceA, wrong) + So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch) + So(ev.Client, ShouldEqual, "scriptcat:"+instanceA) + unchanged() + }) + + Convey("MAC 用 pairing.key 的密钥但按老版公式计算:按握手失败处理", func() { + key := h.legacyKey() + 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: scriptCatPeer(instanceA)}) + e.closedWithPolicyViolation() + unchanged() + }) + + for _, name := range []string{"home", "scriptcat"} { + Convey("能力声明的名称 "+name+" 冲突:回复 CONFLICT,不登记、不删除 pairing.key", func() { + h.registerScriptCat(instanceB, "home") + e := h.authenticateScriptCatInstance(instanceA, h.legacyKey()) + reply := e.declare(scriptCatCapabilities(name)) + So(reply.Error, ShouldNotBeNil) + So(reply.Error.Data, ShouldNotBeNil) + So(reply.Error.Data.Code, ShouldEqual, CodeConflict) + e.closedByDaemon() + unchanged() + So(h.scriptCatOnline(instanceA, false), ShouldBeFalse) + }) + } + }) +} + +func TestLegacyConnectionCannotComeOnlineAfterTakeover(t *testing.T) { + Convey("握手已通过但尚未声明能力的老版连接,在接管之后声明能力时被断开且不上线", t, func() { + h := startTestServer(t) + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + legacy := h.authenticateSession(key) + + h.connectScriptCatInstance(instanceA, key, "work") + legacy.writeRequest(methodCapabilities, uuid.NewString(), capabilitiesParams{SchemaVersion: "1.0.0", Methods: h.allMethods()}) + legacy.closedWithPolicyViolation() + + h.srv.mu.Lock() + online := h.srv.legacyScriptCat + h.srv.mu.Unlock() + So(online, ShouldBeNil) + }) +} + +func TestFailedPairingKeyDeleteIsRetried(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("relies on a read-only POSIX directory blocking file removal") + } + Convey("接管登记成功但删除 pairing.key 失败:登记保留,老版握手不成立,删除在启动或下次接管检查时重试", t, func() { + dir := t.TempDir() + keyDir := filepath.Join(dir, "keys") + So(os.Mkdir(keyDir, 0o700), ShouldBeNil) + h := startTestServerAt(t, dir, filepath.Join(keyDir, "pairing.key")) + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + legacy := h.authenticateSession(key) + So(legacy.declare(capabilitiesParams{SchemaVersion: "1.0.0", Methods: h.allMethods()}).Error, ShouldBeNil) + + So(os.Chmod(keyDir, 0o500), ShouldBeNil) + defer func() { _ = os.Chmod(keyDir, 0o700) }() + a := h.connectScriptCatInstance(instanceA, key, "work") + + got, ok := h.scriptcats.Get(instanceA) + So(ok, ShouldBeTrue) + So(got.Key, ShouldResemble, key) + So(h.pairingKeyRemoved(), ShouldBeFalse) + legacy.closedByDaemon() + h.legacyHandshakeFails(key) + h.scriptsListWorks(a) + + So(os.Chmod(keyDir, 0o700), ShouldBeNil) + + Convey("daemon 启动时删除", func() { + So(h.srv.RetireTakenOverPairingKey(), ShouldBeNil) + So(h.pairingKeyRemoved(), ShouldBeTrue) + }) + + Convey("下次接管检查时删除:另一个未登记实例拿这把密钥也接管不了", func() { + ev := h.takeoverFails(instanceB, key) + So(ev.Reason, ShouldEqual, audit.ReasonUnknownInstance) + So(h.pairingKeyRemoved(), ShouldBeTrue) + _, ok := h.scriptcats.Get(instanceB) + So(ok, ShouldBeFalse) + }) + + Convey("下次老版握手检查时删除", func() { + h.legacyHandshakeFails(key) + So(h.pairingKeyRemoved(), ShouldBeTrue) + }) + }) +} + +func TestStartupKeepsAnUntakenPairingKey(t *testing.T) { + Convey("pairing.key 未被任何实例接管时,启动检查保留它,老版 ScriptCat 照常连接", t, func() { + h := startTestServer(t) + h.registerScriptCat(instanceA, "work") + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + + So(h.srv.RetireTakenOverPairingKey(), ShouldBeNil) + + So(h.pairingKeyRemoved(), ShouldBeFalse) + h.doSessionHandshake(key).alive() + }) +} diff --git a/internal/daemon/component.go b/internal/daemon/component.go index bb0e4d1..8ecfeb7 100644 --- a/internal/daemon/component.go +++ b/internal/daemon/component.go @@ -66,6 +66,10 @@ func (b *daemonComponent) StartCancel(ctx context.Context, cancel context.Cancel return err } b.srv = bridge.NewServer(b.version, p, keys, browsers, scriptcats, logger.Ctx(ctx)) + // 上次接管没删成的 pairing.key 在这里补删;仍删不掉不阻止启动,老版握手照样不成立,下次检查再删。 + if err := b.srv.RetireTakenOverPairingKey(); err != nil { + logger.Ctx(ctx).Warn("failed to delete the pairing.key taken over by a ScriptCat instance", zap.Error(err)) + } // 同步 net.Listen 使绑定失败在启动阶段即暴露(cago 会 panic,符合 fail-fast 约定)。 ln, err := net.Listen("tcp", b.address) diff --git a/internal/daemon/store/keystore.go b/internal/daemon/store/keystore.go index c3c504c..685f38e 100644 --- a/internal/daemon/store/keystore.go +++ b/internal/daemon/store/keystore.go @@ -45,3 +45,11 @@ func (s *KeyStore) Save(key []byte) error { } return fsutil.WriteFileAtomic(s.path, []byte(hex.EncodeToString(key)), 0o600) } + +// Delete 删除长期密钥文件;文件本就不存在不算错误,使删除可以安全地重试。 +func (s *KeyStore) Delete() error { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete key file: %w", err) + } + return nil +} diff --git a/internal/daemon/store/store_test.go b/internal/daemon/store/store_test.go index ada5f45..5fd4b96 100644 --- a/internal/daemon/store/store_test.go +++ b/internal/daemon/store/store_test.go @@ -46,5 +46,19 @@ func TestKeyStore(t *testing.T) { got, _, _ := ks.Load() So(got, ShouldResemble, k2) }) + + Convey("删除后读不到密钥;再删一次(文件已不存在)不算错误", func() { + k, _ := auth.NewLongTermKey() + So(ks.Save(k), ShouldBeNil) + + So(ks.Delete(), ShouldBeNil) + _, ok, err := ks.Load() + So(err, ShouldBeNil) + So(ok, ShouldBeFalse) + _, statErr := os.Stat(path) + So(os.IsNotExist(statErr), ShouldBeTrue) + + So(ks.Delete(), ShouldBeNil) + }) }) } From 91e85a68a6ccf2a67067a80215149ef5c93950a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:53:58 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20feat(bridge)=EF=BC=9Ascripts.?= =?UTF-8?q?*=20=E6=8C=89=20ScriptCat=20=E7=9B=AE=E6=A0=87=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=EF=BC=8C=E5=A4=9A=E5=AE=9E=E4=BE=8B=E6=97=B6=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20scripts.list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SCRIPTCAT_OFFLINE / SCRIPTCAT_NOT_FOUND / SCRIPTCAT_AMBIGUOUS(peers: scriptcat) - 目标解析与合并在浏览器与 ScriptCat 间共用,老版 ScriptCat 只按保留名 scriptcat 选中 - scripts.list 声明 mergeField,汇总项带 scriptcat 来源;protocolgen 按对端类型校验来源字段 - /control/call 透传 scriptcat 目标;更新 ScriptCat TS 摘要与 docs/protocol.md --- docs/protocol.md | 74 ++++-- internal/client/control/protocol.go | 11 +- internal/daemon/bridge/call.go | 190 ++++++++------ internal/daemon/bridge/envelope.go | 6 +- internal/daemon/bridge/listener.go | 6 +- internal/daemon/bridge/merge_test.go | 4 +- internal/daemon/bridge/routing_test.go | 231 ++++++++++++++++++ internal/daemon/bridge/scriptcats_test.go | 9 +- internal/daemon/controlapi/controlapi.go | 9 +- internal/daemon/controlapi/harness_test.go | 55 ++++- internal/daemon/controlapi/routing_test.go | 60 +++++ .../protocol/generated/protocol.generated.go | 5 +- .../protocol/generated/protocol.generated.ts | 5 +- internal/pkg/protocol/protocol.json | 6 +- internal/protocolgen/generate.go | 17 +- internal/protocolgen/generate_test.go | 9 +- 16 files changed, 547 insertions(+), 150 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 5544235..6d85816 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -338,7 +338,7 @@ digest. Edit approval rechecks the staged digest and target identity before appl Tab titles and URLs, tab group titles, reading list titles, history titles and URLs, recently closed titles and URLs, download file names and URLs, cookie names and values, and bookmark titles and URLs are controlled by web pages, and extension names by their authors; `tabs.list`, `tabGroups.list`, `readingList.list`, `history.search`, `recent.list`, `downloads.list`, `cookies.list`, `cookies.get`, `cookies.set`, `bookmarks.list`, `bookmarks.search`, `extensions.list`, and `extensions.uninstall` mark their results 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. A list result may also declare a boolean `hasMore`, +several instances of the method's peer combine by concatenating that array. A list result may also declare a boolean `hasMore`, meaning the instance has items it did not return. Methods without `mergeField` are never combined. A list method whose parameters declare `limit` returns at most that many items per instance: 100 when it is omitted, and a value above 1000 is rejected with `INVALID_REQUEST`. @@ -440,38 +440,47 @@ 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 `scripts.*` call cannot yet -name a ScriptCat instance: it goes to the only connected ScriptCat, legacy or instance, and fails with `CONFLICT` -while several are connected. A connection must still have declared the method, otherwise the call fails with -`METHOD_NOT_FOUND`. +calls go to a ScriptCat instance, 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. +A call carries an optional target of its own peer kind: a browser target on a browser method, a ScriptCat target on +a `scripts.*` method. A target is 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 of that kind, online or offline. +Browser and ScriptCat names are separate namespaces. -| 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 legacy ScriptCat ([§2.1](#21-authentication)) takes part as one instance under the reserved name `scriptcat`; it +counts as paired while it is connected or `pairing.key` exists. It has no instance ID, so it is selected only by +that name and never by an ID prefix. + +The same table applies to both kinds; the codes differ: + +| Target | Browser method | `scripts.*` method | +|---|---|---| +| none, no instance online | `NO_BROWSER_CONNECTED` | unchanged from a single ScriptCat: `/control/call` answers `INTERNAL_ERROR` "extension not connected" | +| none, exactly one instance online | that instance | that instance | +| none, several online, method with `mergeField` | every online instance; results are combined | every online instance; results are combined | +| none, several online, method without `mergeField` | `BROWSER_AMBIGUOUS`; the message lists the online instances | `SCRIPTCAT_AMBIGUOUS`; the message lists the online instances | +| matches no paired instance | `BROWSER_NOT_FOUND` | `SCRIPTCAT_NOT_FOUND` | +| ID prefix matches several paired instances | `BROWSER_AMBIGUOUS`; the message lists the matching instances | `SCRIPTCAT_AMBIGUOUS`; the message lists the matching instances | +| matches one paired instance that is not connected | `BROWSER_OFFLINE` | `SCRIPTCAT_OFFLINE` | +| matches one online instance | that 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": ""}`. An -instance that answers `NOT_FOUND` — for example, it has no window with the requested ID — contributes no items; +item gains an object named after the method's peer kind that names its source: `browser` for browser methods, +`scriptcat` for `scripts.list`, each `{"id": "", "name": ""}`; a legacy ScriptCat's +items carry `{"name": "scriptcat"}` without `id`. 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. If any combined result carries `hasMore`, the combined result's `hasMore` is `true` when at least one instance answered `true`. -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. +A browser target on a `scripts.*` call and a ScriptCat target on a browser method are rejected with +`INVALID_REQUEST`; otherwise routing for one kind does not depend on instances of the other. 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`. Write and source-disclosure approvals are raised and decided by the +ScriptCat instance that received the request. ### 3.2 Internal methods @@ -688,6 +697,16 @@ These codes are reserved for browser target selection failures ([§3.1](#31-rout | `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 | +These codes are reserved for ScriptCat target selection failures on `scripts.*` calls +([§3.1](#31-routing-and-target-selection)); with no ScriptCat online and no target, the call keeps the +`INTERNAL_ERROR` "extension not connected" answer instead: + +| Code | Meaning | +|---|---| +| `SCRIPTCAT_OFFLINE` | the target names a paired ScriptCat instance, or the legacy `scriptcat` while `pairing.key` exists, that is not connected | +| `SCRIPTCAT_NOT_FOUND` | the target matches no paired ScriptCat instance | +| `SCRIPTCAT_AMBIGUOUS` | the target instance-ID prefix matches more than one paired ScriptCat instance, or a `scripts.*` method without `mergeField` was called without a target while several ScriptCat instances are online | + These codes are browser-only too: | Code | Meaning | @@ -792,12 +811,15 @@ daemon-side metadata and does not reach the TypeScript output. The generator emi 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`. +lists exactly. Adding browser-owned definitions therefore never changes ScriptCat's files or `schemaVersion`; +a definition listing `scriptcat`, such as the `SCRIPTCAT_*` error codes, does, and lands together with a paired +ScriptCat commit carrying the regenerated files. The generator rejects a method whose `level` is missing or not `L0`/`L1`/`L2`, an L2 method without a human gate (`blocking` is `none`) or a human-gated method that is not L2, an L1 method whose parameters do not declare the -optional `confirm: {"const": true}`, a `mergeField` on a method that waits for a human, and a list result whose -`hasMore` is not a boolean. The ScriptCat TypeScript does not carry `level`, so it stays byte-identical. +optional `confirm: {"const": true}`, a `mergeField` on a method that waits for a human, a `mergeField` whose items +already declare the property named after the method's peer (`browser` or `scriptcat`) that the merge adds, and a +list result whose `hasMore` is not a boolean. `mergeField` does not reach the TypeScript output. The ScriptCat TypeScript does not carry `level`, so it stays byte-identical. 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 diff --git a/internal/client/control/protocol.go b/internal/client/control/protocol.go index b7098af..50bc90c 100644 --- a/internal/client/control/protocol.go +++ b/internal/client/control/protocol.go @@ -49,8 +49,10 @@ const CLIClientID = "sctl-cli" type CallRequest struct { Action string `json:"action"` // Browser 是浏览器方法的可选目标:实例名称或实例 ID 前缀,由 daemon 解析(docs/protocol.md §3.1)。 - Browser string `json:"browser,omitempty"` - Input json.RawMessage `json:"input"` + Browser string `json:"browser,omitempty"` + // ScriptCat 是 scripts.* 方法的可选目标:ScriptCat 实例名称或实例 ID 前缀,由 daemon 解析(docs/protocol.md §3.1)。 + ScriptCat string `json:"scriptcat,omitempty"` + Input json.RawMessage `json:"input"` // ReportPending 请 daemon 在对端报告请求进入人工审批时先写出一行 CallResult{Pending: true}, // 结论随后另起一行。不带时响应体只有结论一行。 ReportPending bool `json:"reportPending,omitempty"` @@ -69,8 +71,9 @@ type PageRequest struct { } // CallResult 是 /control/call 的响应体,映射桥接的 JSON-RPC result/error。 -// 列表类浏览器方法未指定目标且多个浏览器在线时,Result 是各实例结果按 mergeField 拼接后的汇总, -// 该数组的每一项多一个 "browser": {"id","name"} 字段;其余情况 Result 是单个对端的原始结果。 +// 列表类方法未指定目标且多个同类实例在线时,Result 是各实例结果按 mergeField 拼接后的汇总, +// 该数组的每一项多一个来源字段:浏览器方法是 "browser": {"id","name"},scripts.list 是 +// "scriptcat": {"id","name"}(老版 ScriptCat 没有 id);其余情况 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 4174bae..858fcf1 100644 --- a/internal/daemon/bridge/call.go +++ b/internal/daemon/bridge/call.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "strconv" "strings" "sync" @@ -12,6 +13,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + "github.com/scriptscat/sctl/internal/daemon/store" "github.com/scriptscat/sctl/internal/pkg/protocol" "github.com/scriptscat/sctl/internal/pkg/protocol/generated" "github.com/scriptscat/sctl/internal/pkg/protocolschema" @@ -30,8 +32,9 @@ type pendingCall struct { approvalSignaled bool } -// Call 按方法归属把一次 bridge action 转发给对端并阻塞等待应答:scripts.* 发给 ScriptCat, -// 浏览器方法发给由 req.Browser 解析出的浏览器实例(见 resolveBrowsers)。write=true 的写调用可能挂起数分钟 +// Call 按方法归属把一次 bridge action 转发给对端并阻塞等待应答:scripts.* 发给由 req.ScriptCat 解析出的 +// ScriptCat 实例(见 resolveScriptCats),浏览器方法发给由 req.Browser 解析出的浏览器实例(见 resolveBrowsers); +// 另一类对端的目标不适用于该方法,以 INVALID_REQUEST 拒绝。write=true 的写调用可能挂起数分钟 // (等待用户在浏览器审批)。调用方 ctx 取消 / 超过 writeDecisionTTL / 对端断开时: // - ctx 取消 / 超时:向对端发 $/cancelRequest 作废该操作,返回相应错误; // - 对端断开:隐式作废全部在途请求,返回 ErrDisconnected。 @@ -40,50 +43,30 @@ func (s *Server) Call(ctx context.Context, req Request) (Response, error) { if !ok { return Response{}, &Error{Code: generated.ErrorCodeMethodNotFound, Message: "unknown method " + req.Action} } + mergeable := action.MergeField != "" + var targets []instanceTarget + var err error switch action.Peer { case protocol.PeerScriptCat: if req.Browser != "" { return Response{}, &Error{Code: CodeInvalidRequest, Message: "a target browser does not apply to " + req.Action} } - active, err := s.soleScriptCat() - if err != nil { - return Response{}, err - } - return s.callConn(ctx, active, req) + targets, err = s.resolveScriptCats(req.ScriptCat, mergeable) case protocol.PeerBrowser: - targets, err := s.resolveBrowsers(req.Browser, action.MergeField != "") - if err != nil { - return Response{}, err + if req.ScriptCat != "" { + return Response{}, &Error{Code: CodeInvalidRequest, Message: "a target ScriptCat does not apply to " + req.Action} } - if len(targets) == 1 { - return s.callConn(ctx, targets[0].conn, req) - } - return s.callMerged(ctx, targets, req, action.MergeField) + targets, err = s.resolveBrowsers(req.Browser, mergeable) default: panic("bridge: method " + req.Action + " has no owning peer " + string(action.Peer)) } -} - -// soleScriptCat 返回唯一在线的 ScriptCat 连接(老版或新版实例)。调用还不能指定 ScriptCat 实例, -// 多个同时在线时任选一个会把脚本操作发到用户没想到的浏览器里,因此拒绝而不是猜。 -func (s *Server) soleScriptCat() (*conn, error) { - s.mu.Lock() - online := make([]*conn, 0, len(s.scriptCats)+1) - if s.legacyScriptCat != nil { - online = append(online, s.legacyScriptCat) - } - for _, c := range s.scriptCats { - online = append(online, c) + if err != nil { + return Response{}, err } - s.mu.Unlock() - switch len(online) { - case 0: - return nil, ErrNotConnected - case 1: - return online[0], nil - default: - return nil, &Error{Code: CodeConflict, Message: "several ScriptCat instances are connected; disconnect all but one"} + if len(targets) == 1 { + return s.callConn(ctx, targets[0].conn, req) } + return s.callMerged(ctx, targets, req, action) } // callConn 把调用发给连接 c 并阻塞等待它的应答。 @@ -193,42 +176,100 @@ 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 { +// instanceTarget 是一次调用的候选实例,conn 为 nil 表示已配对但不在线。id 为空的是老版 ScriptCat: +// 它没有实例 ID,只能按保留名选中。 +type instanceTarget struct { id string name string conn *conn } -// browserRef 是汇总结果里每一项附带的来源浏览器。 -type browserRef struct { - ID string `json:"id"` +// instanceRef 是汇总结果里每一项附带的来源实例;老版 ScriptCat 没有实例 ID,只带名称。 +type instanceRef struct { + ID string `json:"id,omitempty"` Name string `json:"name"` } -// mergedItemBrowserField 是汇总结果里每一项新增的来源浏览器字段。 -const mergedItemBrowserField = "browser" - // mergedHasMoreField 是列表结果里「还有未返回的条目」的布尔字段:任一实例还有更多,汇总结果就还有更多。 const mergedHasMoreField = "hasMore" -// resolveBrowsers 在 daemon 侧解析浏览器调用的目标,只有这里知道哪些实例在线。 -// target 为空时按在线实例数选择:没有在线实例报 NO_BROWSER_CONNECTED;恰好一个就用它; -// 多个时列表类方法(mergeable)用全部在线实例,操作类方法报 BROWSER_AMBIGUOUS 并列出候选。 -// target 非空时先按名称精确匹配,再按实例 ID 前缀匹配。返回的目标按名称排序,汇总结果沿用这个顺序。 -func (s *Server) resolveBrowsers(target string, mergeable bool) ([]browserTarget, error) { - var registered []browserTarget +// targetKind 是一类对端在目标选择中的用语与错误码。两类对端的选择规则相同,只有这些不同。 +type targetKind struct { + // one / many 是错误消息里这类实例的单数与复数称呼。 + one, many string + // noneOnline 构造未指定目标且没有实例在线时的错误;每次新建,调用方拿到的 *Error 互不共享。 + noneOnline func() error + offline, notFound, ambiguous string +} + +var ( + browserKind = targetKind{ + one: "browser", many: "browsers", + noneOnline: func() error { + return &Error{Code: generated.ErrorCodeNoBrowserConnected, Message: "no browser connected"} + }, + offline: generated.ErrorCodeBrowserOffline, notFound: generated.ErrorCodeBrowserNotFound, ambiguous: generated.ErrorCodeBrowserAmbiguous, + } + // 没有 ScriptCat 在线时沿用实例化之前的 ErrNotConnected,已有的请求方据此报告扩展未连接。 + scriptCatKind = targetKind{ + one: "ScriptCat", many: "ScriptCat instances", + noneOnline: func() error { return ErrNotConnected }, + offline: generated.ErrorCodeScriptcatOffline, notFound: generated.ErrorCodeScriptcatNotFound, ambiguous: generated.ErrorCodeScriptcatAmbiguous, + } +) + +// resolveBrowsers 在 daemon 侧解析浏览器调用的目标,只有这里知道哪些实例在线;规则见 resolveTargets。 +func (s *Server) resolveBrowsers(target string, mergeable bool) ([]instanceTarget, error) { + var registered []instanceTarget for _, inst := range s.browsers.List() { - registered = append(registered, browserTarget{id: inst.ID, name: inst.Name}) + registered = append(registered, instanceTarget{id: inst.ID, name: inst.Name}) } s.mu.Lock() for i := range registered { registered[i].conn = s.online[registered[i].id] } s.mu.Unlock() + return resolveTargets(browserKind, registered, target, mergeable) +} + +// resolveScriptCats 解析 scripts.* 调用的目标 ScriptCat:已配对的新版实例,加上以保留名出现的老版 ScriptCat—— +// 它在线,或 pairing.key 存在(此时点名它得到 SCRIPTCAT_OFFLINE 而不是 SCRIPTCAT_NOT_FOUND)。 +// 老版没有实例 ID,因此不参与 ID 前缀匹配。规则见 resolveTargets。 +func (s *Server) resolveScriptCats(target string, mergeable bool) ([]instanceTarget, error) { + var registered []instanceTarget + for _, inst := range s.scriptcats.List() { + registered = append(registered, instanceTarget{id: inst.ID, name: inst.Name}) + } + s.mu.Lock() + for i := range registered { + registered[i].conn = s.scriptCats[registered[i].id] + } + legacy := s.legacyScriptCat + s.mu.Unlock() + // 只有点名老版时才需要知道离线的老版是否已配对;其余调用不读 pairing.key,它损坏也不妨碍新版实例。 + paired := legacy != nil + if !paired && target == store.ReservedScriptCatName { + _, ok, err := s.keys.Load() + if err != nil { + return nil, err + } + paired = ok + } + if paired { + registered = append(registered, instanceTarget{name: store.ReservedScriptCatName, conn: legacy}) + slices.SortFunc(registered, func(a, b instanceTarget) int { return strings.Compare(a.name, b.name) }) + } + return resolveTargets(scriptCatKind, registered, target, mergeable) +} + +// resolveTargets 从按名称排序的已配对实例 registered 中选出调用目标,汇总结果沿用这个顺序。 +// target 为空时按在线实例数选择:没有在线实例报 kind.noneOnline;恰好一个就用它; +// 多个时列表类方法(mergeable)用全部在线实例,操作类方法报 kind.ambiguous 并列出候选。 +// target 非空时先按名称精确匹配,再按实例 ID 前缀匹配。 +func resolveTargets(kind targetKind, registered []instanceTarget, target string, mergeable bool) ([]instanceTarget, error) { if target == "" { - var online []browserTarget + var online []instanceTarget for _, t := range registered { if t.conn != nil { online = append(online, t) @@ -236,44 +277,45 @@ func (s *Server) resolveBrowsers(target string, mergeable bool) ([]browserTarget } switch { case len(online) == 0: - return nil, &Error{Code: generated.ErrorCodeNoBrowserConnected, Message: "no browser connected"} + return nil, kind.noneOnline() 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)} + return nil, &Error{Code: kind.ambiguous, Message: "several " + kind.many + " are online, choose a target " + kind.one + ": " + describeTargets(online)} } } - matched, err := matchOneTarget(registered, target) + matched, err := matchOneTarget(kind, registered, target) if err != nil { return nil, err } if matched.conn == nil { - return nil, &Error{Code: generated.ErrorCodeBrowserOffline, Message: "browser " + describeTargets([]browserTarget{matched}) + " is not connected"} + return nil, &Error{Code: kind.offline, Message: kind.one + " " + describeTargets([]instanceTarget{matched}) + " is not connected"} } - return []browserTarget{matched}, nil + return []instanceTarget{matched}, nil } // matchOneTarget 按 matchTarget 解析非空 target,要求恰好匹配一个已配对实例(在线与否)。 -func matchOneTarget(registered []browserTarget, target string) (browserTarget, error) { +func matchOneTarget(kind targetKind, registered []instanceTarget, target string) (instanceTarget, error) { matched := matchTarget(registered, target) switch len(matched) { case 0: - return browserTarget{}, &Error{Code: generated.ErrorCodeBrowserNotFound, Message: "no paired browser matches " + target} + return instanceTarget{}, &Error{Code: kind.notFound, Message: "no paired " + kind.one + " matches " + target} case 1: return matched[0], nil } - return browserTarget{}, &Error{Code: generated.ErrorCodeBrowserAmbiguous, Message: "browser ID prefix " + target + " matches several paired browsers: " + describeTargets(matched)} + return instanceTarget{}, &Error{Code: kind.ambiguous, Message: kind.one + " ID prefix " + target + " matches several paired " + kind.many + ": " + describeTargets(matched)} } // matchTarget 返回 target 指向的已配对实例:名称精确匹配优先(名称也可能形如 ID 前缀),否则取实例 ID 前缀匹配的全部实例。 -func matchTarget(registered []browserTarget, target string) []browserTarget { +// target 非空,没有实例 ID 的老版 ScriptCat 因此不会被前缀匹配选中。 +func matchTarget(registered []instanceTarget, target string) []instanceTarget { for _, t := range registered { if t.name == target { - return []browserTarget{t} + return []instanceTarget{t} } } - var byPrefix []browserTarget + var byPrefix []instanceTarget for _, t := range registered { if strings.HasPrefix(t.id, target) { byPrefix = append(byPrefix, t) @@ -282,19 +324,23 @@ func matchTarget(registered []browserTarget, target string) []browserTarget { return byPrefix } -func describeTargets(targets []browserTarget) string { +func describeTargets(targets []instanceTarget) string { parts := make([]string, len(targets)) for i, t := range targets { - parts[i] = t.name + " (" + t.id + ")" + parts[i] = t.name + if t.id != "" { + parts[i] += " (" + t.id + ")" + } } return strings.Join(parts, ", ") } -// callMerged 把列表类调用并发发给每个目标,并把各结果的 mergeField 数组按目标顺序拼接。 +// callMerged 把列表类调用并发发给每个目标,并把各结果的 mergeField 数组按目标顺序拼接,每一项以方法所属的 +// 对端类型(browser / scriptcat)为字段名标上来源实例。 // 回 NOT_FOUND 的目标(例如没有所请求窗口的浏览器)不贡献条目:窗口 ID 只在各自浏览器内有意义, // 只有全部目标都回 NOT_FOUND 时整次调用才是 NOT_FOUND。其余任一目标失败都使整次调用失败,不返回 // 部分结果:按目标顺序取第一个传输错误,其次第一个对端错误。 -func (s *Server) callMerged(ctx context.Context, targets []browserTarget, req Request, mergeField string) (Response, error) { +func (s *Server) callMerged(ctx context.Context, targets []instanceTarget, req Request, action protocol.Action) (Response, error) { responses := make([]Response, len(targets)) errs := make([]error, len(targets)) var wg sync.WaitGroup @@ -312,7 +358,7 @@ func (s *Server) callMerged(ctx context.Context, targets []browserTarget, req Re return Response{}, err } } - var found []browserTarget + var found []instanceTarget var results []Response var notFound *Response for i, resp := range responses { @@ -331,17 +377,17 @@ func (s *Server) callMerged(ctx context.Context, targets []browserTarget, req Re if len(results) == 0 { return *notFound, nil } - merged, err := mergeResults(found, results, mergeField) + merged, err := mergeResults(found, results, action.MergeField, string(action.Peer)) if err != nil { return Response{}, err } return Response{OK: true, Result: merged}, nil } -// mergeResults 以第一个结果为底,把各结果 mergeField 数组的每一项标上来源浏览器后拼接, +// mergeResults 以第一个结果为底,把各结果 mergeField 数组的每一项在 tagField 下标上来源实例后拼接, // 结果带 hasMore 时对各实例取或。各项保持原始 JSON,不经数值往返。结果已按方法 schema 校验过, -// mergeField 必是对象数组、hasMore 必是布尔值(protocolgen 保证);解析失败说明协议定义与此处假设不符。 -func mergeResults(targets []browserTarget, responses []Response, mergeField string) (json.RawMessage, error) { +// mergeField 必是对象数组、项不声明 tagField、hasMore 必是布尔值(protocolgen 保证);解析失败说明协议定义与此处假设不符。 +func mergeResults(targets []instanceTarget, responses []Response, mergeField, tagField 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) @@ -349,7 +395,7 @@ func mergeResults(targets []browserTarget, responses []Response, mergeField stri items := []map[string]json.RawMessage{} hasMore, anyHasMore := false, false for i, resp := range responses { - ref, err := json.Marshal(browserRef{ID: targets[i].id, Name: targets[i].name}) + ref, err := json.Marshal(instanceRef{ID: targets[i].id, Name: targets[i].name}) if err != nil { return nil, err } @@ -362,7 +408,7 @@ func mergeResults(targets []browserTarget, responses []Response, mergeField stri return nil, fmt.Errorf("merge %s: %w", mergeField, err) } for _, item := range list { - item[mergedItemBrowserField] = ref + item[tagField] = ref items = append(items, item) } if raw, ok := fields[mergedHasMoreField]; ok { diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go index 58e0c67..3655498 100644 --- a/internal/daemon/bridge/envelope.go +++ b/internal/daemon/bridge/envelope.go @@ -126,8 +126,10 @@ type Request struct { ClientID string `json:"clientId"` Action string `json:"action"` // Browser 是浏览器方法的目标实例:名称或实例 ID 前缀,空表示按在线实例自动选择。 - Browser string `json:"browser,omitempty"` - Input json.RawMessage `json:"input"` + Browser string `json:"browser,omitempty"` + // ScriptCat 是 scripts.* 方法的目标 ScriptCat 实例:名称或实例 ID 前缀,空表示按在线实例自动选择。 + ScriptCat string `json:"scriptcat,omitempty"` + Input json.RawMessage `json:"input"` // OnPending 在对端报告这个请求进入人工审批时被调用,至多一次,在 Call 的 goroutine 里执行; // 请求在进入审批前就得到答复时不会被调用。nil 表示调用方不关心。 OnPending func() `json:"-"` diff --git a/internal/daemon/bridge/listener.go b/internal/daemon/bridge/listener.go index d7f386e..a0661bb 100644 --- a/internal/daemon/bridge/listener.go +++ b/internal/daemon/bridge/listener.go @@ -116,12 +116,12 @@ func (s *Server) ResolvePairedBrowser(target string) (InstanceInfo, error) { return s.ResolveBrowser(target) } byID := map[string]InstanceInfo{} - var registered []browserTarget + var registered []instanceTarget for _, info := range s.Instances() { byID[info.ID] = info - registered = append(registered, browserTarget{id: info.ID, name: info.Name}) + registered = append(registered, instanceTarget{id: info.ID, name: info.Name}) } - matched, err := matchOneTarget(registered, target) + matched, err := matchOneTarget(browserKind, registered, target) if err != nil { return InstanceInfo{}, err } diff --git a/internal/daemon/bridge/merge_test.go b/internal/daemon/bridge/merge_test.go index 83a87cf..7fb3dae 100644 --- a/internal/daemon/bridge/merge_test.go +++ b/internal/daemon/bridge/merge_test.go @@ -10,9 +10,9 @@ import ( // 当前协议里还没有带 hasMore 的列表方法,这里直接用合成结果驱动汇总逻辑。 func TestMergedListReportsHasMoreWhenAnyInstanceHasMore(t *testing.T) { Convey("汇总列表结果时 hasMore 取各实例的或", t, func() { - targets := []browserTarget{{id: instanceA, name: "chrome-0123"}, {id: instanceB, name: "edge-fedc"}} + targets := []instanceTarget{{id: instanceA, name: "chrome-0123"}, {id: instanceB, name: "edge-fedc"}} merge := func(a, b string) map[string]json.RawMessage { - merged, err := mergeResults(targets, []Response{{OK: true, Result: json.RawMessage(a)}, {OK: true, Result: json.RawMessage(b)}}, "items") + merged, err := mergeResults(targets, []Response{{OK: true, Result: json.RawMessage(a)}, {OK: true, Result: json.RawMessage(b)}}, "items", "browser") So(err, ShouldBeNil) var fields map[string]json.RawMessage So(json.Unmarshal(merged, &fields), ShouldBeNil) diff --git a/internal/daemon/bridge/routing_test.go b/internal/daemon/bridge/routing_test.go index 89bbfa0..1e4ac6e 100644 --- a/internal/daemon/bridge/routing_test.go +++ b/internal/daemon/bridge/routing_test.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" "github.com/coder/websocket/wsjson" . "github.com/smartystreets/goconvey/convey" + "github.com/scriptscat/sctl/internal/daemon/auth" "github.com/scriptscat/sctl/internal/pkg/protocol/generated" ) @@ -173,3 +175,232 @@ func TestMergedListSkipsInstancesWithoutTheRequestedWindow(t *testing.T) { }) }) } + +// instanceC 与 instanceA 共享 ID 前缀 "0123",用于前缀歧义。 +const instanceC = "0123aaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// scriptsListResult 是一个 ScriptCat 实例对 scripts.list 的应答,列出给定 uuid 的脚本。 +func scriptsListResult(uuids ...string) json.RawMessage { + items := make([]string, len(uuids)) + for i, u := range uuids { + items[i] = `{"uuid":"` + u + `","name":"s","namespace":"ns","type":"normal","enabled":true,"updatedAt":"2026-11-02T00:00:00Z","hasUpdateUrl":false}` + } + return json.RawMessage(`{"scripts":[` + strings.Join(items, ",") + `],"contentTrust":"untrusted-user-script-metadata"}`) +} + +// 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) +} + +// answerNotFound 读取发到 e 的下一条业务请求,断言方法后以 NOT_FOUND 应答。 +func (e *extClient) answerNotFound(method string) { + req := e.read() + So(req.Method, ShouldEqual, method) + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + So(wsjson.Write(ctx, e.ws, newError(req.ID, generated.ErrorCodeNotFound, "no such script")), ShouldBeNil) +} + +// sourceTag 是汇总列表里每一项附带的来源实例;ID 用指针区分「没有 id 字段」与空串。 +type sourceTag struct { + ID *string `json:"id"` + Name string `json:"name"` +} + +type mergedScripts struct { + Scripts []struct { + UUID string `json:"uuid"` + ScriptCat sourceTag `json:"scriptcat"` + } `json:"scripts"` + ContentTrust string `json:"contentTrust"` +} + +const metadataInput = `{"uuid":"00000000-0000-4000-8000-000000000000"}` + +func TestScriptCatTargetSelection(t *testing.T) { + Convey("scripts.* 调用按在线 ScriptCat 实例数与 ScriptCat 目标路由", t, func() { + h := startTestServer(t) + keyA := h.registerScriptCat(instanceA, "work") + keyB := h.registerScriptCat(instanceB, "home") + h.registerScriptCat(instanceC, "spare") + + Convey("没有实例在线时,指定已配对实例为目标:SCRIPTCAT_OFFLINE", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "work", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatOffline) + }) + + Convey("恰好一个在线:未指定目标的操作类调用发给它", func() { + a := h.connectScriptCatInstance(instanceA, keyA, "work") + out := h.goCall(Request{Action: "scripts.metadata.get", Input: json.RawMessage(metadataInput)}) + a.answerNotFound("scripts.metadata.get") + got := <-out + So(got.err, ShouldBeNil) + So(got.resp.Error.Code, ShouldEqual, generated.ErrorCodeNotFound) + }) + + Convey("多个在线", func() { + a := h.connectScriptCatInstance(instanceA, keyA, "work") + b := h.connectScriptCatInstance(instanceB, keyB, "home") + + Convey("未指定目标的 scripts.list 汇总全部在线实例:按实例名称排序,每项标上来源实例", func() { + out := h.goCall(Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + a.answer("scripts.list", scriptsListResult("from-work")) + b.answer("scripts.list", scriptsListResult("from-home-1", "from-home-2")) + got := <-out + So(got.err, ShouldBeNil) + So(got.resp.OK, ShouldBeTrue) + var merged mergedScripts + So(json.Unmarshal(got.resp.Result, &merged), ShouldBeNil) + So(merged.ContentTrust, ShouldEqual, "untrusted-user-script-metadata") + So(merged.Scripts, ShouldHaveLength, 3) + So(merged.Scripts[0].UUID, ShouldEqual, "from-home-1") + So(merged.Scripts[1].UUID, ShouldEqual, "from-home-2") + So(merged.Scripts[2].UUID, ShouldEqual, "from-work") + So(merged.Scripts[0].ScriptCat.Name, ShouldEqual, "home") + So(*merged.Scripts[0].ScriptCat.ID, ShouldEqual, instanceB) + So(merged.Scripts[2].ScriptCat.Name, ShouldEqual, "work") + So(*merged.Scripts[2].ScriptCat.ID, ShouldEqual, instanceA) + }) + + Convey("未指定目标的其他 scripts.* 调用:SCRIPTCAT_AMBIGUOUS 并只列出在线实例,不转发", func() { + _, err := h.call(Request{Action: "scripts.metadata.get", Input: json.RawMessage(metadataInput)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatAmbiguous) + So(err.Error(), ShouldContainSubstring, "work ("+instanceA+")") + So(err.Error(), ShouldContainSubstring, "home ("+instanceB+")") + So(err.Error(), ShouldNotContainSubstring, "spare") + a.alive() + b.alive() + }) + + Convey("按名称指定目标:只发给该实例,结果原样返回", func() { + out := h.goCall(Request{Action: "scripts.list", ScriptCat: "home", Input: json.RawMessage(`{}`)}) + b.answer("scripts.list", scriptsListResult("from-home")) + got := <-out + So(got.err, ShouldBeNil) + So(string(got.resp.Result), ShouldEqual, string(scriptsListResult("from-home"))) + a.alive() + }) + + Convey("按实例 ID 前缀指定目标的操作类调用发给该实例", func() { + out := h.goCall(Request{Action: "scripts.metadata.get", ScriptCat: "fed", Input: json.RawMessage(metadataInput)}) + b.answerNotFound("scripts.metadata.get") + So((<-out).resp.Error.Code, ShouldEqual, generated.ErrorCodeNotFound) + a.alive() + }) + + Convey("汇总调用中一个实例断开:整次调用作废", func() { + out := h.goCall(Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + a.answer("scripts.list", scriptsListResult("from-work")) + So(b.read().Method, ShouldEqual, "scripts.list") + b.ws.CloseNow() + So((<-out).err, ShouldEqual, ErrDisconnected) + }) + + Convey("目标不匹配任何已配对实例:SCRIPTCAT_NOT_FOUND", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "nope", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatNotFound) + a.alive() + b.alive() + }) + + Convey("实例 ID 前缀匹配多个已配对实例:SCRIPTCAT_AMBIGUOUS 并列出匹配的实例", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "0123", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatAmbiguous) + So(err.Error(), ShouldContainSubstring, instanceA) + So(err.Error(), ShouldContainSubstring, instanceC) + So(err.Error(), ShouldNotContainSubstring, instanceB) + }) + + Convey("目标是已配对但离线的实例:SCRIPTCAT_OFFLINE", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "spare", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatOffline) + So(err.Error(), ShouldContainSubstring, "spare") + }) + + Convey("名称精确匹配优先于 ID 前缀", func() { + keyNamed := h.registerScriptCat("abcdef0123456789abcdef0123456789", "fed") + named := h.connectScriptCatInstance("abcdef0123456789abcdef0123456789", keyNamed, "fed") + out := h.goCall(Request{Action: "scripts.list", ScriptCat: "fed", Input: json.RawMessage(`{}`)}) + named.answer("scripts.list", scriptsListResult()) + So((<-out).err, ShouldBeNil) + b.alive() + }) + }) + }) +} + +func TestLegacyScriptCatTargetSelection(t *testing.T) { + Convey("老版 ScriptCat 只以保留名 scriptcat 被选中,不参与 ID 前缀匹配", t, func() { + h := startTestServer(t) + keyA := h.registerScriptCat(instanceA, "work") + + Convey("没有 pairing.key 时目标 scriptcat 不对应任何实例:SCRIPTCAT_NOT_FOUND", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "scriptcat", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatNotFound) + }) + + Convey("pairing.key 存在而老版未连接时目标 scriptcat:SCRIPTCAT_OFFLINE", func() { + key, _ := auth.NewLongTermKey() + So(h.keys.Save(key), ShouldBeNil) + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "scriptcat", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatOffline) + }) + + Convey("老版与新版实例同时在线", func() { + legacy := h.connectScriptCat() + a := h.connectScriptCatInstance(instanceA, keyA, "work") + + Convey("目标 scriptcat 发给老版连接", func() { + out := h.goCall(Request{Action: "scripts.list", ScriptCat: "scriptcat", Input: json.RawMessage(`{}`)}) + legacy.answer("scripts.list", scriptsListResult("legacy")) + So((<-out).err, ShouldBeNil) + a.alive() + }) + + Convey("任何 ID 前缀都不会选中老版连接", func() { + _, err := h.call(Request{Action: "scripts.list", ScriptCat: "s", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatNotFound) + legacy.alive() + }) + + Convey("未指定目标的操作类调用:SCRIPTCAT_AMBIGUOUS 列出老版与新版实例", func() { + _, err := h.call(Request{Action: "scripts.metadata.get", Input: json.RawMessage(metadataInput)}) + So(errorCode(err), ShouldEqual, generated.ErrorCodeScriptcatAmbiguous) + So(err.Error(), ShouldContainSubstring, "scriptcat") + So(err.Error(), ShouldContainSubstring, "work ("+instanceA+")") + }) + + Convey("未指定目标的 scripts.list 汇总两者,老版条目的来源只有名称没有 id", func() { + out := h.goCall(Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + legacy.answer("scripts.list", scriptsListResult("legacy")) + a.answer("scripts.list", scriptsListResult("from-work")) + got := <-out + So(got.err, ShouldBeNil) + var merged mergedScripts + So(json.Unmarshal(got.resp.Result, &merged), ShouldBeNil) + So(merged.Scripts, ShouldHaveLength, 2) + So(merged.Scripts[0].UUID, ShouldEqual, "legacy") + So(merged.Scripts[0].ScriptCat.Name, ShouldEqual, "scriptcat") + So(merged.Scripts[0].ScriptCat.ID, ShouldBeNil) + So(merged.Scripts[1].ScriptCat.Name, ShouldEqual, "work") + }) + }) + }) +} + +func TestTargetsOfTheOtherPeerKindAreRejected(t *testing.T) { + Convey("浏览器方法带 ScriptCat 目标被拒为 INVALID_REQUEST,不转发", t, func() { + h := startTestServer(t) + keyA := h.registerBrowser(instanceA, "chrome-0123") + a := h.connectBrowser(instanceA, keyA, "chrome-0123") + keyS := h.registerScriptCat(instanceB, "work") + sc := h.connectScriptCatInstance(instanceB, keyS, "work") + _, err := h.call(Request{Action: "tabs.list", ScriptCat: "work", Input: json.RawMessage(`{}`)}) + So(errorCode(err), ShouldEqual, CodeInvalidRequest) + a.alive() + sc.alive() + }) +} diff --git a/internal/daemon/bridge/scriptcats_test.go b/internal/daemon/bridge/scriptcats_test.go index c97f987..daec770 100644 --- a/internal/daemon/bridge/scriptcats_test.go +++ b/internal/daemon/bridge/scriptcats_test.go @@ -25,7 +25,7 @@ func scriptCatPeer(id string) *authPeer { func scriptCatCapabilities(name string) capabilitiesParams { return capabilitiesParams{ SchemaVersion: "1.0.0", - Methods: []string{"scripts.list", "scripts.get"}, + Methods: []string{"scripts.list", "scripts.metadata.get"}, Peer: &capabilitiesPeer{Name: name, Product: "Chrome", ProductVersion: "129.0", ExtensionVersion: "1.3.0"}, } } @@ -141,13 +141,6 @@ func TestScriptCatInstancesCoexist(t *testing.T) { a2.alive() b.alive() - Convey("多个 ScriptCat 同时在线时 scripts 调用不随意选一个发出,而是报错", func() { - _, err := h.srv.Call(context.Background(), Request{Action: "scripts.list", Input: json.RawMessage(`{}`)}) - var bridgeErr *Error - So(errors.As(err, &bridgeErr), ShouldBeTrue) - So(bridgeErr.Code, ShouldEqual, CodeConflict) - }) - Convey("只剩一个 ScriptCat 在线时 scripts 调用发给它", func() { b.ws.CloseNow() So(h.scriptCatOnline(instanceB, false), ShouldBeFalse) diff --git a/internal/daemon/controlapi/controlapi.go b/internal/daemon/controlapi/controlapi.go index 5e79773..062006d 100644 --- a/internal/daemon/controlapi/controlapi.go +++ b/internal/daemon/controlapi/controlapi.go @@ -189,10 +189,11 @@ func (h *Handler) call(w http.ResponseWriter, r *http.Request) { } bridgeReq := bridge.Request{ - ClientID: clientID, - Action: req.Action, - Browser: req.Browser, - Input: req.Input, + ClientID: clientID, + Action: req.Action, + Browser: req.Browser, + ScriptCat: req.ScriptCat, + Input: req.Input, } if req.ReportPending { // 请求方据此才提示「等待批准」:审批前的校验失败时不会有这一行。必须立刻冲刷,否则它会和结论一起到达。 diff --git a/internal/daemon/controlapi/harness_test.go b/internal/daemon/controlapi/harness_test.go index eb99f2d..316c48c 100644 --- a/internal/daemon/controlapi/harness_test.go +++ b/internal/daemon/controlapi/harness_test.go @@ -73,12 +73,13 @@ type cancelParams struct { // testHarness 承载一个运行中的 daemon(WS 面 + 控制 API)与用于对拍的密码学助手/存储。 type testHarness struct { - srv *bridge.Server - crypto *auth.Crypto - keys *store.KeyStore - browsers *store.BrowserRegistry - url string - proto *protocol.Protocol + srv *bridge.Server + crypto *auth.Crypto + keys *store.KeyStore + browsers *store.BrowserRegistry + scriptcats *store.ScriptCatRegistry + url string + proto *protocol.Protocol } func startTestServer(t *testing.T) *testHarness { @@ -107,12 +108,13 @@ func startTestServer(t *testing.T) *testHarness { go func() { _ = srv.Serve(ctx, ln, mux) }() return &testHarness{ - srv: srv, - crypto: auth.NewCrypto(p), - keys: keys, - browsers: browsers, - url: "ws://" + ln.Addr().String() + "/", - proto: p, + srv: srv, + crypto: auth.NewCrypto(p), + keys: keys, + browsers: browsers, + scriptcats: scriptcats, + url: "ws://" + ln.Addr().String() + "/", + proto: p, } } @@ -254,6 +256,35 @@ func (h *testHarness) connectBrowser(id, name string) *extClient { return e } +// connectScriptCatInstance 配对并按线上协议连接一个新版 ScriptCat 实例,返回完成能力声明的连接。 +func (h *testHarness) connectScriptCatInstance(id, name string) *extClient { + key, err := auth.NewLongTermKey() + So(err, ShouldBeNil) + So(h.scriptcats.Pair(store.ScriptCatInstance{ID: id, Name: name, Key: key}), ShouldBeNil) + 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.ScriptCatExtHMAC(auth.ModeSession, id, key, cp.NonceD, nonceE) + e.writeResult(challenge.ID, authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac, Peer: &authPeerPayload{Kind: "scriptcat", 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": []string{"scripts.list", "scripts.metadata.get"}, + "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 index 5f450ea..7cdc178 100644 --- a/internal/daemon/controlapi/routing_test.go +++ b/internal/daemon/controlapi/routing_test.go @@ -285,3 +285,63 @@ func TestScriptsRoutingUnchangedWithBrowsers(t *testing.T) { }) }) } + +func scriptsListResult(uuid string) json.RawMessage { + return json.RawMessage(`{"scripts":[{"uuid":"` + uuid + `","name":"s","namespace":"ns","type":"normal","enabled":true,"updatedAt":"2026-11-02T00:00:00Z","hasUpdateUrl":false}],"contentTrust":"untrusted-user-script-metadata"}`) +} + +func TestControlCallCarriesScriptCatTarget(t *testing.T) { + Convey("/control/call 把 scriptcat 目标交给 daemon 的 ScriptCat 目标选择", t, func() { + h := startTestServer(t) + a := h.connectScriptCatInstance(instanceA, "work") + b := h.connectScriptCatInstance(instanceB, "home") + + Convey("按名称指定目标:只发给该实例,结果原样返回", func() { + ch := h.goCall(control.CallRequest{Action: "scripts.list", ScriptCat: "home", Input: json.RawMessage(`{}`)}) + b.answer("scripts.list", scriptsListResult("from-home")) + res := <-ch + So(res.OK, ShouldBeTrue) + So(string(res.Result), ShouldEqual, string(scriptsListResult("from-home"))) + a.idle() + }) + + Convey("未指定目标的 scripts.list 汇总在线实例,每项带 scriptcat 来源", func() { + ch := h.goCall(control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + a.answer("scripts.list", scriptsListResult("from-work")) + b.answer("scripts.list", scriptsListResult("from-home")) + res := <-ch + So(res.OK, ShouldBeTrue) + var merged struct { + Scripts []struct { + UUID string `json:"uuid"` + ScriptCat struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"scriptcat"` + } `json:"scripts"` + } + So(json.Unmarshal(res.Result, &merged), ShouldBeNil) + So(merged.Scripts, ShouldHaveLength, 2) + So(merged.Scripts[0].UUID, ShouldEqual, "from-home") + So(merged.Scripts[0].ScriptCat.ID, ShouldEqual, instanceB) + So(merged.Scripts[1].ScriptCat.Name, ShouldEqual, "work") + }) + + Convey("目标不匹配任何已配对实例:响应带 SCRIPTCAT_NOT_FOUND", func() { + So(errCode(h.callControl(control.CallRequest{Action: "scripts.list", ScriptCat: "nope", Input: json.RawMessage(`{}`)})), ShouldEqual, generated.ErrorCodeScriptcatNotFound) + }) + + Convey("汇总中的一个实例断开时整次调用作废:OPERATION_EXPIRED", func() { + ch := h.goCall(control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)}) + a.answer("scripts.list", scriptsListResult("from-work")) + So(b.read().Method, ShouldEqual, "scripts.list") + b.ws.CloseNow() + So(errCode(<-ch), ShouldEqual, generated.ErrorCodeOperationExpired) + }) + + Convey("浏览器方法带 ScriptCat 目标被拒为 INVALID_REQUEST", func() { + h.connectBrowser(instanceC, "chrome-0123") + So(errCode(h.callControl(control.CallRequest{Action: "tabs.list", ScriptCat: "work", Input: json.RawMessage(`{}`)})), ShouldEqual, generated.ErrorCodeInvalidRequest) + }) + }) +} diff --git a/internal/pkg/protocol/generated/protocol.generated.go b/internal/pkg/protocol/generated/protocol.generated.go index 2ebcf59..16642e4 100644 --- a/internal/pkg/protocol/generated/protocol.generated.go +++ b/internal/pkg/protocol/generated/protocol.generated.go @@ -999,7 +999,7 @@ var Methods = map[string]MethodMetadata{ "scripts.delete.request": {Params: "ScriptUUIDParams", Result: "ScriptsDeleteResult", Scope: "scripts:delete:request", Effect: "write", Blocking: "approval", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}, "scripts.edit.request": {Params: "ScriptsEditParams", Result: "ScriptMutationResult", Scope: "scripts:edit:request", Effect: "write", Blocking: "approval", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}, "scripts.install.request": {Params: "ScriptsInstallParams", Result: "ScriptMutationResult", Scope: "scripts:install:request", Effect: "write", Blocking: "approval", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}, - "scripts.list": {Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "scriptcat", MergeField: "", Internal: false}, + "scripts.list": {Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "scriptcat", MergeField: "scripts", Internal: false}, "scripts.metadata.get": {Params: "ScriptUUIDParams", Result: "ScriptMetadata", Scope: "scripts:metadata:read", Effect: "read", Blocking: "none", Level: "L0", Peer: "scriptcat", MergeField: "", Internal: false}, "scripts.source.get": {Params: "ScriptsSourceGetParams", Result: "ScriptSource", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}, "scripts.source.grep": {Params: "ScriptsSourceGrepParams", Result: "ScriptSourceGrepResult", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}, @@ -1060,6 +1060,9 @@ const ( ErrorCodeRateLimited = "RATE_LIMITED" ErrorCodePayloadTooLarge = "PAYLOAD_TOO_LARGE" ErrorCodeInternalError = "INTERNAL_ERROR" + ErrorCodeScriptcatOffline = "SCRIPTCAT_OFFLINE" + ErrorCodeScriptcatNotFound = "SCRIPTCAT_NOT_FOUND" + ErrorCodeScriptcatAmbiguous = "SCRIPTCAT_AMBIGUOUS" ErrorCodeNoBrowserConnected = "NO_BROWSER_CONNECTED" ErrorCodeBrowserOffline = "BROWSER_OFFLINE" ErrorCodeBrowserNotFound = "BROWSER_NOT_FOUND" diff --git a/internal/pkg/protocol/generated/protocol.generated.ts b/internal/pkg/protocol/generated/protocol.generated.ts index 4631a48..62034c7 100644 --- a/internal/pkg/protocol/generated/protocol.generated.ts +++ b/internal/pkg/protocol/generated/protocol.generated.ts @@ -24,7 +24,10 @@ export const ERROR_CODES = [ "NOT_FOUND", "RATE_LIMITED", "PAYLOAD_TOO_LARGE", - "INTERNAL_ERROR" + "INTERNAL_ERROR", + "SCRIPTCAT_OFFLINE", + "SCRIPTCAT_NOT_FOUND", + "SCRIPTCAT_AMBIGUOUS" ] as const; export const CRYPTO = { "mac": "HMAC-SHA-256", diff --git a/internal/pkg/protocol/protocol.json b/internal/pkg/protocol/protocol.json index 60ba3e6..04b2c7b 100644 --- a/internal/pkg/protocol/protocol.json +++ b/internal/pkg/protocol/protocol.json @@ -24,7 +24,8 @@ "effect": "read", "blocking": "none", "level": "L0", - "peer": "scriptcat" + "peer": "scriptcat", + "mergeField": "scripts" }, "scripts.metadata.get": { "params": "ScriptUUIDParams", @@ -3898,6 +3899,9 @@ { "code": "RATE_LIMITED", "peers": ["scriptcat"] }, { "code": "PAYLOAD_TOO_LARGE", "peers": ["scriptcat", "browser"] }, { "code": "INTERNAL_ERROR", "peers": ["scriptcat", "browser"] }, + { "code": "SCRIPTCAT_OFFLINE", "peers": ["scriptcat"] }, + { "code": "SCRIPTCAT_NOT_FOUND", "peers": ["scriptcat"] }, + { "code": "SCRIPTCAT_AMBIGUOUS", "peers": ["scriptcat"] }, { "code": "NO_BROWSER_CONNECTED", "peers": ["browser"] }, { "code": "BROWSER_OFFLINE", "peers": ["browser"] }, { "code": "BROWSER_NOT_FOUND", "peers": ["browser"] }, diff --git a/internal/protocolgen/generate.go b/internal/protocolgen/generate.go index 82f1b30..1c2dab6 100644 --- a/internal/protocolgen/generate.go +++ b/internal/protocolgen/generate.go @@ -302,15 +302,11 @@ func validateDefinition(def definition) error { return fmt.Errorf("rpc %q: %w", name, err) } if method.MergeField != "" { - // 只有浏览器方法会有多个同时在线的目标,daemon 也只为它们合并。 - if method.Peer != protocol.PeerBrowser { - return fmt.Errorf("rpc %q: mergeField is only meaningful for browser methods", name) - } // 汇总调用并发发给每个实例;bridge 把 $/approvalPending 交给调用方的回调时假定只有一个目标在等人工决定。 if method.Blocking != protocol.BlockingNone { - return fmt.Errorf("rpc %q: a method that waits for a human cannot be merged across browsers", name) + return fmt.Errorf("rpc %q: a method that waits for a human cannot be merged across instances", name) } - if err := validateMergeField(def.Types[method.Result], method.MergeField); err != nil { + if err := validateMergeField(def.Types[method.Result], method.MergeField, method.Peer); err != nil { return fmt.Errorf("rpc %q: %w", name, err) } } @@ -399,8 +395,9 @@ func validateLevel(def definition, method method) error { const mergedHasMoreField = "hasMore" // validateMergeField 要求合并字段是结果类型中必填的对象数组属性,daemon 才能无条件地拼接各实例的列表; -// 合并时每一项都会加上来源浏览器的 browser 字段,项本身不能已有同名属性。结果若声明 hasMore,它必须是布尔值。 -func validateMergeField(result json.RawMessage, field string) error { +// 合并时每一项都会加上以对端类型命名的来源实例字段(browser / scriptcat),项本身不能已有同名属性。 +// 结果若声明 hasMore,它必须是布尔值。 +func validateMergeField(result json.RawMessage, field string, peer protocol.Peer) error { schema := parseSchema(result) if hasMore, ok := schema.Properties[mergedHasMoreField]; ok && parseSchema(hasMore).Type != "boolean" { return fmt.Errorf("%s must be a boolean for the merge to combine it", mergedHasMoreField) @@ -413,8 +410,8 @@ func validateMergeField(result json.RawMessage, field string) error { 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) + if _, taken := items.Properties[string(peer)]; taken { + return fmt.Errorf("mergeField %q items must not declare the %s property the merge adds", field, peer) } return nil } diff --git a/internal/protocolgen/generate_test.go b/internal/protocolgen/generate_test.go index d1da49a..d2be0fb 100644 --- a/internal/protocolgen/generate_test.go +++ b/internal/protocolgen/generate_test.go @@ -167,7 +167,7 @@ func TestGenerateProducesStronglyTypedRPCContracts(t *testing.T) { // 这两个摘要是生成给 ScriptCat 的文件的逐字节身份,必须与 ScriptCat 固定版本(CI 的 SCRIPTCAT_REF) // 里的副本一致;只有 ScriptCat 协议真的变化(如新增 ScriptCat 实例握手上下文)并同步到 ScriptCat 时才允许改动。 const ( - scriptCatProtocolSHA256 = "7e4b47356310a28ae181a08260d294a57b5fc264deeb410b7d5847a01e7d0e2d" + scriptCatProtocolSHA256 = "3d901cbd9f024c0a15d703dd3b6f00e0a68aa9eef58f32931ffc30e14081b249" scriptCatValidatorsSHA256 = "2b55453161a593907759c4be7d9a2caa09f71aed7155b3c840f93d33d04896bb" ) @@ -261,7 +261,7 @@ func TestGenerateGoBindingsCarryEveryPeer(t *testing.T) { `{Params: "TabsListParams", Result: "TabsListResult", Scope: "tabs:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "tabs", Internal: false}`, `{Params: "ScriptUUIDParams", Result: "ScriptsDeleteResult", Scope: "scripts:delete:request", Effect: "write", Blocking: "approval", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}`, `{Params: "ScriptsSourceGetParams", Result: "ScriptSource", Scope: "scripts:source:read", Effect: "read", Blocking: "disclosure", Level: "L2", Peer: "scriptcat", MergeField: "", Internal: false}`, - `{Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "scriptcat", MergeField: "", Internal: false}`, + `{Params: "ScriptsListParams", Result: "ScriptsListResult", Scope: "scripts:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "scriptcat", MergeField: "scripts", Internal: false}`, `ErrorCodeConfirmationRequired `, `= "CONFIRMATION_REQUIRED"`, `ErrorCodeUnsupported `, @@ -307,8 +307,9 @@ func TestGenerateRejectsInvalidPeerAnnotations(t *testing.T) { method(def, "tabs.list")["blocking"] = "approval" method(def, "tabs.list")["level"] = "L2" }, - "merge field on a ScriptCat method": func(def map[string]any) { - method(def, "scripts.list")["mergeField"] = "scripts" + "ScriptCat merge field whose items already carry a scriptcat property": func(def map[string]any) { + items := resultProperty(def, "ScriptsListResult", "scripts")["items"].(map[string]any) + items["properties"].(map[string]any)["scriptcat"] = map[string]any{"type": "string"} }, "error code without peers": func(def map[string]any) { def["errorCodes"].([]any)[0].(map[string]any)["peers"] = []any{} From b5424b3d524f6225d02d36f23cbd45c9ef05eab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 15:55:46 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=85=20test(protocol):=20scripts.lis?= =?UTF-8?q?t=20=E5=A3=B0=E6=98=8E=E5=90=88=E5=B9=B6=E5=AD=97=E6=AE=B5=20sc?= =?UTF-8?q?ripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/pkg/protocol/protocol_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/protocol/protocol_test.go b/internal/pkg/protocol/protocol_test.go index b119755..e62c4c6 100644 --- a/internal/pkg/protocol/protocol_test.go +++ b/internal/pkg/protocol/protocol_test.go @@ -46,7 +46,7 @@ func TestLoadExposesMethodPeerAndListMergeField(t *testing.T) { peer Peer mergeField string }{ - "scripts.list": {PeerScriptCat, ""}, + "scripts.list": {PeerScriptCat, "scripts"}, "tabs.list": {PeerBrowser, "tabs"}, "windows.list": {PeerBrowser, "windows"}, "tabs.open": {PeerBrowser, ""}, From a50a90ec7f3180eeb8069f460aeb745283cc4213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 16:00:56 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=91=B7=20CI=EF=BC=9AScriptCat=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E9=95=9C=E5=83=8F=E6=94=B9=E4=B8=BA=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=A4=9A=E5=AE=9E=E4=BE=8B=E5=90=8C=E6=AD=A5=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=20bc6f6bb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f4a434f..f22d4a6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -74,7 +74,7 @@ jobs: name: protocol schema and ScriptCat mirror runs-on: ubuntu-latest env: - SCRIPTCAT_REF: e32bb5a43d3061aa1d380f62328bd249fdf55ac8 + SCRIPTCAT_REF: bc6f6bb41abd4521d34e67245cde9b6f41dea024 steps: - uses: actions/checkout@v5 with: From de4bfc84ed2d6d02bce5b4ca7f330c38ab1fb77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 16:01:27 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20sctl=20scriptcats=20list/forget=EF=BC=8Csctl=20stat?= =?UTF-8?q?us=20=E9=80=90=E4=B8=AA=E5=88=97=E5=87=BA=20ScriptCat=20?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/README_zh-CN.md | 2 + docs/architecture.md | 1 + docs/cli.md | 3 +- docs/mcp.md | 7 +- internal/cli/cli.go | 1 + internal/cli/scriptcats.go | 100 ++++++++++ internal/cli/scriptcats_test.go | 171 ++++++++++++++++++ internal/cli/status.go | 15 ++ internal/client/control/client.go | 54 ++++-- internal/client/control/client_test.go | 36 ++++ internal/client/control/protocol.go | 32 +++- internal/daemon/bridge/scriptcat_instances.go | 119 ++++++++++++ .../daemon/bridge/scriptcat_instances_test.go | 130 +++++++++++++ internal/daemon/bridge/server.go | 1 + internal/daemon/controlapi/controlapi.go | 53 ++++++ internal/daemon/controlapi/controlapi_test.go | 99 ++++++++++ skills/sctl/SKILL.md | 3 +- 18 files changed, 802 insertions(+), 27 deletions(-) create mode 100644 internal/cli/scriptcats.go create mode 100644 internal/cli/scriptcats_test.go create mode 100644 internal/daemon/bridge/scriptcat_instances.go create mode 100644 internal/daemon/bridge/scriptcat_instances_test.go diff --git a/README.md b/README.md index 04c6d62..beea350 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ The install script verifies the release archive's sha256 and installs `sctl` int ```bash sctl status # daemon and ScriptCat + sctl scriptcats # paired ScriptCat instances sctl browsers # paired sctl Browser instances ``` @@ -120,6 +121,7 @@ browser's logins; see [`docs/cli.md`](./docs/cli.md#endpoint-for-playwright-and- | `sctl serve` / `connect` / `status` / `mcp` | Run the daemon, pair an extension, show connection status, serve MCP over stdio. | | `sctl service` | Run the daemon in the background as a user service (launchd, systemd, Task Scheduler). | | `sctl get` / `grep` / `install` / `edit` / `enable` / `disable` / `delete` | Manage ScriptCat userscripts. | +| `sctl scriptcats` | List paired ScriptCat instances, or forget one. | | `sctl browsers` | List paired sctl Browser instances, or forget one. | | `sctl tabs` / `windows` / `groups` | Tabs, windows, and tab groups. | | `sctl bookmarks` / `reading-list` | Bookmarks and the reading list. | diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index 1aa9bf7..cefb53b 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -71,6 +71,7 @@ irm https://raw.githubusercontent.com/scriptscat/sctl/main/scripts/install.ps1 | ```bash sctl status # daemon 与 ScriptCat + sctl scriptcats # 已配对的 ScriptCat 实例 sctl browsers # 已配对的 sctl Browser 实例 ``` @@ -110,6 +111,7 @@ ln -s "$PWD/skills/sctl" ~/.claude/skills/sctl | `sctl serve` / `connect` / `status` / `mcp` | 运行 daemon、配对扩展、查看连接状态、通过 stdio 提供 MCP。 | | `sctl service` | 以用户服务的形式在后台运行 daemon(launchd、systemd、任务计划程序)。 | | `sctl get` / `grep` / `install` / `edit` / `enable` / `disable` / `delete` | 管理 ScriptCat 用户脚本。 | +| `sctl scriptcats` | 列出已配对的 ScriptCat 实例,或忘记其中一个。 | | `sctl browsers` | 列出已配对的 sctl Browser 实例,或忘记其中一个。 | | `sctl tabs` / `windows` / `groups` | 标签页、窗口和标签组。 | | `sctl bookmarks` / `reading-list` | 书签和阅读列表。 | diff --git a/docs/architecture.md b/docs/architecture.md index de8735b..e712713 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,6 +149,7 @@ internal/cli/ # subcommand definitions; spans both sides, hence to 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 + scriptcats.go # sctl scriptcats [list] / sctl scriptcats forget tabs.go windows.go # sctl tabs list|open|close|activate, sctl windows list page*.go # sctl page snapshot|click|hover|fill|type|press|select|upload|scroll|goto|back|forward| # reload|wait|screenshot|eval|dialog|detach (reach /control/page through dispatchPage) diff --git a/docs/cli.md b/docs/cli.md index 5a5045c..0c8de76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,7 +15,8 @@ command line can use the skill in [`../skills/sctl/`](../skills/sctl/SKILL.md). | `sctl service install\|uninstall\|start\|stop\|restart\|enable\|disable\|status` | Run `sctl serve` in the background as a user service; see [Background service](#background-service-sctl-service). | | `sctl connect` | Open a one-time enrollment window for ScriptCat or sctl Browser. | | `sctl mcp [--name