diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index 88c39fa4d..5da3ba0a1 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -96,6 +96,7 @@ of sanitization patterns can otherwise look like matches — so don't rely on a | [`architecture.md`](./architecture.md) | Deep internals: process model, message passing; subsystem deep-dives split to [`references/architecture-services.md`](./references/architecture-services.md), [`references/architecture-data.md`](./references/architecture-data.md), [`references/architecture-gm-api.md`](./references/architecture-gm-api.md), [`references/architecture-execution.md`](./references/architecture-execution.md), [`references/architecture-build.md`](./references/architecture-build.md), [`references/architecture-agent.md`](./references/architecture-agent.md). | | [`cloud-sync.md`](./cloud-sync.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation.md`](./translation.md) | Translation / localization single source of truth. | +| [`mcp-bridge-guide.md`](./mcp-bridge-guide.md) | End-user how-to for the MCP bridge: install sctl, enable, pair (extension↔daemon + MCP client), tool table, CLI verbs, worked examples. Security rationale lives in the sctl repo's `THREAT-MODEL.md`/`PROTOCOL.md` — link, don't duplicate. `mcp-bridge-guide_zh-CN.md` is its zh-CN translation — keep in sync via [`translation.md`](./translation.md), don't fork content. | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules, fact-check / anti-drift discipline, and policy-consistency checks — for every tracked agent/contributor Markdown file, not just `AGENTS.md` + `docs/*`. | | [`README.md`](./README.md) | The index that points to all of the above. | | `.github/copilot-instructions.md` | Copilot-specific entry point and any genuine tool-specific differences; shared facts (architecture, commands, testing, design, translation, PR mechanics) route to the owning doc above instead of being copied. | diff --git a/docs/README.md b/docs/README.md index 13fc84aa4..8f0c00833 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,16 @@ | [`cloud-sync.md`](./cloud-sync.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、跨文档政策一致性核对、隐私清理、以及在 resolved final tree 上的复核方法,覆盖全部 tracked 的 agent/contributor Markdown(不止 `AGENTS.md` + `docs/*`,还包括 `.github/*.md`、package-local README)。**改/审文档前先读。** | +## MCP 桥接 / MCP Bridge + +内置于所有构建、**默认关闭**,从扩展设置开启;经本地伴随二进制 [`sctl`](https://github.com/scriptscat/sctl)(WebSocket daemon,仅回环 `127.0.0.1:8643`)通信,不新增浏览器权限、无 native-messaging 主机与安装器。 + +| 文档 | 说明 | +| --- | --- | +| [`mcp-bridge-guide.md`](./mcp-bridge-guide.md) | 使用指南:安装 sctl、启用桥接、两条配对(扩展↔daemon、MCP 客户端)、授权的实操步骤,附 6 个 MCP 工具、CLI 动词与真实用例(读取源码、请求安装、启停、删除脚本、撤销客户端)。**想实际用起来先读这份。**中文版见 [`mcp-bridge-guide_zh-CN.md`](./mcp-bridge-guide_zh-CN.md)。 | +| [`sctl` 仓库 `PROTOCOL.md`](https://github.com/scriptscat/sctl/blob/main/PROTOCOL.md) | 协议规范:扩展↔daemon WS 双向握手、扩展/客户端配对、bridge action、错误码、写操作阻塞语义。常量单源 `protocol.json` 与本仓库 [`src/app/service/service_worker/mcp/protocol.json`](../src/app/service/service_worker/mcp/protocol.json) 逐字节镜像(由 `protocol.conformance.test.ts` 守护)。 | +| [`sctl` 仓库 `THREAT-MODEL.md`](https://github.com/scriptscat/sctl/blob/main/THREAT-MODEL.md) | 威胁模型:两个信任锚点(长期密钥 K + 0600 控制令牌)、攻击面与对策、写路径人工审批 + TOCTOU、落盘凭据一览。 | + ## 翻译 / Translation | 文档 | 说明 | diff --git a/docs/develop.md b/docs/develop.md index f4eb30c2d..68dd31ce3 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -32,6 +32,19 @@ pre-commit runs `prettier --check` and `pnpm run typecheck` plus ESLint for stag After `pnpm run dev`, load `dist/ext` as an unpacked extension. The browser hot-reloads page changes, but edits to `manifest.json`, `service_worker`, `offscreen`, or `sandbox` require reloading the extension. +### The MCP bridge + +The MCP bridge (`src/app/service/service_worker/mcp/`) is **built into every profile and gated only +at runtime** by `mcp_enabled` (`SystemConfig`, device-local, off by default) — there is no build-time +flag and no `nativeMessaging` permission (both were removed when the transport moved to WebSocket). +It connects, from an offscreen WebSocket client (`src/app/service/offscreen/mcp-connect.ts`), to a +local companion binary [`sctl`](https://github.com/scriptscat/sctl) — a loopback-only WS daemon on +`127.0.0.1:8643`; the extension never listens on a port itself. Protocol constants are single-sourced +in [`mcp/protocol.json`](../src/app/service/service_worker/mcp/protocol.json), mirrored byte-for-byte +with the sctl repo and guarded by `protocol.conformance.test.ts`. See +[`mcp-bridge-guide.md`](./mcp-bridge-guide.md) for usage, and the sctl repo's `PROTOCOL.md` / +`THREAT-MODEL.md` for the wire protocol and security design. + ## Project Structure & Module Organization Core entry points live in `src` (`service_worker.ts`, `content.ts`, `inject.ts`, `offscreen.ts`, `sandbox.ts`). UI pages are in `src/pages`, with shared UI in `src/pages/components` and state in `src/pages/store`. Reusable domain code is in `src/pkg`; app services are in `src/app`; templates are in `src/template`; assets and translations are in `src/assets` and `src/locales`. Workspace packages live in `packages`, including browser mocks and filesystem adapters. Unit tests are colocated as `*.test.ts`/`*.test.tsx` or placed in `tests`; E2E specs are in `e2e`. diff --git a/docs/mcp-bridge-guide.md b/docs/mcp-bridge-guide.md new file mode 100644 index 000000000..36e177ca1 --- /dev/null +++ b/docs/mcp-bridge-guide.md @@ -0,0 +1,261 @@ +

+中文 English +

+ +# Using the ScriptCat MCP Bridge + +A practical, task-oriented guide to connecting an AI agent (Claude Desktop, Claude Code, or any +other [Model Context Protocol](https://modelcontextprotocol.io/) client) — or your own terminal — +to your ScriptCat userscripts, with worked examples of the flows you'll actually hit. + +The bridge is **built into every build but ships turned off**; you opt in from the extension's +settings. It talks to a small local companion binary, [`sctl`](https://github.com/scriptscat/sctl), +which runs a WebSocket daemon on `127.0.0.1:8643`; the extension connects to it as a client from an +offscreen document. No browser permission is added, and there is no native-messaging host or +installer to register. For *why* it's built this way (threat model, handshake, scope design, TOCTOU +guarantees) see the sctl repo's [`THREAT-MODEL.md`](https://github.com/scriptscat/sctl/blob/main/THREAT-MODEL.md) +and [`PROTOCOL.md`](https://github.com/scriptscat/sctl/blob/main/PROTOCOL.md); this guide is the +"how do I actually use it" companion. + +## What you get + +Once connected, an AI agent (over MCP) **or** you (over the `sctl` CLI) can: + +- List your installed userscripts and read their metadata (matches, grants, enabled state) — + read-only, no approval needed once the scope is granted. +- Read a script's full source — gated behind a one-time (or permanent, your choice) approval per + script per client, because source can contain secrets. (The `sctl` CLI is exempt from this + prompt — you typed the command yourself — but MCP agents are not.) +- **Request** installing a new script, enabling/disabling one, or deleting one. Every one is a + *request*: the call blocks and nothing changes until you review it and click Approve in a + ScriptCat window that pops up automatically. New installs stay disabled even after you approve, + unless you flip the enable switch on that same approval screen. + +There is no code path from an MCP or CLI request to a script mutation that skips your approval (see +"direct-allow mode" below for the one exception you can deliberately opt into). + +## 1. Prerequisites + +- The `sctl` binary. It's a single self-contained Go binary — no Node, no runtime deps. Build it + from the [`scriptscat/sctl`](https://github.com/scriptscat/sctl) repo: + + ```bash + go build -ldflags "-X github.com/scriptscat/sctl/internal/cli.Version=0.1.0" -o sctl ./cmd/sctl + ``` + + > **Version matters.** The extension refuses any daemon reporting a version below + > `minDaemonVersion` (currently `0.1.0`) and shows "Host outdated". A plain `go build` stamps + > `0.0.0-dev`, which is *below* the gate — always build with the `-ldflags` above (or use a + > release binary) for anything you intend to actually connect. + +- macOS, Linux, or Windows — `sctl` is loopback-only and cross-platform; there is no OS-specific + installer step. + +## 2. Start the daemon + +```bash +sctl serve +``` + +This binds the WebSocket hub on `127.0.0.1:8643` (loopback only — it refuses any non-loopback +address) and writes a `0600` control-token file that local `sctl` front-ends use. You can also skip +this step: `sctl pair`, `sctl mcp`, and the CLI verbs auto-start a detached `serve` if one isn't +already running. + +## 3. Enable the bridge in ScriptCat + +Open the extension's options page → **Tools** → **MCP Bridge (Developer)**. Flip "Enable MCP +bridge" — a dialog first explains what you're turning on (agents can list/read metadata freely; +everything else needs your approval). The connection address defaults to `ws://127.0.0.1:8643`; +leave it unless you moved the daemon. Status stays "Connecting…" until you pair (next step). + +## 4. Pair the extension with the daemon (one time) + +The extension and the daemon establish a shared long-term key once, so neither can be impersonated +by another local process. Generate a code from the daemon and enter it in the extension: + +```bash +sctl pair +``` + +This prints an 8-character one-time code (valid 2 minutes). In ScriptCat's MCP Bridge card, paste it +into the **Pairing code** field and click **Pair**. The two sides run a mutual handshake, the daemon +hands the extension a long-term key over an encrypted channel, and the status moves to **Connected**. +Re-pairing replaces the old key (only one extension instance is supported in this version). + +## 5a. Connect an MCP client (Claude Desktop, Claude Code, …) + +Each MCP agent pairs once, so it gets its own revocable identity and scopes. Because `sctl mcp`'s +stdout is the MCP protocol channel, pairing is a **separate** terminal command: + +```bash +sctl mcp pair --name "Claude Desktop" +``` + +This prints an 8-character code and blocks. ScriptCat shows a pairing dialog (in the open options +tab, or a popup) with the same code and a **scope checklist**. **Confirm the code matches**, tick +the scopes this client may *request*, and approve. The minted token is cached at +`/mcp-clients/Claude Desktop.json` (`0600`); ScriptCat only ever stores its hash. + +Then point your client's MCP config at the serving command (one identity per `--name`, so you can +run several client configs): + +```json +{ + "mcpServers": { + "scriptcat": { "command": "sctl", "args": ["mcp", "--name", "Claude Desktop"] } + } +} +``` + +Restart your client. It will list a `scriptcat` server exposing only the tools your approved scopes +allow. An unpaired or revoked `sctl mcp` serves zero tools and tells the model to run +`sctl mcp pair`. + +## 5b. …or just use the CLI + +The `sctl` verbs drive the exact same bridge with a built-in `sctl-cli` identity — no pairing, full +scope, but **writes still require your approval in the browser**: + +```bash +sctl scripts list # or --json for machine-readable output +sctl scripts info +sctl scripts source # raw source to stdout (no disclosure prompt for the CLI) +sctl install ./my-script.user.js # or a URL; blocks until you approve/reject in the browser +sctl enable +sctl disable +sctl rm +``` + +Write verbs block until you decide; **Ctrl-C** cancels the request (the browser confirm page is +dismissed). Exit codes: **0** approved/ok, **1** you rejected, **2** voided (timeout / Ctrl-C / +disconnect), **3** other error. + +## 6. Turn on write mode when you actually want changes made + +Even with write scopes granted, write requests are refused (`WRITE_MODE_DISABLED`) until you flip +**"Allow write requests this session"** in the Tools card. It is deliberately **not** persisted — +it resets on browser restart and can't be toggled from outside the ScriptCat UI. Separately, the +**write approval policy** chooses what happens to an allowed write request: + +- **Require approval** (default) — every write blocks on a per-item confirm page. +- **Allow directly** — write requests run immediately without per-item confirmation (an amber + warning marks this as a safety downgrade). Even here, new installs still default to disabled, and + reading source still needs approval. + +## Available MCP tools + +| Tool | What it needs from you | Write? | +|---|---|---| +| `scripts_list` | `scripts:list` scope | No | +| `scripts_metadata_get` | `scripts:metadata:read` scope | No | +| `scripts_source_get` | `scripts:source:read` scope **+ a one-time disclosure approval per script** | No | +| `scripts_install_request` | `scripts:install:request` scope + install approval | Yes | +| `scripts_toggle_request` | `scripts:toggle:request` scope + toggle approval | Yes | +| `scripts_delete_request` | `scripts:delete:request` scope + hold-to-confirm delete approval | Yes | + +Write tools are **blocking**: the call suspends until you approve or reject (there is no +operation-polling API — the result comes back on the same call). While it waits, the MCP server +sends progress notifications so clients don't time out; if the client disconnects or times out, the +operation is voided and its confirm page invalidated. + +## Case studies + +### Case 1 — "What userscripts do I have installed, and which are enabled?" + +Read-only, works the moment you're paired with `scripts:list`: + +> **You:** What userscripts do I have installed right now? +> **Agent:** *calls `scripts_list`* → an array of `{ uuid, name, type, enabled, updatedAt, +> hasUpdateUrl, … }` — no source, and only whether an update URL exists (metadata-tier, not +> secrets). +> **Agent:** "You have 12 scripts installed; 9 are enabled." + +No approval prompt appears — it's exactly as safe as looking at the Scripts list yourself. (The same +answer from your terminal: `sctl scripts list`.) + +### Case 2 — "Find and fix a bug in my auto-login script" + +This is the flow that needs the disclosure gate: + +> **You:** There's a bug in my "Auto Login" script — can you find and fix it? +> **Agent:** *calls `scripts_list`*, finds the uuid, *calls `scripts_metadata_get`* to confirm, +> then *calls `scripts_source_get`*. +> **Result:** the first `scripts_source_get` for *this script, this client* blocks. ScriptCat pops +> up: *"`Claude Desktop` wants to read the source of `Auto Login`. Source may contain secrets."* +> with **Deny**, **Allow once**, **Allow for this client**. +> +> - **Allow once** — this read succeeds; the *next* read prompts again. +> - **Allow for this client** — this and every future read of *this script* by *this client* +> succeed with no further prompt (a permanent per-script grant, not a blanket "read anything"). +> +> Say you pick "Allow once." The call returns the source, the agent spots the bug and (with write +> mode on and `scripts:install:request` granted) calls `scripts_install_request` with the fix. +> ScriptCat's install page opens: *"Requested by `Claude Desktop`"*, the source label, an expandable +> content SHA-256, and the normal permission/diff review — the enable switch defaults **off**, so +> even after you click Install the fixed version won't run until you enable it. + +### Case 3 — "Turn off the script that's breaking this site while I debug it" + +> **You:** Disable my "Ad Blocker Tweaks" script for now. +> **Agent:** *calls `scripts_list`* to find the uuid, then `scripts_toggle_request` with +> `{ uuid, enable: false }`. +> **Result:** if write mode is off, the call fails immediately with `WRITE_MODE_DISABLED`. If it's +> on, ScriptCat opens a lightweight confirm popup (script name, requesting client, Approve/Reject). +> You approve → the toggle runs → the blocking call returns success. +> +> Between your approval and the actual disable, ScriptCat re-checks that the script's code hasn't +> changed since the request (TOCTOU protection) — if you'd edited it meanwhile you'd get `CONFLICT`, +> and the agent would make a fresh request. + +### Case 4 — "Clean up scripts I don't use anymore" + +> **You:** Delete the three scripts I haven't used in months: X, Y, Z. +> **Agent:** calls `scripts_delete_request` three times, once per uuid. +> **Result:** the requests block and their confirm pages are shown **one at a time** (concurrent +> writes queue). Each Delete needs a **press-and-hold for 1.5 s** — harder to fumble than a click, +> since deletion also removes the script's stored values and isn't undoable. You can reject any +> independently; rejecting one doesn't touch the others. If you close a confirm page by mistake, the +> request stays pending — reopen it from the **Awaiting confirmation** row in the settings card. + +### Case 5 — Revoking access when you're done + +> Tools → MCP Bridge → the paired-clients list shows every client with its scopes and last-used +> time. **Revoke** → confirm → the daemon drops the token immediately and any in-flight or future +> call from that client fails. **"Revoke all clients & stop bridge"** does that for everyone and +> flips the enable switch off. (The `sctl-cli` identity isn't a paired client — it doesn't appear +> here and shares the bridge's lifecycle; stopping the bridge stops it.) + +## Auditing what happened + +The settings card has an **Audit log** — every bridge call (allowed or denied), pairing decision, +operation transition, and revocation, newest first, with client name, action, and outcome. It never +contains tokens or source; the audit writer is only given the action, client, and outcome. **Export +JSON** downloads it client-side; **Clear** wipes it (confirmed, irreversible). + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| Status stuck on "Connecting…" then "Host unreachable" | The daemon isn't running or is on a different address. Start `sctl serve` (or run any `sctl` command), and check the connection address in the card matches. | +| "Host outdated" | The daemon reports a version below `minDaemonVersion` (`0.1.0`) — almost always a plain `go build` (`0.0.0-dev`). Rebuild with the `-ldflags "…Version=0.1.0"` from step 1, or use a release binary. | +| Pairing never completes | `sctl pair` codes last 2 minutes; the bridge must be enabled and the daemon reachable so ScriptCat can run the handshake. Confirm the code in the browser matches the terminal before clicking Pair. | +| `sctl mcp` serves no tools / model says to run `sctl mcp pair` | That `--name` identity isn't paired (or was revoked). Run `sctl mcp pair --name ""`. | +| A write always returns `WRITE_MODE_DISABLED` | The session write switch is off (resets each browser restart, on purpose). Flip it in Tools → MCP Bridge. | +| A write returns `INSUFFICIENT_SCOPE` | The client wasn't granted that scope at pairing. Re-pair with the scope, or edit the client's scopes in the paired-clients list. (The `sctl-cli` identity always has full scope.) | +| `scripts_source_get` prompts again after you approved | You chose "Allow once" and the agent made a second read — expected; approve again, or pick "Allow for this client". | +| A CLI write exits `2` | The request was voided — you (or the client) timed out, Ctrl-C'd, or the extension disconnected before you decided. | + +## What this bridge deliberately does and doesn't do + +- It **does** open a loopback WebSocket listener (`127.0.0.1:8643`) — that's the trade for zero new + browser permissions and no installer. A web page can see the port is open, but every connection + must pass a bidirectional HMAC handshake before any business message; an unauthenticated socket is + dropped after 5 s with no information leaked. There is deliberately no Origin check (a non-browser + process can forge Origin freely — the handshake is the only real gate). +- It doesn't trust the client's own claims — the daemon re-derives which client is calling from the + authenticated session, and the extension independently re-checks that client's scopes against its + own record before acting. +- It can't defend against another process already running as your own OS user reading the paired + token or key file (both `0600`) — a documented, accepted residual limitation, not a bug. See the + sctl [`THREAT-MODEL.md`](https://github.com/scriptscat/sctl/blob/main/THREAT-MODEL.md). diff --git a/docs/mcp-bridge-guide_zh-CN.md b/docs/mcp-bridge-guide_zh-CN.md new file mode 100644 index 000000000..1519fcf99 --- /dev/null +++ b/docs/mcp-bridge-guide_zh-CN.md @@ -0,0 +1,226 @@ +

+中文 English +

+ +# 使用 ScriptCat MCP 桥接 + +一份面向实操、以任务为线索的指南:把 AI 代理(Claude Desktop、Claude Code,或任何 +[Model Context Protocol](https://modelcontextprotocol.io/) 客户端)——或你自己的终端——连接到你的 +ScriptCat 用户脚本,并附上你实际会遇到的每条流程的示例。 + +桥接**内置于所有构建,但默认关闭**;你从扩展设置里主动开启。它与一个小巧的本地伴随二进制 +[`sctl`](https://github.com/scriptscat/sctl) 通信:`sctl` 在 `127.0.0.1:8643` 上运行一个 WebSocket +守护进程,扩展从 offscreen 文档作为客户端连接过去。不新增任何浏览器权限,也没有 native-messaging +主机或安装器要注册。至于**为何这样设计**(威胁模型、握手、scope 设计、TOCTOU 保证),见 sctl 仓库的 +[`THREAT-MODEL.md`](https://github.com/scriptscat/sctl/blob/main/THREAT-MODEL.md) 与 +[`PROTOCOL.md`](https://github.com/scriptscat/sctl/blob/main/PROTOCOL.md);本指南是它们「怎么实际用起来」 +的配套。 + +## 你能得到什么 + +连接后,AI 代理(经 MCP)**或**你(经 `sctl` 命令行)可以: + +- 列出已安装的用户脚本并读取元数据(匹配规则、授权、启用状态)——只读,授予 scope 后无需审批。 +- 读取脚本完整源码——每个脚本、每个客户端首次读取需一次性(或永久,由你选)审批,因为源码可能包含 + 密钥等敏感信息。(`sctl` 命令行对此豁免——命令是你亲手输入的——但 MCP 代理不豁免。) +- **请求**安装新脚本、启用/禁用脚本、删除脚本。每一项都是**请求**:调用会阻塞,在你审阅并于自动弹出的 + ScriptCat 窗口点「批准」之前,什么都不会改变。即使批准,新安装的脚本仍默认禁用,除非你在同一审批页 + 勾选启用开关。 + +从 MCP 或 CLI 请求到脚本变更,没有任何绕过你审批的代码路径(唯一例外是你主动开启的「直接允许」模式,见下)。 + +## 1. 前置条件 + +- `sctl` 二进制。它是单个自包含的 Go 二进制——无需 Node、无运行时依赖。从 + [`scriptscat/sctl`](https://github.com/scriptscat/sctl) 仓库构建: + + ```bash + go build -ldflags "-X github.com/scriptscat/sctl/internal/cli.Version=0.1.0" -o sctl ./cmd/sctl + ``` + + > **版本很关键。** 扩展会拒绝版本低于 `minDaemonVersion`(当前 `0.1.0`)的 daemon,并显示「主机版本 + > 过旧」。普通 `go build` 会打上 `0.0.0-dev`,**低于门槛**——凡是要真正连接的构建,务必用上面的 + > `-ldflags`(或使用 release 二进制)。 + +- macOS、Linux 或 Windows——`sctl` 仅回环、跨平台;没有任何依赖操作系统的安装步骤。 + +## 2. 启动 daemon + +```bash +sctl serve +``` + +它在 `127.0.0.1:8643` 上绑定 WebSocket hub(仅回环——拒绝任何非回环地址),并写入一个 `0600` 的 +控制令牌文件供本机 `sctl` 前端使用。你也可以跳过这步:`sctl pair`、`sctl mcp` 与各 CLI 动词在 serve +未运行时会自动以 detached 方式拉起它。 + +## 3. 在 ScriptCat 中启用桥接 + +打开扩展选项页 → **工具** → **MCP 桥接(开发者)**。打开「启用 MCP 桥接」——弹窗会先说明你正在开启 +什么(代理可自由列出/读取元数据;其余都需你批准)。连接地址默认 `ws://127.0.0.1:8643`,未移动 daemon +就保持不变。状态会停在「连接中…」直到你完成配对(下一步)。 + +## 4. 让扩展与 daemon 配对(一次性) + +扩展与 daemon 一次性建立共享长期密钥,使两者都无法被其他本机进程冒充。从 daemon 生成配对码,填入扩展: + +```bash +sctl pair +``` + +它会打印一个 8 字符一次性配对码(有效 2 分钟)。在 ScriptCat 的 MCP 桥接卡片里,把它粘贴到**配对码** +输入框并点**配对**。双方跑一次相互握手,daemon 经加密信道把长期密钥交给扩展,状态转为**已连接**。 +重新配对会替换旧密钥(本版本只支持一个扩展实例)。 + +## 5a. 接入 MCP 客户端(Claude Desktop、Claude Code……) + +每个 MCP 代理各配对一次,从而拥有自己可撤销的身份与 scope。由于 `sctl mcp` 的 stdout 被 MCP 协议独占, +配对是一条**独立**的终端命令: + +```bash +sctl mcp pair --name "Claude Desktop" +``` + +它打印一个 8 字符配对码并阻塞。ScriptCat 弹出配对对话框(在已打开的选项页,或以弹窗形式),显示同一个码 +和一个 **scope 勾选清单**。**核对码一致**,勾选该客户端可以**请求**的 scope,然后批准。铸造出的令牌缓存 +在 `/mcp-clients/Claude Desktop.json`(`0600`);ScriptCat 只存它的哈希。 + +然后把客户端的 MCP 配置指向服务命令(一个 `--name` 一份身份,可同时跑多份客户端配置): + +```json +{ + "mcpServers": { + "scriptcat": { "command": "sctl", "args": ["mcp", "--name", "Claude Desktop"] } + } +} +``` + +重启客户端。它会列出一个 `scriptcat` 服务,只暴露你已批准的 scope 所允许的工具。未配对或已撤销的 +`sctl mcp` 提供零工具,并提示模型运行 `sctl mcp pair`。 + +## 5b. ……或直接用命令行 + +`sctl` 各动词以内建 `sctl-cli` 身份驱动同一座桥——不配对、全量 scope,但**写操作仍需你在浏览器审批**: + +```bash +sctl scripts list # 或 --json 输出结构化数据 +sctl scripts info +sctl scripts source # 源码输出到 stdout(CLI 不弹披露确认) +sctl install ./my-script.user.js # 或一个 URL;阻塞至你在浏览器批准/拒绝 +sctl enable +sctl disable +sctl rm +``` + +写动词阻塞至你决策;**Ctrl-C** 取消请求(浏览器确认页随之关闭)。退出码:**0** 批准/成功,**1** 你拒绝, +**2** 作废(超时 / Ctrl-C / 断开),**3** 其他错误。 + +## 6. 需要真正落地变更时,开启写模式 + +即便授予了写 scope,写请求仍会被拒绝(`WRITE_MODE_DISABLED`),直到你在工具卡片里打开 +**「允许本次会话的写请求」**。它**刻意不持久化**——浏览器重启即复位,且无法从 ScriptCat UI 之外开启。 +另外,**写审批策略**决定一个被允许的写请求会怎样处理: + +- **需人工审批**(默认)——每个写请求都在逐项确认页上阻塞。 +- **直接允许**——写请求立即执行,无需逐项确认(琥珀警示标明这是安全降级)。即便如此,新安装仍默认禁用, + 读取源码仍需审批。 + +## 可用 MCP 工具 + +| 工具 | 需要你什么 | 写? | +|---|---|---| +| `scripts_list` | `scripts:list` scope | 否 | +| `scripts_metadata_get` | `scripts:metadata:read` scope | 否 | +| `scripts_source_get` | `scripts:source:read` scope **+ 每脚本一次性披露审批** | 否 | +| `scripts_install_request` | `scripts:install:request` scope + 安装审批 | 是 | +| `scripts_toggle_request` | `scripts:toggle:request` scope + 启停审批 | 是 | +| `scripts_delete_request` | `scripts:delete:request` scope + 按住确认的删除审批 | 是 | + +写工具是**阻塞的**:调用挂起直到你批准或拒绝(没有操作轮询接口——结果就在这次调用里返回)。等待期间 +MCP 服务会发送 progress 通知以免客户端超时;若客户端断开或超时,操作作废、确认页失效。 + +## 案例 + +### 案例 1 —— 「我装了哪些用户脚本,哪些是启用的?」 + +只读,配对并授予 `scripts:list` 后立即可用: + +> **你:** 我现在装了哪些用户脚本? +> **代理:** *调用 `scripts_list`* → 得到 `{ uuid, name, type, enabled, updatedAt, hasUpdateUrl, … }` +> 数组——无源码,且只告知是否存在更新 URL(元数据层,非密钥)。 +> **代理:** 「你装了 12 个脚本,其中 9 个启用。」 + +全程不弹审批——与你自己看脚本列表一样安全。(同样的答案用终端:`sctl scripts list`。) + +### 案例 2 —— 「找出并修复我自动登录脚本里的 bug」 + +这是需要披露闸门的流程: + +> **你:** 我的「自动登录」脚本有个 bug,能找出来并修好吗? +> **代理:** *调用 `scripts_list`* 找到 uuid,*调用 `scripts_metadata_get`* 确认,再 *调用 +> `scripts_source_get`*。 +> **结果:** 针对*此脚本、此客户端*的首次 `scripts_source_get` 会阻塞。ScriptCat 弹出:*「`Claude +> Desktop` 想要读取「自动登录」的源码。源码中可能包含密钥等敏感信息。」*,附**拒绝**、**仅本次允许**、 +> **对该客户端始终允许**。 +> +> - **仅本次允许**——这次读取成功;*下次*读取再次弹窗。 +> - **对该客户端始终允许**——此客户端对*此脚本*的今后每次读取都不再弹窗(每脚本的永久授予,不是「允许 +> 该客户端读任何东西」)。 +> +> 假设你选「仅本次允许」。调用返回源码,代理找出 bug,并(在写模式开启且授予了 `scripts:install:request` +> 时)带上修复后的代码调用 `scripts_install_request`。ScriptCat 安装页打开:*「由 `Claude Desktop` +> 请求」*、来源标签、可展开的内容 SHA-256,以及常规的权限/差异审阅界面——启用开关默认**关**,即便你点了 +> 安装,修好的版本在你启用前也不会运行。 + +### 案例 3 —— 「先把弄坏这个站点的脚本关掉,我要调试」 + +> **你:** 先禁用我的「广告拦截微调」脚本。 +> **代理:** *调用 `scripts_list`* 找 uuid,再 `scripts_toggle_request` 带 `{ uuid, enable: false }`。 +> **结果:** 若写模式关,调用立刻以 `WRITE_MODE_DISABLED` 失败。若开,ScriptCat 打开一个轻量确认弹窗 +> (脚本名、请求方、批准/拒绝)。你批准 → 执行启停 → 阻塞的调用返回成功。 +> +> 在你批准与实际禁用之间,ScriptCat 会复核脚本代码自请求以来未变(TOCTOU 防护)——若你期间改过,会得到 +> `CONFLICT`,代理需重新请求。 + +### 案例 4 —— 「清理我不再用的脚本」 + +> **你:** 删掉我几个月没用的这三个:X、Y、Z。 +> **代理:** 按 uuid 调用 `scripts_delete_request` 三次。 +> **结果:** 请求阻塞,确认页**逐个**呈现(并发写排队)。每个删除都需**按住 1.5 秒**——比单击更难误触, +> 因为删除还会移除脚本已存储的值且不可撤销。你可独立拒绝任意一个;拒一个不影响其余。若不慎关掉确认页, +> 请求仍挂起——从设置卡片的**待确认**行重新打开。 + +### 案例 5 —— 用完后撤销访问 + +> 工具 → MCP 桥接 → 已配对客户端列表列出每个客户端及其 scope 和最近使用时间。**撤销** → 确认 → daemon +> 立即丢弃令牌,该客户端任何在途或今后的调用都失败。**「撤销所有客户端并停止桥接」**对所有人执行此操作 +> 并关闭启用开关。(`sctl-cli` 身份不是已配对客户端——不出现在此处,与桥接同生命周期;停止桥接即停止它。) + +## 审计发生了什么 + +设置卡片有**审计日志**——每次桥接调用(允许或拒绝)、每次配对决策、每次操作状态流转、每次撤销,最新在前, +含客户端名、动作与结果。它从不包含令牌或源码;审计写入方只拿到动作、客户端与结果。**导出 JSON** 在本地 +下载;**清空**抹除(有确认,不可撤销)。 + +## 排障 + +| 现象 | 可能原因 | +|---|---| +| 状态卡在「连接中…」后转「无法连接主机」 | daemon 没运行或地址不同。启动 `sctl serve`(或跑任意 `sctl` 命令),并核对卡片里的连接地址一致。 | +| 「主机版本过旧」 | daemon 报告的版本低于 `minDaemonVersion`(`0.1.0`)——几乎总是普通 `go build`(`0.0.0-dev`)。用第 1 步的 `-ldflags "…Version=0.1.0"` 重建,或用 release 二进制。 | +| 配对始终完不成 | `sctl pair` 的码有效 2 分钟;桥接须已启用且 daemon 可达,ScriptCat 才能跑握手。点配对前先核对浏览器里的码与终端一致。 | +| `sctl mcp` 无工具 / 模型提示运行 `sctl mcp pair` | 该 `--name` 身份未配对(或已被撤销)。运行 `sctl mcp pair --name "<同一名字>"`。 | +| 写操作总返回 `WRITE_MODE_DISABLED` | 会话写开关关着(每次浏览器重启复位,刻意为之)。在工具 → MCP 桥接里打开。 | +| 写操作返回 `INSUFFICIENT_SCOPE` | 配对时未授予该 scope。带上该 scope 重新配对,或在已配对客户端列表里编辑其 scope。(`sctl-cli` 身份始终全量 scope。) | +| 批准后 `scripts_source_get` 又弹窗 | 你选了「仅本次允许」而代理又读了一次——正常;再批准一次,或选「对该客户端始终允许」。 | +| CLI 写操作退出码 `2` | 请求被作废——你(或客户端)超时、Ctrl-C,或扩展在你决策前断开。 | + +## 本桥接刻意做与不做什么 + +- 它**确实**开了一个回环 WebSocket 监听(`127.0.0.1:8643`)——这是换取零新增浏览器权限、无安装器的代价。 + 网页能看到端口开着,但每条连接必须先过双向 HMAC 握手才能收发业务消息;无凭据的 socket 在 5 秒后被断开, + 不泄露任何信息。刻意**不做 Origin 判别**(非浏览器进程可任意伪造 Origin——真正的闸门只有握手)。 +- 它不信任客户端的自述——daemon 从已认证会话重新推导是哪个客户端在调用,扩展再独立地用自己的记录复核该 + 客户端的 scope 才动作。 +- 它挡不住已以你本人操作系统用户身份运行的进程读取已配对令牌或密钥文件(均 `0600`)——这是有记录、被接受 + 的残余局限,不是 bug。见 sctl [`THREAT-MODEL.md`](https://github.com/scriptscat/sctl/blob/main/THREAT-MODEL.md)。 diff --git a/rspack.config.ts b/rspack.config.ts index 55470a397..0585196ba 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -64,6 +64,7 @@ export default { popup: `${src}/pages/popup/main.tsx`, options: `${src}/pages/options/main.tsx`, confirm: `${src}/pages/confirm/main.tsx`, + mcp_confirm: `${src}/pages/mcp_confirm/main.tsx`, batchupdate: `${src}/pages/batchupdate/main.tsx`, install: `${src}/pages/install/main.tsx`, import: `${src}/pages/import/main.tsx`, @@ -203,6 +204,14 @@ export default { minify: true, chunks: ["confirm"], }), + new rspack.HtmlRspackPlugin({ + filename: `${dist}/ext/src/mcp_confirm.html`, + template: `${src}/pages/mcp_confirm.html`, + inject: "head", + title: "ScriptCat", + minify: true, + chunks: ["mcp_confirm"], + }), new rspack.HtmlRspackPlugin({ filename: `${dist}/ext/src/batchupdate.html`, template: `${src}/pages/batchupdate.html`, diff --git a/src/app/repo/mcp.test.ts b/src/app/repo/mcp.test.ts new file mode 100644 index 000000000..e0c360b18 --- /dev/null +++ b/src/app/repo/mcp.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { McpClientDAO, McpOperationDAO, McpAuditDAO, MCP_AUDIT_RING_BUFFER_SIZE } from "./mcp"; +import type { McpClient, McpOperation, McpAuditEvent } from "./mcp"; + +function makeClient(overrides: Partial = {}): McpClient { + return { + clientId: "client-1", + displayName: "Test Client", + tokenHash: "hash-1", + scopes: ["scripts:list"], + createdAt: Date.now(), + lastUsedAt: Date.now(), + revoked: false, + ...overrides, + }; +} + +function makeOperation(overrides: Partial = {}): McpOperation { + const now = Date.now(); + return { + operationId: "op-1", + clientId: "client-1", + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + 5 * 60_000, + requestedEnabledState: false, + ...overrides, + }; +} + +function makeAuditEvent(overrides: Partial = {}): McpAuditEvent { + return { + eventId: "evt-1", + timestamp: Date.now(), + clientId: "client-1", + clientName: "Test Client", + action: "scripts.list", + decision: "allowed", + correlationId: "corr-1", + ...overrides, + }; +} + +describe("McpClientDAO", () => { + let dao: McpClientDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpClientDAO(); + }); + + it("save / get 往返读写一条客户端记录", async () => { + const client = makeClient(); + await dao.save(client); + const result = await dao.get(client.clientId); + expect(result).toEqual(client); + }); + + it("all 返回所有客户端记录", async () => { + await dao.save(makeClient({ clientId: "a" })); + await dao.save(makeClient({ clientId: "b" })); + const all = await dao.all(); + expect(all.map((c) => c.clientId).sort()).toEqual(["a", "b"]); + }); +}); + +describe("McpOperationDAO", () => { + let dao: McpOperationDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpOperationDAO(); + }); + + it("save / get 往返读写一条待批操作", async () => { + const op = makeOperation(); + await dao.save(op); + const result = await dao.get(op.operationId); + expect(result).toEqual(op); + }); + + it("byClient 只返回属于该 clientId 的操作", async () => { + await dao.save(makeOperation({ operationId: "op-a", clientId: "client-a" })); + await dao.save(makeOperation({ operationId: "op-b", clientId: "client-b" })); + const forA = await dao.byClient("client-a"); + expect(forA.map((o) => o.operationId)).toEqual(["op-a"]); + }); +}); + +describe("McpAuditDAO - 环形缓冲", () => { + let dao: McpAuditDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpAuditDAO(); + }); + + it("append 写入的事件可通过 all 读回", async () => { + await dao.append(makeAuditEvent({ eventId: "evt-a" })); + await dao.append(makeAuditEvent({ eventId: "evt-b" })); + const all = await dao.all(); + expect(all.map((e) => e.eventId).sort()).toEqual(["evt-a", "evt-b"]); + }); + + it(`append 超过 ${MCP_AUDIT_RING_BUFFER_SIZE} 条时裁剪最旧的事件`, async () => { + for (let i = 0; i < MCP_AUDIT_RING_BUFFER_SIZE + 10; i++) { + await dao.append(makeAuditEvent({ eventId: `evt-${i}`, timestamp: i })); + } + const all = await dao.all(); + expect(all.length).toBe(MCP_AUDIT_RING_BUFFER_SIZE); + // The 10 oldest (evt-0..evt-9) must have been pruned. + const ids = new Set(all.map((e) => e.eventId)); + expect(ids.has("evt-0")).toBe(false); + expect(ids.has("evt-9")).toBe(false); + expect(ids.has(`evt-${MCP_AUDIT_RING_BUFFER_SIZE + 9}`)).toBe(true); + }); + + it("clear 清空所有审计事件", async () => { + await dao.append(makeAuditEvent({ eventId: "evt-a" })); + await dao.clear(); + expect(await dao.all()).toEqual([]); + }); +}); diff --git a/src/app/repo/mcp.ts b/src/app/repo/mcp.ts new file mode 100644 index 000000000..7832cd54c --- /dev/null +++ b/src/app/repo/mcp.ts @@ -0,0 +1,119 @@ +import { Repo } from "./repo"; +import type { + McpScope, + OperationKind, + OperationStatus, + BridgeErrorCode, +} from "@App/app/service/service_worker/mcp/types"; + +// 客户端记录:主机侧(原生消息宿主)持有权威 token store,本记录是扩展侧的镜像,仅用于 UI 展示与 scope 判定。 +export interface McpClient { + clientId: string; // 配对时生成的随机 UUID + displayName: string; // 用户可编辑,展示时需转义 + tokenHash: string; // SHA-256(token) 十六进制;token 本身从不落地扩展侧 + scopes: McpScope[]; + createdAt: number; + lastUsedAt: number; + revoked: boolean; + // 用户在源码披露弹窗中选择「对该客户端始终允许」时记录的脚本 uuid 列表—— + // 一次性批准(「仅本次允许」)不会写入此处,只消费单次待批操作。 + sourceDisclosureAllowed?: string[]; +} + +export class McpClientDAO extends Repo { + constructor() { + super("mcpClient"); + } + + save(client: McpClient): Promise { + return this._save(client.clientId, client); + } +} + +// 待批操作:绑定请求当时的内容哈希/目标脚本状态等字段全部保留,执行器 +// (McpApprovalService.decide)在批准瞬间重新校验这些字段,防止请求与批准之间的 TOCTOU 篡改。 +export interface McpOperation { + operationId: string; // 加密安全随机 UUID + clientId: string; + kind: OperationKind; + status: OperationStatus; + createdAt: number; + expiresAt: number; // createdAt + 5 分钟 + sourceUrl?: string; + contentHash?: string; // 暂存代码的 SHA-256 + stagedUuid?: string; // TempStorageDAO 的 key + targetUuid?: string; // 更新/启用/禁用/删除的目标脚本 + existingCodeHash?: string; // 请求时目标脚本当前代码的 SHA-256 + requestedEnabledState: false; // 安装操作恒为 false,仅为文档化约束保留字面量类型 + // 阻塞语义下发起该操作的 bridge.request.requestId:终态决策/断开作废时据此把 bridge.response + // 经 offscreen 回发给 daemon(不在 SW 内存里悬挂 Promise)。「直接允许」立即执行的操作不寻址 + // 任何挂起请求,故不带 requestId。 + requestId?: string; + decidedAt?: number; + errorCode?: BridgeErrorCode; +} + +export class McpOperationDAO extends Repo { + constructor() { + super("mcpOperation"); + } + + save(operation: McpOperation): Promise { + return this._save(operation.operationId, operation); + } + + byClient(clientId: string): Promise { + return this.find((_key, value) => value.clientId === clientId); + } + + // 断开作废按 bridge.request.requestId 定位待批操作(daemon 只知道 requestId,不知 operationId)。 + byRequestId(requestId: string): Promise { + return this.findOne((_key, value) => value.requestId === requestId); + } + + // 串行确认队列的数据源:所有仍在等待用户决策的操作,用于关闭当前确认页后弹出下一个。 + awaitingUser(): Promise { + return this.find((_key, value) => value.status === "awaiting_user"); + } +} + +// 审计事件:环形缓冲,永不记录 token、脚本源码或 URL 中的凭据。 +export interface McpAuditEvent { + eventId: string; + timestamp: number; + clientId: string; + clientName: string; + action: string; + targetUuid?: string; + sourceHost?: string; + contentHash?: string; + decision: "allowed" | "denied" | "awaiting_user" | "approved" | "rejected" | "expired"; + result?: "success" | "failure"; + errorCode?: string; + correlationId: string; +} + +export const MCP_AUDIT_RING_BUFFER_SIZE = 500; + +export class McpAuditDAO extends Repo { + constructor() { + super("mcpAudit"); + } + + // 追加一条事件,超出环形缓冲上限时裁剪最旧的记录(按 timestamp 排序)。 + async append(event: McpAuditEvent): Promise { + await this._save(event.eventId, event); + const all = await this.all(); + if (all.length <= MCP_AUDIT_RING_BUFFER_SIZE) { + return; + } + const sorted = [...all].sort((a, b) => a.timestamp - b.timestamp); + const toPrune = sorted.slice(0, sorted.length - MCP_AUDIT_RING_BUFFER_SIZE); + await this.deletes(toPrune.map((e) => e.eventId)); + } + + async clear(): Promise { + const all = await this.all(); + await this.deletes(all.map((e) => e.eventId)); + } +} diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 90ed1cb5b..be9cad283 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -8,6 +8,7 @@ import { sendMessage } from "@Packages/message/client"; import GMApi from "./gm_api"; import { MessageQueue } from "@Packages/message/message_queue"; import { VSCodeConnect } from "./vscode-connect"; +import { McpConnect } from "./mcp-connect"; import { HtmlExtractorService } from "./html_extractor"; import { makeBlobURL } from "@App/pkg/utils/utils"; @@ -71,6 +72,8 @@ export class BackgroundEnvManagerBase { gmApi.init(); const vscodeConnect = new VSCodeConnect(this.offscreenServer.group("vscodeConnect"), this.extMsgSender); vscodeConnect.init(); + const mcpConnect = new McpConnect(this.offscreenServer.group("mcpConnect"), this.extMsgSender); + mcpConnect.init(); const htmlExtractor = new HtmlExtractorService(this.offscreenServer.group("htmlExtractor")); htmlExtractor.init(); diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index 93bfee267..9da025076 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -3,6 +3,8 @@ import type { SCRIPT_RUN_STATUS, ScriptRunResource } from "@App/app/repo/scripts import { Client, sendMessage } from "@Packages/message/client"; import type { MessageSend } from "@Packages/message/types"; import { type VSCodeConnectParam } from "./vscode-connect"; +import { type McpConnectParam } from "./mcp-connect"; +import type { WSEnvelope } from "../service_worker/mcp/types"; export function preparationSandbox(windowMessage: WindowMessage) { return sendMessage(windowMessage, "offscreen/preparationSandbox"); @@ -105,3 +107,24 @@ export class VscodeConnectClient extends Client { return this.do("connect", params); } } + +// SW → offscreen driver for the MCP WS transport. McpController uses it to open/close the socket +// and to hand the offscreen McpConnect outbound envelopes (bridge.response / pair.decision / +// client.revoke / bridge.shutdown) to write onto the wire. +export class McpConnectClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "offscreen/mcpConnect"); + } + + connect(params: McpConnectParam): Promise { + return this.do("connect", params); + } + + disconnect(): Promise { + return this.do("disconnect"); + } + + send(envelope: WSEnvelope): Promise { + return this.do("send", envelope); + } +} diff --git a/src/app/service/offscreen/mcp-connect.test.ts b/src/app/service/offscreen/mcp-connect.test.ts new file mode 100644 index 000000000..c1ac6474f --- /dev/null +++ b/src/app/service/offscreen/mcp-connect.test.ts @@ -0,0 +1,483 @@ +import { initTestEnv } from "@Tests/utils"; +import { + McpConnect, + bytesToHex, + computeHandshakeHmac, + constantTimeEqualHex, + decryptLongTermKey, + derivePairingKeys, + hexToBytes, + normalizePairingCode, + randomNonceHex, + utf8Bytes, + type McpConnectParam, +} from "./mcp-connect"; +import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server } from "@Packages/message/server"; +import EventEmitter from "eventemitter3"; +import { createCipheriv, createHmac, hkdfSync, randomBytes } from "node:crypto"; +import protocol from "../service_worker/mcp/protocol.json"; + +initTestEnv(); + +const CTX = protocol.crypto.context; + +// ──────────────────────────────────────────────── +// 独立参照实现(node:crypto)——同时充当 daemon 模拟器,钉住线格式与 sctl 互操作 +// ──────────────────────────────────────────────── + +function nodeHmacHex(keyBytes: Uint8Array, context: string, a: string, b: string): string { + return createHmac("sha256", Buffer.from(keyBytes)) + .update(context + a + b) + .digest("hex"); +} + +function nodePairingKeys(code: string): { mac: Buffer; enc: Buffer } { + const ikm = Buffer.from(normalizePairingCode(code), "utf8"); + const salt = Buffer.from(CTX.pairKdfSalt, "utf8"); + const mac = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoMac, "utf8"), 32)); + const enc = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoEnc, "utf8"), 32)); + return { mac, enc }; +} + +function nodeEncryptKey(encKey: Buffer, plaintext: Buffer): { ciphertext: string; iv: string } { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", encKey, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return { ciphertext: Buffer.concat([ct, tag]).toString("base64"), iv: iv.toString("base64") }; +} + +// ──────────────────────────────────────────────── +// Mock WebSocket +// ──────────────────────────────────────────────── + +type WSReadyState = 0 | 1 | 2 | 3; + +class MockWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readyState: WSReadyState = MockWebSocket.CONNECTING; + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + + readonly url: string; + sentMessages: string[] = []; + + constructor(url: string) { + this.url = url; + } + + send(data: string) { + this.sentMessages.push(data); + } + + close() { + this.readyState = MockWebSocket.CLOSED; + setTimeout(() => this.onclose?.(new CloseEvent("close")), 0); + } + + simulateOpen() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(new Event("open")); + } + + simulateMessage(data: unknown) { + this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); + } + + simulateClose() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(new CloseEvent("close")); + } + + simulateError() { + this.onerror?.(new Event("error")); + } + + /** 返回第 index 个已发送信封(解析后) */ + sent(index: number): any { + return JSON.parse(this.sentMessages[index]); + } +} + +let wsInstances: MockWebSocket[] = []; + +function stubWebSocket() { + vi.stubGlobal( + "WebSocket", + Object.assign( + function (url: string) { + const ws = new MockWebSocket(url); + wsInstances.push(ws); + return ws; + }, + { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 } + ) + ); +} + +// ──────────────────────────────────────────────── + +describe("McpConnect", () => { + // 握手用例走真实 WebCrypto(HKDF/HMAC/AES-GCM),比纯逻辑用例重;在满载并行下会超出 fast + // 项目默认的 340ms 预算,故为本文件放宽超时(真实互操作向量不宜用 mock 替换)。 + beforeAll(() => vi.setConfig({ testTimeout: 5000 })); + + let mcpConnect: McpConnect; + let relay: { + envelope: ReturnType; + paired: ReturnType; + disconnected: ReturnType; + }; + + beforeEach(() => { + wsInstances = []; + stubWebSocket(); + + const ee = new EventEmitter(); + const mockMessage = new MockMessage(ee); + const server = new Server("offscreen", mockMessage); + const group = server.group("mcpConnect"); + + mcpConnect = new McpConnect(group, mockMessage); + relay = { + envelope: vi.fn().mockResolvedValue(undefined), + paired: vi.fn().mockResolvedValue(undefined), + disconnected: vi.fn().mockResolvedValue(undefined), + }; + (mcpConnect as any).relay = relay; + mcpConnect.init(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + function triggerConnect(param: McpConnectParam): MockWebSocket { + (mcpConnect as any).startSession(param); + return wsInstances[wsInstances.length - 1]; + } + + const sessionParam = (key: string): McpConnectParam => ({ + url: "ws://127.0.0.1:8643", + auth: { mode: "session", key }, + }); + + // ──────────────────────────────────────────────── + describe("WebCrypto 握手原语", () => { + it("computeHandshakeHmac 与 node:crypto 参照实现逐字节一致", async () => { + const key = randomBytes(32); + const nonceD = randomBytes(32).toString("hex"); + const nonceE = randomBytes(32).toString("hex"); + const got = await computeHandshakeHmac(new Uint8Array(key), CTX.sessionExt, nonceD, nonceE); + expect(got).toBe(nodeHmacHex(new Uint8Array(key), CTX.sessionExt, nonceD, nonceE)); + }); + + it("derivePairingKeys 的 HKDF 输出与 node:crypto 参照实现一致", async () => { + const { mac, enc } = await derivePairingKeys("ABCD-2345"); + const ref = nodePairingKeys("ABCD-2345"); + expect(bytesToHex(mac)).toBe(ref.mac.toString("hex")); + expect(bytesToHex(enc)).toBe(ref.enc.toString("hex")); + expect(bytesToHex(mac)).not.toBe(bytesToHex(enc)); // info 不同 → 两把密钥不同 + }); + + it("decryptLongTermKey 能还原 AES-256-GCM(ct||tag) 加密的长期密钥", async () => { + const enc = randomBytes(32); + const k = randomBytes(32); + const { ciphertext, iv } = nodeEncryptKey(enc, k); + const got = await decryptLongTermKey(new Uint8Array(enc), iv, ciphertext); + expect(got).toBe(k.toString("hex")); + }); + + it("normalizePairingCode 去连字符/大写并按 Crockford 映射 O→0、I/L→1", () => { + expect(normalizePairingCode("abcd-2345")).toBe("ABCD2345"); + expect(normalizePairingCode(" o1l-o0 ")).toBe("01100"); + }); + + it("constantTimeEqualHex 对相同/不同/不等长返回正确布尔", () => { + expect(constantTimeEqualHex("deadbeef", "deadbeef")).toBe(true); + expect(constantTimeEqualHex("deadbeef", "deadbeff")).toBe(false); + expect(constantTimeEqualHex("dead", "deadbeef")).toBe(false); + }); + + it("hexToBytes 与 bytesToHex 互逆,randomNonceHex 产生 32 字节小写 hex", () => { + const bytes = new Uint8Array([0, 15, 16, 255]); + expect(bytesToHex(bytes)).toBe("000f10ff"); + expect([...hexToBytes("000f10ff")]).toEqual([0, 15, 16, 255]); + const nonce = randomNonceHex(); + expect(nonce).toMatch(/^[0-9a-f]{64}$/); + expect(utf8Bytes("a").length).toBe(1); + }); + }); + + // ──────────────────────────────────────────────── + describe("会话握手(已配对,双向 HMAC)", () => { + it("完成 challenge → response → ok 后握手成功,hello 上抛给 SW", async () => { + const K = randomBytes(32); + const ws = triggerConnect(sessionParam(K.toString("hex"))); + ws.simulateOpen(); + + const nonceD = randomBytes(32).toString("hex"); + ws.simulateMessage({ v: 1, type: "auth.challenge", requestId: "c1", payload: { nonceD } }); + + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); + const resp = ws.sent(0); + expect(resp.type).toBe("auth.response"); + expect(resp.payload.mode).toBe("session"); + // 扩展侧 HMAC 用 sessionExt || nonceD || nonceE,参照实现逐字节复核 + expect(resp.payload.hmac).toBe(nodeHmacHex(new Uint8Array(K), CTX.sessionExt, nonceD, resp.payload.nonceE)); + + const okHmac = nodeHmacHex(new Uint8Array(K), CTX.sessionDaemon, resp.payload.nonceE, nonceD); + ws.simulateMessage({ v: 1, type: "auth.ok", requestId: "c1", payload: { hmac: okHmac } }); + await vi.waitFor(() => expect((mcpConnect as any).handshakeComplete).toBe(true)); + + ws.simulateMessage({ + v: 1, + type: "hello", + requestId: "h1", + payload: { daemonVersion: "0.1.0", protocolVersion: 1 }, + }); + await vi.waitFor(() => expect(relay.envelope).toHaveBeenCalledTimes(1)); + expect(relay.envelope.mock.calls[0][0].type).toBe("hello"); + }); + + it("auth.ok 的 daemon HMAC 不匹配时断开连接、不完成握手", async () => { + const K = randomBytes(32); + const ws = triggerConnect(sessionParam(K.toString("hex"))); + ws.simulateOpen(); + ws.simulateMessage({ + v: 1, + type: "auth.challenge", + requestId: "c1", + payload: { nonceD: randomBytes(32).toString("hex") }, + }); + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); + + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ v: 1, type: "auth.ok", requestId: "c1", payload: { hmac: "00".repeat(32) } }); + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + expect((mcpConnect as any).handshakeComplete).toBe(false); + }); + + it("握手完成前收到业务消息立即断开", async () => { + const ws = triggerConnect(sessionParam(randomBytes(32).toString("hex"))); + ws.simulateOpen(); + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ v: 1, type: "bridge.request", requestId: "r1", payload: {} }); + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + expect(relay.envelope).not.toHaveBeenCalled(); + }); + + it("v 不等于 1 的信封立即断开", async () => { + const ws = triggerConnect(sessionParam(randomBytes(32).toString("hex"))); + ws.simulateOpen(); + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ v: 2, type: "auth.challenge", requestId: "c1", payload: {} }); + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + }); + }); + + // ──────────────────────────────────────────────── + describe("配对握手(首次配对)", () => { + it("校验 daemon、解密新长期密钥 K 并上抛 paired,随后切换为会话模式", async () => { + const code = "MNBV-3456"; + const { mac, enc } = nodePairingKeys(code); + const K = randomBytes(32); + + const ws = triggerConnect({ url: "ws://127.0.0.1:8643", auth: { mode: "pairing", code } }); + ws.simulateOpen(); + + const nonceD = randomBytes(32).toString("hex"); + ws.simulateMessage({ v: 1, type: "auth.challenge", requestId: "c1", payload: { nonceD } }); + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); + const resp = ws.sent(0); + expect(resp.payload.mode).toBe("pairing"); + // 配对模式用 pairExt 上下文 + Kp_mac;且 auth.response 不含配对码本身 + expect(resp.payload.hmac).toBe(nodeHmacHex(new Uint8Array(mac), CTX.pairExt, nonceD, resp.payload.nonceE)); + expect(resp.payload.code).toBeUndefined(); + + const okHmac = nodeHmacHex(new Uint8Array(mac), CTX.pairDaemon, resp.payload.nonceE, nonceD); + const key = nodeEncryptKey(enc, K); + ws.simulateMessage({ v: 1, type: "auth.ok", requestId: "c1", payload: { hmac: okHmac, key } }); + + await vi.waitFor(() => expect(relay.paired).toHaveBeenCalledTimes(1)); + expect(relay.paired.mock.calls[0][0]).toBe(K.toString("hex")); + expect((mcpConnect as any).handshakeComplete).toBe(true); + // 握手后内部凭据切到会话模式,供后续自动重连 + expect((mcpConnect as any).currentParams.auth).toEqual({ mode: "session", key: K.toString("hex") }); + }); + + it("配对 auth.ok 缺少 key 时断开、不上抛 paired", async () => { + const code = "MNBV-3456"; + const { mac } = nodePairingKeys(code); + const ws = triggerConnect({ url: "ws://127.0.0.1:8643", auth: { mode: "pairing", code } }); + ws.simulateOpen(); + const nonceD = randomBytes(32).toString("hex"); + ws.simulateMessage({ v: 1, type: "auth.challenge", requestId: "c1", payload: { nonceD } }); + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); + const resp = ws.sent(0); + const okHmac = nodeHmacHex(new Uint8Array(mac), CTX.pairDaemon, resp.payload.nonceE, nonceD); + + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ v: 1, type: "auth.ok", requestId: "c1", payload: { hmac: okHmac } }); + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + expect(relay.paired).not.toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────── + describe("握手后信封路由与心跳", () => { + async function completeSessionHandshake(): Promise { + const K = randomBytes(32); + const ws = triggerConnect(sessionParam(K.toString("hex"))); + ws.simulateOpen(); + const nonceD = randomBytes(32).toString("hex"); + ws.simulateMessage({ v: 1, type: "auth.challenge", requestId: "c1", payload: { nonceD } }); + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); + const resp = ws.sent(0); + const okHmac = nodeHmacHex(new Uint8Array(K), CTX.sessionDaemon, resp.payload.nonceE, nonceD); + ws.simulateMessage({ v: 1, type: "auth.ok", requestId: "c1", payload: { hmac: okHmac } }); + await vi.waitFor(() => expect((mcpConnect as any).handshakeComplete).toBe(true)); + return ws; + } + + it("业务信封(bridge.request/pair.request/client.sync)转发给 SW", async () => { + const ws = await completeSessionHandshake(); + ws.simulateMessage({ v: 1, type: "bridge.request", requestId: "r1", payload: { action: "scripts.list" } }); + ws.simulateMessage({ v: 1, type: "pair.request", requestId: "p1", payload: {} }); + ws.simulateMessage({ v: 1, type: "client.sync", requestId: "s1", payload: [] }); + await vi.waitFor(() => expect(relay.envelope).toHaveBeenCalledTimes(3)); + expect(relay.envelope.mock.calls.map((c) => c[0].type)).toEqual([ + "bridge.request", + "pair.request", + "client.sync", + ]); + }); + + it("收到 ping 回复 pong,不转发给 SW", async () => { + const ws = await completeSessionHandshake(); + ws.simulateMessage({ v: 1, type: "ping", requestId: "pg1", payload: {} }); + await vi.waitFor(() => expect(ws.sentMessages.length).toBe(2)); + expect(ws.sent(1)).toEqual({ v: 1, type: "pong", requestId: "pg1", payload: {} }); + expect(relay.envelope).not.toHaveBeenCalled(); + }); + + it("未知类型忽略且不断开、不转发", async () => { + const ws = await completeSessionHandshake(); + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ v: 1, type: "totally.unknown", requestId: "x1", payload: {} }); + await Promise.resolve(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(relay.envelope).not.toHaveBeenCalled(); + }); + + it("SW 下发的出站信封在握手后写入 socket", async () => { + const ws = await completeSessionHandshake(); + (mcpConnect as any).sendEnvelope({ v: 1, type: "bridge.response", requestId: "r1", payload: { ok: true } }); + expect(ws.sent(1).type).toBe("bridge.response"); + }); + + it("握手完成前的出站信封被丢弃", () => { + const ws = triggerConnect(sessionParam(randomBytes(32).toString("hex"))); + ws.simulateOpen(); + (mcpConnect as any).sendEnvelope({ v: 1, type: "bridge.response", requestId: "r1", payload: {} }); + expect(ws.sentMessages.length).toBe(0); + }); + }); + + // ──────────────────────────────────────────────── + describe("连接/握手超时与自动重连", () => { + it("30 秒内未连接成功应关闭 WebSocket", () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam("aa".repeat(32))); + const closeSpy = vi.spyOn(ws, "close"); + vi.advanceTimersByTime(30_000); + expect(closeSpy).toHaveBeenCalled(); + }); + + it("打开后 authTimeoutMs 内未完成握手应关闭 WebSocket", () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam("aa".repeat(32))); + ws.simulateOpen(); + const closeSpy = vi.spyOn(ws, "close"); + vi.advanceTimersByTime(protocol.limits.authTimeoutMs); + expect(closeSpy).toHaveBeenCalled(); + }); + + it("连接关闭后上抛 disconnected 并按退避自动重连", () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam("aa".repeat(32))); + ws.simulateClose(); + expect(relay.disconnected).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1000); + expect(wsInstances).toHaveLength(2); + }); + + it("disconnect 指令后不再重连", () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam("aa".repeat(32))); + (mcpConnect as any).currentParams = null; + (mcpConnect as any).dispose(); + ws.simulateClose(); + vi.advanceTimersByTime(30_000); + expect(wsInstances).toHaveLength(1); + }); + + it("重连延迟指数递增(最大 10 秒)", () => { + vi.useFakeTimers(); + const ws1 = triggerConnect(sessionParam("aa".repeat(32))); + ws1.simulateClose(); + vi.advanceTimersByTime(1000); + expect(wsInstances).toHaveLength(2); + wsInstances[1].simulateClose(); + vi.advanceTimersByTime(1500); + expect(wsInstances).toHaveLength(3); + }); + + it("error + close 不触发双重重连", () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam("aa".repeat(32))); + ws.simulateError(); + ws.simulateClose(); + vi.advanceTimersByTime(1000); + expect(wsInstances).toHaveLength(2); + }); + }); + + // ──────────────────────────────────────────────── + describe("Epoch 机制", () => { + it("新连接使旧连接的消息失效", async () => { + const ws1 = triggerConnect(sessionParam("aa".repeat(32))); + ws1.simulateOpen(); + const ws2 = triggerConnect(sessionParam("bb".repeat(32))); + ws2.simulateOpen(); + + // 旧连接收到 challenge 不应产生响应 + ws1.simulateMessage({ + v: 1, + type: "auth.challenge", + requestId: "c1", + payload: { nonceD: randomBytes(32).toString("hex") }, + }); + await Promise.resolve(); + expect(ws1.sentMessages.length).toBe(0); + }); + + it("新连接关闭旧 WebSocket 并清除其事件监听", () => { + const ws1 = triggerConnect(sessionParam("aa".repeat(32))); + ws1.simulateOpen(); + const closeSpy = vi.spyOn(ws1, "close"); + triggerConnect(sessionParam("bb".repeat(32))); + expect(closeSpy).toHaveBeenCalled(); + expect(ws1.onmessage).toBeNull(); + }); + }); +}); diff --git a/src/app/service/offscreen/mcp-connect.ts b/src/app/service/offscreen/mcp-connect.ts new file mode 100644 index 000000000..26fe71bb9 --- /dev/null +++ b/src/app/service/offscreen/mcp-connect.ts @@ -0,0 +1,429 @@ +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { type Group } from "@Packages/message/server"; +import type { MessageSend } from "@Packages/message/types"; +import { McpConnectRelayClient } from "../service_worker/client"; +import protocol from "../service_worker/mcp/protocol.json"; +import type { + AuthChallengePayload, + AuthMode, + AuthOkPayload, + AuthResponsePayload, + WSEnvelope, +} from "../service_worker/mcp/types"; + +/** + * MCP 桥接 · offscreen WebSocket client + * + * offscreen 拥有到 sctl daemon 的 WS 连接:epoch 守卫的重连 + 指数退避(骨架取自 + * vscode-connect.ts),以及 PROTOCOL §3 的双向 HMAC 握手(WebCrypto)。握手/心跳在本层自持, + * 业务信封经现有 Group 通道转发给 Service Worker 的 McpController。 + * + * 之所以放 offscreen 而非 SW:写操作审批可能挂起数分钟且期间无流量,MV3 会休眠空闲 SW; + * offscreen 由这条连接保活,SW 被转发消息唤醒。 + * + * @see PROTOCOL.md(sctl 仓库)/ protocol.json(本仓库权威常量) + */ + +const CONFIG = { + CONNECT_TIMEOUT: 30_000, + BASE_RECONNECT_DELAY: 1000, + MAX_RECONNECT_DELAY: 10_000, +} as const; + +const CRYPTO = protocol.crypto; +const NONCE_BYTES = CRYPTO.nonceBytes; +const AUTH_TIMEOUT_MS = protocol.limits.authTimeoutMs; + +// 会话模式携带既有长期密钥 K(小写 hex);配对模式携带 sctl 生成的一次性配对码。 +export type McpAuth = { mode: "session"; key: string } | { mode: "pairing"; code: string }; + +export interface McpConnectParam { + url: string; + auth: McpAuth; +} + +// --------------------------------------------------------------------------------------------- +// WebCrypto 原语(导出以便 conformance / 互操作向量测试直接钉住线格式) +// --------------------------------------------------------------------------------------------- + +// WebCrypto 的 BufferSource 要求 ArrayBuffer 后备(而非 ArrayBufferLike),全部原语统一走此别名。 +type Bytes = Uint8Array; + +const textEncoder = new TextEncoder(); + +export function utf8Bytes(s: string): Bytes { + const encoded = textEncoder.encode(s); + const out = new Uint8Array(encoded.length); + out.set(encoded); + return out; +} + +export function bytesToHex(bytes: Uint8Array): string { + let hex = ""; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +export function hexToBytes(hex: string): Bytes { + const out = new Uint8Array(hex.length >> 1); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function base64ToBytes(b64: string): Bytes { + const bin = globalThis.atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export function randomNonceHex(): string { + const bytes = new Uint8Array(NONCE_BYTES); + globalThis.crypto.getRandomValues(bytes); + return bytesToHex(bytes); +} + +// 恒定时间比较两个等长 hex 字符串。 +export function constantTimeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +// 配对码归一化到 Crockford base32 规范形态:去连字符/空白、大写,并按 Crockford 解码规则把 +// 易混字符映射回规范字符(O→0、I/L→1),使用户手输的歧义字符与 daemon 生成的规范码派生出同一 +// 密钥。daemon 侧同款归一化。 +export function normalizePairingCode(code: string): string { + return code + .replace(/[^0-9A-Za-z]/g, "") + .toUpperCase() + .replace(/O/g, "0") + .replace(/[IL]/g, "1"); +} + +// HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。 +export async function computeHandshakeHmac( + keyBytes: Bytes, + context: string, + firstHex: string, + secondHex: string +): Promise { + const key = await globalThis.crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, [ + "sign", + ]); + const sig = await globalThis.crypto.subtle.sign("HMAC", key, utf8Bytes(context + firstHex + secondHex)); + return bytesToHex(new Uint8Array(sig)); +} + +// 从配对码 HKDF-SHA-256 派生 MAC 与 AEAD 两把 256-bit 临时密钥。 +export async function derivePairingKeys(code: string): Promise<{ mac: Bytes; enc: Bytes }> { + const ikm = await globalThis.crypto.subtle.importKey("raw", utf8Bytes(normalizePairingCode(code)), "HKDF", false, [ + "deriveBits", + ]); + const salt = utf8Bytes(CRYPTO.context.pairKdfSalt); + const derive = async (info: string): Promise => + new Uint8Array( + await globalThis.crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt, info: utf8Bytes(info) }, + ikm, + 256 + ) + ); + return { mac: await derive(CRYPTO.context.pairKdfInfoMac), enc: await derive(CRYPTO.context.pairKdfInfoEnc) }; +} + +// 用 Kp_enc 解密 auth.ok 下发的长期密钥 K(AES-256-GCM,ct||tag 拼接)→ 小写 hex。 +export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciphertextB64: string): Promise { + const key = await globalThis.crypto.subtle.importKey("raw", encKeyBytes, { name: "AES-GCM" }, false, ["decrypt"]); + const plain = await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(ivB64) }, + key, + base64ToBytes(ciphertextB64) + ); + return bytesToHex(new Uint8Array(plain)); +} + +// 收到 auth.challenge 后,按当前模式计算 auth.response 载荷,并把握手参数带出以便验证 auth.ok。 +async function buildAuthResponse( + auth: McpAuth, + nonceD: string +): Promise<{ payload: AuthResponsePayload; verifyKey: Bytes; nonceE: string; enc?: Bytes }> { + const nonceE = randomNonceHex(); + const mode: AuthMode = auth.mode; + if (auth.mode === "session") { + const keyBytes = hexToBytes(auth.key); + const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.sessionExt, nonceD, nonceE); + return { payload: { mode, nonceE, hmac }, verifyKey: keyBytes, nonceE }; + } + const { mac, enc } = await derivePairingKeys(auth.code); + const hmac = await computeHandshakeHmac(mac, CRYPTO.context.pairExt, nonceD, nonceE); + return { payload: { mode, nonceE, hmac }, verifyKey: mac, nonceE, enc }; +} + +export class McpConnect { + private readonly logger = LoggerCore.logger().with({ service: "McpConnect" }); + private readonly relay: McpConnectRelayClient; + + private ws: WebSocket | null = null; + private epoch = 0; + private currentParams: McpConnectParam | null = null; + private handshakeComplete = false; + // 已发出 auth.response、等待 auth.ok 校验期间保留的握手素材。 + private pendingVerify: { verifyKey: Bytes; nonceD: string; nonceE: string; mode: AuthMode; enc?: Bytes } | null = + null; + + private reconnectDelay: number = CONFIG.BASE_RECONNECT_DELAY; + private reconnectTimer: ReturnType | null = null; + private connectTimeoutTimer: ReturnType | null = null; + private authTimeoutTimer: ReturnType | null = null; + + constructor( + private readonly messageGroup: Group, + messageSender: MessageSend + ) { + this.relay = new McpConnectRelayClient(messageSender); + } + + init(): void { + this.messageGroup.on("connect", (params: McpConnectParam) => { + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + this.startSession(params); + }); + this.messageGroup.on("disconnect", () => { + this.currentParams = null; + this.dispose(); + }); + this.messageGroup.on("send", (envelope: WSEnvelope) => { + this.sendEnvelope(envelope); + }); + } + + private startSession(params: McpConnectParam): void { + this.dispose(); + this.currentParams = params; + this.epoch++; + this.connect(this.epoch); + } + + private connect(sessionEpoch: number): void { + const url = this.currentParams?.url; + if (!url) return; + + try { + this.logger.debug(`Attempting connection (Epoch: ${sessionEpoch})`, { url }); + this.ws = new WebSocket(url); + this.connectTimeoutTimer = setTimeout(() => { + if (sessionEpoch === this.epoch) { + this.logger.warn("Connection timeout"); + this.ws?.close(); + } + }, CONFIG.CONNECT_TIMEOUT); + + this.ws.onopen = () => this.handleOpen(sessionEpoch); + this.ws.onmessage = (ev) => void this.handleMessage(ev, sessionEpoch); + this.ws.onclose = () => this.handleClose(sessionEpoch); + this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch); + } catch (e) { + this.logger.error("WebSocket creation failed", Logger.E(e)); + this.handleError(e, sessionEpoch); + } + } + + private handleOpen(sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; + this.logger.info("WebSocket connected, awaiting auth.challenge"); + this.clearTimer("connectTimeoutTimer"); + // 握手必须在 authTimeoutMs 内完成,否则断开重连(PROTOCOL §3)。 + this.authTimeoutTimer = setTimeout(() => { + if (sessionEpoch === this.epoch && !this.handshakeComplete) { + this.logger.warn("Auth handshake timeout"); + this.ws?.close(); + } + }, AUTH_TIMEOUT_MS); + // daemon 先发 auth.challenge,扩展在此之前不主动发消息。 + } + + private async handleMessage(ev: MessageEvent, sessionEpoch: number): Promise { + if (sessionEpoch !== this.epoch) return; + + let envelope: WSEnvelope; + try { + envelope = JSON.parse(ev.data as string) as WSEnvelope; + } catch (e) { + this.logger.warn("Failed to parse message", Logger.E(e)); + return; + } + + if (envelope.v !== 1) { + this.logger.warn("Unsupported protocol version, closing", { v: envelope.v }); + this.ws?.close(); + return; + } + + if (!this.handshakeComplete) { + await this.handleHandshakeMessage(envelope, sessionEpoch); + return; + } + + switch (envelope.type) { + case "ping": + this.rawSend({ v: 1, type: "pong", requestId: envelope.requestId, payload: {} }); + break; + case "pong": + break; + case "hello": + case "bridge.request": + case "bridge.cancel": + case "pair.request": + case "client.sync": + case "bridge.shutdown": + void this.relay.envelope(envelope); + break; + default: + // 未知类型:忽略并记日志(前向兼容,PROTOCOL §2)。 + this.logger.warn("Unknown envelope type", { type: envelope.type }); + } + } + + // 握手期只接受 auth.challenge / auth.ok;其余任何类型立即断开(PROTOCOL §3)。 + private async handleHandshakeMessage(envelope: WSEnvelope, sessionEpoch: number): Promise { + const auth = this.currentParams?.auth; + if (!auth) return; + + if (envelope.type === "auth.challenge") { + const { nonceD } = envelope.payload as AuthChallengePayload; + const built = await buildAuthResponse(auth, nonceD); + if (sessionEpoch !== this.epoch) return; + // 保存验证素材,供随后的 auth.ok 校验使用。 + this.pendingVerify = { + verifyKey: built.verifyKey, + nonceD, + nonceE: built.nonceE, + mode: auth.mode, + enc: built.enc, + }; + this.rawSend({ v: 1, type: "auth.response", requestId: envelope.requestId, payload: built.payload }); + return; + } + + if (envelope.type === "auth.ok") { + await this.verifyAuthOk(envelope.payload as AuthOkPayload, sessionEpoch); + return; + } + + this.logger.warn("Unexpected message before handshake, closing", { type: envelope.type }); + this.ws?.close(); + } + + private async verifyAuthOk(payload: AuthOkPayload, sessionEpoch: number): Promise { + const pending = this.pendingVerify; + if (!pending) { + this.ws?.close(); + return; + } + const context = pending.mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon; + const expected = await computeHandshakeHmac(pending.verifyKey, context, pending.nonceE, pending.nonceD); + if (sessionEpoch !== this.epoch) return; + if (!constantTimeEqualHex(expected, payload.hmac || "")) { + this.logger.warn("auth.ok HMAC mismatch, closing"); + this.ws?.close(); + return; + } + + // 配对模式:解密 daemon 下发的长期密钥 K,持久化交给 SW,并把自身切到会话模式供后续重连。 + if (pending.mode === "pairing") { + if (!pending.enc || !payload.key) { + this.logger.warn("pairing auth.ok missing key, closing"); + this.ws?.close(); + return; + } + const key = await decryptLongTermKey(pending.enc, payload.key.iv, payload.key.ciphertext); + if (sessionEpoch !== this.epoch) return; + if (this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } }; + void this.relay.paired(key); + } + + this.pendingVerify = null; + this.handshakeComplete = true; + this.clearTimer("authTimeoutTimer"); + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + this.logger.info("Auth handshake complete"); + } + + // 供 SW 通过 Group 下发的出站信封(bridge.response / pair.decision / client.revoke / + // bridge.shutdown);握手完成前直接丢弃(连接尚不可用于业务消息)。 + private sendEnvelope(envelope: WSEnvelope): void { + if (!this.handshakeComplete) { + this.logger.warn("Dropped outbound envelope before handshake", { type: envelope.type }); + return; + } + this.rawSend(envelope); + } + + private rawSend(envelope: WSEnvelope): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(envelope)); + } + } + + private handleClose(sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; + this.clearTimer("connectTimeoutTimer"); + this.clearTimer("authTimeoutTimer"); + this.ws = null; + this.handshakeComplete = false; + this.pendingVerify = null; + this.logger.debug("WebSocket connection closed"); + void this.relay.disconnected(); + this.scheduleReconnect(); + } + + private handleError(ev: Event | Error | unknown, sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; + this.logger.error("WebSocket error", { + event: ev instanceof Event ? ev.type : undefined, + error: ev instanceof Error ? ev.message : String(ev), + }); + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (!this.currentParams || this.reconnectTimer) return; + const sessionEpoch = this.epoch; + this.logger.debug(`Scheduling reconnect in ${this.reconnectDelay}ms`); + this.reconnectTimer = setTimeout(() => { + if (sessionEpoch !== this.epoch) return; + this.reconnectTimer = null; + this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, CONFIG.MAX_RECONNECT_DELAY); + this.connect(sessionEpoch); + }, this.reconnectDelay); + } + + private clearTimer(name: "reconnectTimer" | "connectTimeoutTimer" | "authTimeoutTimer"): void { + const timer = this[name]; + if (timer) { + clearTimeout(timer); + this[name] = null; + } + } + + private dispose(): void { + this.clearTimer("reconnectTimer"); + this.clearTimer("connectTimeoutTimer"); + this.clearTimer("authTimeoutTimer"); + this.handshakeComplete = false; + this.pendingVerify = null; + if (this.ws) { + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onmessage = null; + this.ws.onerror = null; + this.ws.close(); + this.ws = null; + } + } +} diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index ac046020d..3e8cec3a3 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -27,6 +27,8 @@ import type { TrashScript } from "@App/app/repo/trash_script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; import type { LocalBackupExport } from "./synchronize"; +import type { McpUIService } from "./mcp/service"; +import type { McpScope, WSEnvelope } from "./mcp/types"; export class ServiceWorkerClient extends Client { constructor(msgSender: MessageSend) { @@ -502,3 +504,99 @@ export class AgentClient extends Client { return this.doThrow("mcpApi", request); } } + +// Page-facing client for the MCP native-messaging bridge (ScriptCat as an MCP *server* exposed +// to external AI clients) — unrelated to AgentClient.mcpApi above, which is the opposite +// direction (ScriptCat's own agent acting as an MCP *client* of external servers). +export class MCPClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/mcp"); + } + + getBridgeStatus(): Promise> { + return this.doThrow("status"); + } + + setWriteSession(active: boolean) { + return this.do("setWriteSession", active); + } + + getClients(): ReturnType { + return this.doThrow("clients"); + } + + revokeClient(clientId: string) { + return this.do("revokeClient", clientId); + } + + revokeAllAndStop() { + return this.do("revokeAllAndStop"); + } + + getOperation(operationId: string): ReturnType { + return this.doThrow("operation", operationId); + } + + decideOperation(param: { + operationId: string; + approved: boolean; + enable?: boolean; + rememberChoice?: "once" | "client"; + }): ReturnType { + return this.doThrow("operationDecision", param); + } + + // Re-opens a still-pending op's confirm page (误关重开入口). The popup/settings "待确认" row calls + // this after the user closed the confirm tab without deciding. + reopenOperation(operationId: string): ReturnType { + return this.doThrow("operationReopen", operationId); + } + + // Still-pending ops for the popup/settings "待确认" list. + getPendingOperations(): ReturnType { + return this.doThrow("pendingOperations"); + } + + getAudit(): ReturnType { + return this.doThrow("audit"); + } + + clearAudit() { + return this.do("auditClear"); + } + + getPendingPairing(): Promise> { + return this.doThrow("pendingPairing"); + } + + decidePairing(param: { pairingId: string; approved: boolean; grantedScopes: McpScope[] }) { + return this.do("pairingDecision", param); + } + + // Ext↔daemon pairing with the one-time code from `sctl pair` (user pastes it into settings). + pair(code: string) { + return this.do("pair", code); + } +} + +// offscreen → SW relay for the MCP WS transport. The offscreen McpConnect owns the socket and the +// auth handshake; once a connection is live it forwards decoded business envelopes (and the newly +// paired long-term key) up to McpController here. Deliberately fire-and-forget: a blocking write +// approval may keep a bridge.request pending for minutes, so the relay never awaits the dispatch. +export class McpConnectRelayClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/mcpConnect"); + } + + envelope(envelope: WSEnvelope): Promise { + return this.do("envelope", envelope); + } + + paired(key: string): Promise { + return this.do("paired", { key }); + } + + disconnected(): Promise { + return this.do("disconnected"); + } +} diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 5d805c3f0..a781e3166 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -15,7 +15,7 @@ import { ScriptDAO } from "@App/app/repo/scripts"; import { SystemService } from "./system"; import { type Logger, LoggerDAO } from "@App/app/repo/logger"; import { initLocales, initLocalesPromise, localePath, t, watchLanguageChange } from "@App/locales/locales"; -import { getCurrentTab } from "@App/pkg/utils/utils"; +import { getCurrentTab, isFirefox } from "@App/pkg/utils/utils"; import { onTabRemoved, onUrlNavigated, setOnUserActionDomainChanged } from "./url_monitor"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { FaviconDAO } from "@App/app/repo/favicon"; @@ -26,6 +26,28 @@ import { extensionEnv, getExtensionUserAgentData } from "../extension/extension_ import { cleanupStaleTempStorageEntries } from "./temp"; import RuntimeLogger from "@App/app/logger/logger"; import LoggerCore from "@App/app/logger/core"; +import { McpClientDAO } from "@App/app/repo/mcp"; +import { McpApprovalService } from "@App/app/service/service_worker/mcp/approval"; +import { McpBridge, type McpWriteNotice } from "@App/app/service/service_worker/mcp/bridge"; +import { McpController } from "@App/app/service/service_worker/mcp/controller"; +import { McpUIService } from "@App/app/service/service_worker/mcp/service"; +import { McpConnectClient } from "@App/app/service/offscreen/client"; + +// "直接允许" 写策略下 MCP 无需人工确认即执行了写操作,发系统通知让用户知晓(决策 #12 的知情兜底)。 +function notifyMcpWrite(notice: McpWriteNotice): void { + const name = notice.name ?? ""; + const body = + notice.kind === "install" + ? t("mcp:allow_notify_install", { name }) + : notice.kind === "enable" + ? t("mcp:allow_notify_enable", { name }) + : notice.kind === "disable" + ? t("mcp:allow_notify_disable", { name }) + : notice.kind === "delete" + ? t("mcp:allow_notify_delete", { name }) + : t("mcp:allow_notify_generic", { name }); + void InfoNotification(t("mcp:allow_notify_title"), body); +} // service worker的管理器 export default class ServiceWorkerManager { @@ -80,6 +102,7 @@ export default class ServiceWorkerManager { const value = new ValueService(this.api.group("value"), this.mq); const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource, scriptDAO); script.init(); + const runtime = new RuntimeService( systemConfig, this.api.group("runtime"), @@ -128,6 +151,39 @@ export default class ServiceWorkerManager { gmApi.setAgentService(agent); } + // MCP 桥接:运行期开关 mcp_enabled(由 McpController.initialize 内部监听),默认关闭, + // 用户在设置里显式开启前不建立连接。Firefox 的 MV3 事件页生命周期未经验证/支持,显式排除。 + if (!isFirefox()) { + const mcpClientDAO = new McpClientDAO(); + const mcpApproval = new McpApprovalService(script, scriptDAO, script.scriptCodeDAO, mcpClientDAO); + const mcpBridge = new McpBridge( + scriptDAO, + script.scriptCodeDAO, + mcpClientDAO, + mcpApproval, + undefined, + undefined, + () => systemConfig.getMcpWritePolicy(), + notifyMcpWrite + ); + const mcpController = new McpController( + systemConfig, + mcpBridge, + this.mq, + this.api.group("mcpConnect"), + new McpConnectClient(this.offscreenSend), + mcpClientDAO + ); + mcpBridge.setWriteSessionChecker(() => mcpController.isWriteSessionActive()); + // Deferred bridge.response for blocking ops (write approval / source disclosure): the decide + // or bridge.cancel event resolves the persisted op and pushes the response back through the + // controller's offscreen relay — never a Promise left hanging in the (suspendable) SW. + mcpApproval.setResponder((requestId, response) => mcpController.sendBridgeResponse(requestId, response)); + mcpController.initialize(); + const mcpUIService = new McpUIService(this.api.group("mcp"), mcpController, mcpApproval, mcpClientDAO); + mcpUIService.init(); + } + const regularScriptUpdateCheck = async () => { const res = await onRegularUpdateCheckAlarm(systemConfig, script, subscribe); if (!res?.ok) return; diff --git a/src/app/service/service_worker/mcp/approval.test.ts b/src/app/service/service_worker/mcp/approval.test.ts new file mode 100644 index 000000000..6de24e3e1 --- /dev/null +++ b/src/app/service/service_worker/mcp/approval.test.ts @@ -0,0 +1,779 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { McpApprovalService, INLINE_CODE_MAX_BYTES, type McpScriptMutator, type SendBridgeResponse } from "./approval"; +import { McpBridgeError } from "./errors"; +import { McpClientDAO, McpOperationDAO, type McpClient } from "@App/app/repo/mcp"; +import { + ScriptDAO, + ScriptCodeDAO, + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; +import { TempStorageDAO } from "@App/app/repo/tempStorage"; +import { createMockOPFS } from "@App/app/repo/test-helpers"; +import { sha256OfText } from "@App/pkg/utils/crypto"; +import * as utilsModule from "@App/pkg/utils/utils"; + +function validCode(name: string) { + return `// ==UserScript== +// @name ${name} +// @namespace test-ns +// @version 1.0.0 +// ==/UserScript== +console.log("hi");`; +} + +// Mirrors url_policy.test.ts's helper — a minimal fetch Response stand-in with a single-chunk +// streaming body, enough for fetchInstallSourceWithPolicy's reader loop. +function makeResponse(opts: { url: string; status?: number; body: string; contentLength?: string }) { + const bytes = new TextEncoder().encode(opts.body); + return { + url: opts.url, + status: opts.status ?? 200, + headers: new Headers(opts.contentLength !== undefined ? { "content-length": opts.contentLength } : {}), + body: { + getReader() { + let sent = false; + return { + read: async () => { + if (sent) return { done: true, value: undefined }; + sent = true; + return { done: false, value: bytes }; + }, + cancel: async () => {}, + }; + }, + }, + text: async () => opts.body, + }; +} + +function makeClient(overrides: Partial = {}): McpClient { + return { + clientId: "client-1", + displayName: "Test Client", + tokenHash: "hash", + scopes: ["scripts:install:request"], + createdAt: Date.now(), + lastUsedAt: Date.now(), + revoked: false, + ...overrides, + }; +} + +describe("McpApprovalService", () => { + let mutator: McpScriptMutator & { + installScript: ReturnType; + enableScript: ReturnType; + deleteScript: ReturnType; + }; + let clientDAO: McpClientDAO; + let operationDAO: McpOperationDAO; + let scriptDAO: ScriptDAO; + let scriptCodeDAO: ScriptCodeDAO; + let tempStorageDAO: TempStorageDAO; + let responder: ReturnType; + let service: McpApprovalService; + + beforeEach(async () => { + createMockOPFS(); + chrome.storage.local.clear(); + vi.spyOn(utilsModule, "openInCurrentTab").mockResolvedValue(undefined); + vi.stubGlobal("fetch", vi.fn()); + + mutator = { + installScript: vi.fn().mockResolvedValue({ update: false, updatetime: Date.now() }), + enableScript: vi.fn().mockResolvedValue(undefined), + deleteScript: vi.fn().mockResolvedValue(undefined), + }; + clientDAO = new McpClientDAO(); + operationDAO = new McpOperationDAO(); + scriptDAO = new ScriptDAO(); + scriptCodeDAO = new ScriptCodeDAO(); + tempStorageDAO = new TempStorageDAO(); + + await clientDAO.save(makeClient()); + + responder = vi.fn(); + service = new McpApprovalService(mutator, scriptDAO, scriptCodeDAO, clientDAO, operationDAO, tempStorageDAO); + service.setResponder(responder as unknown as SendBridgeResponse); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("prepareInstall - 暂存但不安装、不弹窗", () => { + it("暂存代码、计算哈希、创建 awaiting_user 操作,且不调用 installScript,也不自行弹确认页", async () => { + const code = validCode("Hello"); + const ref = await service.prepareInstall({ clientId: "client-1", requestingClientName: "Test Client", code }); + + expect(ref.status).toBe("awaiting_user"); + expect(ref.kind).toBe("install"); + expect(mutator.installScript).not.toHaveBeenCalled(); + // 弹窗与创建解耦:由 present() 负责,prepareInstall 本身不打开确认页。 + expect(vi.mocked(utilsModule.openInCurrentTab)).not.toHaveBeenCalled(); + + const op = await operationDAO.get(ref.operationId); + expect(op?.contentHash).toBe(sha256OfText(code)); + expect(op?.requestedEnabledState).toBe(false); + }); + + it("传入 requestId 时记录到操作上(阻塞响应寻址用)", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("Req"), + requestId: "req-abc", + }); + const op = await operationDAO.get(ref.operationId); + expect(op?.requestId).toBe("req-abc"); + }); + + it("url 与 code 同时提供或都不提供时拒绝", async () => { + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", url: "https://a", code: "x" }) + ).rejects.toThrow(McpBridgeError); + await expect(service.prepareInstall({ clientId: "client-1", requestingClientName: "c" })).rejects.toThrow( + McpBridgeError + ); + }); + + it("内联 code 超过 512 KiB 时在暂存前拒绝", async () => { + const big = "x".repeat(INLINE_CODE_MAX_BYTES + 1); + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code: big }) + ).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("URL 违反策略(私网/本地)时在抓取前拒绝", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", url: "https://127.0.0.1/x.user.js" }) + ).rejects.toThrow(McpBridgeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("URL 通过初步校验并成功抓取后正常暂存,sourceUrl 记录为该 URL", async () => { + const code = validCode("FromUrl"); + const fetchMock = vi.fn().mockResolvedValue(makeResponse({ url: "https://example.com/x.user.js", body: code })); + vi.stubGlobal("fetch", fetchMock); + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + url: "https://example.com/x.user.js", + }); + expect(ref.status).toBe("awaiting_user"); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("抓取阶段违反 URL 策略(如重定向到私网主机)时返回 INVALID_REQUEST,而非未处理异常", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeResponse({ url: "https://127.0.0.1/redirected.user.js", body: "malicious" })); + vi.stubGlobal("fetch", fetchMock); + await expect( + service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + url: "https://example.com/x.user.js", + }) + ).rejects.toMatchObject({ code: "INVALID_REQUEST" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("抓取阶段下载超过 2 MiB 时返回 PAYLOAD_TOO_LARGE", async () => { + const big = "x".repeat(3 * 1024 * 1024); + const fetchMock = vi + .fn() + .mockResolvedValue( + makeResponse({ url: "https://example.com/x.user.js", body: big, contentLength: String(big.length) }) + ); + vi.stubGlobal("fetch", fetchMock); + await expect( + service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + url: "https://example.com/x.user.js", + }) + ).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" }); + }); + + it("抓取阶段发生非 URL 策略类错误(如网络失败)时原样向上抛出,而非误包装为 URL 策略拒绝", async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError("network failure")); + vi.stubGlobal("fetch", fetchMock); + await expect( + service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + url: "https://example.com/x.user.js", + }) + ).rejects.toThrow("network failure"); + }); + + it("重复请求(同 clientId + contentHash)返回同一个 operationId,不重复建操作", async () => { + const code = validCode("Dup"); + const ref1 = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + const ref2 = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + expect(ref2.operationId).toBe(ref1.operationId); + }); + }); + + describe("decide - 批准前不产生任何写入", () => { + it("拒绝(approved=false)不调用 installScript,状态变为 rejected", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("R"), + }); + const result = await service.decide(ref.operationId, false); + expect(result.status).toBe("rejected"); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("批准后调用 installScript,且新脚本默认禁用", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("E"), + }); + const result = await service.decide(ref.operationId, true); + expect(result.status).toBe("approved"); + expect(mutator.installScript).toHaveBeenCalledTimes(1); + const installedScript = mutator.installScript.mock.calls[0][0].script; + expect(installedScript.status).toBe(SCRIPT_STATUS_DISABLE); + }); + + it("批准时显式 enable=true 才会启用新脚本", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("F"), + }); + await service.decide(ref.operationId, true, { enable: true }); + const installedScript = mutator.installScript.mock.calls[0][0].script; + expect(installedScript.status).toBe(SCRIPT_STATUS_ENABLE); + }); + + it("已过期的操作不能被批准(OPERATION_EXPIRED),且不调用 installScript", async () => { + vi.useFakeTimers(); + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("G"), + }); + vi.advanceTimersByTime(6 * 60_000); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "OPERATION_EXPIRED" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("已拒绝的操作不能被重放批准", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("H"), + }); + await service.decide(ref.operationId, false); + await expect(service.decide(ref.operationId, true)).rejects.toThrow(McpBridgeError); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("暂存代码在批准前被篡改时返回 CONFLICT,不安装", async () => { + const code = validCode("Tamper"); + const ref = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + const op = await operationDAO.get(ref.operationId); + const entry = await tempStorageDAO.get(op!.stagedUuid!); + expect(entry).toBeDefined(); + // Corrupt the recorded contentHash to simulate staged-code drift without touching OPFS directly. + await operationDAO.update(ref.operationId, { contentHash: "tampered-hash" }); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("客户端已被撤销时拒绝批准", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("I"), + }); + await clientDAO.save(makeClient({ revoked: true })); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "UNAUTHENTICATED" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + }); + + describe("decide - 阻塞响应回发(事件驱动,非 SW 悬挂 Promise)", () => { + it("带 requestId 的操作批准后经 responder 回发 ok:true 的 bridge.response", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("Wire"), + requestId: "req-wire", + }); + await service.decide(ref.operationId, true); + expect(responder).toHaveBeenCalledTimes(1); + expect(responder).toHaveBeenCalledWith("req-wire", expect.objectContaining({ requestId: "req-wire", ok: true })); + }); + + it("带 requestId 的操作被拒绝后经 responder 回发 USER_REJECTED", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("WireRej"), + requestId: "req-rej", + }); + await service.decide(ref.operationId, false); + expect(responder).toHaveBeenCalledWith( + "req-rej", + expect.objectContaining({ ok: false, error: expect.objectContaining({ code: "USER_REJECTED" }) }) + ); + }); + + it("不带 requestId 的操作(直接允许立即执行)批准时不经 responder 回发(同步返回结果)", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("NoWire"), + }); + const result = await service.decide(ref.operationId, true); + expect(result.status).toBe("approved"); + expect(responder).not.toHaveBeenCalled(); + }); + + it("批准执行失败(CONFLICT)时也经 responder 回发错误响应", async () => { + const code = validCode("WireFail"); + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code, + requestId: "req-fail", + }); + await operationDAO.update(ref.operationId, { contentHash: "tampered" }); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(responder).toHaveBeenCalledWith( + "req-fail", + expect.objectContaining({ ok: false, error: expect.objectContaining({ code: "CONFLICT" }) }) + ); + }); + }); + + describe("requestToggle / requestDelete - 现有脚本的写请求", () => { + async function seedScript(uuid: string, code: string) { + await scriptDAO.save({ + uuid, + name: "Existing", + author: "", + namespace: "ns", + originDomain: "", + origin: "", + checkUpdate: true, + checkUpdateUrl: "", + downloadUrl: "", + config: undefined, + metadata: { name: ["Existing"], namespace: ["ns"], version: ["1.0.0"] } as any, + selfMetadata: {}, + sort: -1, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_DISABLE, + runStatus: "complete", + createtime: Date.now(), + updatetime: Date.now(), + checktime: Date.now(), + } as any); + await scriptCodeDAO.save({ uuid, code }); + } + + it("requestToggle 记录 existingCodeHash 并创建 awaiting_user 操作", async () => { + await seedScript("script-1", "console.log(1)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-1", enable: true }); + expect(ref.status).toBe("awaiting_user"); + const op = await operationDAO.get(ref.operationId); + expect(op?.existingCodeHash).toBe(sha256OfText("console.log(1)")); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("批准 toggle 后调用 enableScript", async () => { + await seedScript("script-2", "console.log(2)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-2", enable: true }); + await service.decide(ref.operationId, true); + expect(mutator.enableScript).toHaveBeenCalledWith({ uuid: "script-2", enable: true }); + }); + + it("目标脚本代码在请求后被修改时批准返回 CONFLICT", async () => { + await seedScript("script-3", "console.log(3)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-3", enable: false }); + await scriptCodeDAO.save({ uuid: "script-3", code: "console.log('changed')" }); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("对不存在的脚本请求 toggle 返回 NOT_FOUND", async () => { + await expect( + service.requestToggle({ clientId: "client-1", uuid: "missing", enable: true }) + ).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("目标脚本在请求后被整个删除时批准返回 CONFLICT(而非把 undefined 当作哈希匹配)", async () => { + await seedScript("script-3b", "console.log(1)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-3b", enable: false }); + await scriptDAO.delete("script-3b"); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("批准 delete 后调用 deleteScript", async () => { + await seedScript("script-4", "console.log(4)"); + const ref = await service.requestDelete({ clientId: "client-1", uuid: "script-4" }); + await service.decide(ref.operationId, true); + expect(mutator.deleteScript).toHaveBeenCalledWith("script-4", "mcp"); + }); + }); + + describe("决策与 UI 读取 - 不存在的 operationId 处理", () => { + it("decide 对不存在的 operationId 返回 NOT_FOUND", async () => { + await expect(service.decide("nonexistent-op", true)).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("getOperationForUI 对不存在的 operationId 返回 undefined", async () => { + await expect(service.getOperationForUI("nonexistent-op")).resolves.toBeUndefined(); + }); + + it("暂存条目在批准前过期/丢失(TempStorageDAO 已清除)时批准返回 CONFLICT", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("N"), + }); + await tempStorageDAO.delete((await operationDAO.get(ref.operationId))!.stagedUuid!); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("批准一个类型不受支持的操作(如遗留/未实现的 kind)返回 INTERNAL_ERROR,而非静默成功", async () => { + const client = makeClient(); + await clientDAO.save(client); + await operationDAO.save({ + operationId: "fixture-op", + clientId: client.clientId, + kind: "update", + status: "awaiting_user", + createdAt: Date.now(), + expiresAt: Date.now() + 5 * 60_000, + requestedEnabledState: false, + } as any); + await expect(service.decide("fixture-op", true)).rejects.toMatchObject({ code: "INTERNAL_ERROR" }); + }); + }); + + describe("present / reopen - 确认页展示与误关重开", () => { + it("present 打开对应确认页;install 走 install.html,其余走 mcp_confirm.html", async () => { + const install = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("P"), + }); + await service.present(install.operationId); + expect(vi.mocked(utilsModule.openInCurrentTab)).toHaveBeenCalledTimes(1); + expect(vi.mocked(utilsModule.openInCurrentTab).mock.calls[0][0]).toContain("/src/install.html?uuid="); + }); + + it("串行展示:已有确认页在等待决策时,第二个操作的 present 不再打开新确认页(排队)", async () => { + const a = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("A"), + requestId: "ra", + }); + const b = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("B"), + requestId: "rb", + }); + await service.present(a.operationId); + await service.present(b.operationId); + expect(vi.mocked(utilsModule.openInCurrentTab)).toHaveBeenCalledTimes(1); + }); + + it("当前确认页决出后,presentNext 自动弹出下一个排队操作", async () => { + const a = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("A2"), + requestId: "ra2", + }); + const b = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("B2"), + requestId: "rb2", + }); + await service.present(a.operationId); + await service.present(b.operationId); // 排队 + await service.decide(a.operationId, true); // a 决出 → 自动弹 b + expect(vi.mocked(utilsModule.openInCurrentTab)).toHaveBeenCalledTimes(2); + }); + + it("reopen 对仍待决的操作重新打开确认页(误关 ≠ 拒绝)", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("Re"), + }); + await service.reopen(ref.operationId); + expect(vi.mocked(utilsModule.openInCurrentTab)).toHaveBeenCalledTimes(1); + }); + + it("reopen 对已决出/过期的操作抛出 OPERATION_EXPIRED", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("Re2"), + }); + await service.decide(ref.operationId, false); + await expect(service.reopen(ref.operationId)).rejects.toMatchObject({ code: "OPERATION_EXPIRED" }); + }); + + it("listPending 返回全部待决操作(含请求方名),已决出的不在其中,按创建时间排序", async () => { + const a = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("LP1"), + }); + const b = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("LP2"), + }); + await service.decide(a.operationId, false); // a 决出 → 不再 pending + + const pending = await service.listPending(); + expect(pending.map((p) => p.operationId)).toEqual([b.operationId]); + expect(pending[0]).toMatchObject({ kind: "install", requestingClientName: "Test Client" }); + }); + + it("listPending 过滤掉 TTL 已过期的操作", async () => { + vi.useFakeTimers(); + await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code: validCode("LPExp") }); + vi.advanceTimersByTime(6 * 60_000); + expect(await service.listPending()).toHaveLength(0); + }); + }); + + describe("cancelByRequestId - 断开即作废与决策仲裁(先到先得)", () => { + it("作废仍待决的操作:状态转为 cancelled,且不回发任何 bridge.response", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("Cancel"), + requestId: "req-cancel", + }); + await service.cancelByRequestId("req-cancel"); + const op = await operationDAO.get(ref.operationId); + expect(op?.status).toBe("cancelled"); + expect(responder).not.toHaveBeenCalled(); + }); + + it("作废未知 requestId 时静默无操作", async () => { + await expect(service.cancelByRequestId("no-such-req")).resolves.toBeUndefined(); + }); + + it("cancel 先到 → 随后的 decide 抛 OPERATION_EXPIRED,不执行安装、不回发批准响应", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("CancelFirst"), + requestId: "req-cf", + }); + await service.cancelByRequestId("req-cf"); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "OPERATION_EXPIRED" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + expect(responder).not.toHaveBeenCalled(); + }); + + it("decide 先到 → 随后的 cancel 是 no-op,不回滚已批准状态", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("DecideFirst"), + requestId: "req-df", + }); + await service.decide(ref.operationId, true); + await service.cancelByRequestId("req-df"); + const op = await operationDAO.get(ref.operationId); + expect(op?.status).toBe("approved"); + expect(mutator.installScript).toHaveBeenCalledTimes(1); + expect(responder).toHaveBeenCalledTimes(1); + expect(responder).toHaveBeenCalledWith("req-df", expect.objectContaining({ ok: true })); + }); + }); + + describe("checkSourceDisclosure - 首次读取源码阻塞、断开即作废", () => { + async function seedScript(uuid: string, code = "console.log('secret')") { + await scriptDAO.save({ + uuid, + name: "Disclosure Target", + author: "", + namespace: "ns", + originDomain: "", + origin: "", + checkUpdate: true, + checkUpdateUrl: "", + downloadUrl: "", + config: undefined, + metadata: { name: ["Disclosure Target"], namespace: ["ns"], version: ["1.0.0"] } as any, + selfMetadata: {}, + sort: -1, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + runStatus: "complete", + createtime: Date.now(), + updatetime: Date.now(), + checktime: Date.now(), + } as any); + await scriptCodeDAO.save({ uuid, code }); + } + + it("对不存在的脚本返回 NOT_FOUND,不创建任何待批操作", async () => { + await expect( + service.checkSourceDisclosure({ clientId: "client-1", uuid: "missing", requestingClientName: "c" }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(await operationDAO.byClient("client-1")).toHaveLength(0); + }); + + it("首次读取创建 awaiting_user 的 source_disclosure 待批操作,而非直接放行", async () => { + await seedScript("script-x"); + const result = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-x", + requestingClientName: "c", + }); + expect(result).not.toBe("allowed"); + if (result !== "allowed") { + expect(result.status).toBe("awaiting_user"); + expect(result.kind).toBe("source_disclosure"); + } + }); + + it("同一 (client, uuid) 重复请求且仍 awaiting_user 时返回同一个 operationId", async () => { + await seedScript("script-y"); + const first = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-y", + requestingClientName: "c", + }); + const second = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-y", + requestingClientName: "c", + }); + expect(first === "allowed" ? undefined : first.operationId).toBe( + second === "allowed" ? undefined : second.operationId + ); + }); + + it("批准披露(阻塞语义)即经 responder 回发脚本源码,无需二次调用", async () => { + await seedScript("script-src", "console.log('the-secret-source')"); + const pending = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-src", + requestingClientName: "c", + requestId: "req-src", + }); + const operationId = pending === "allowed" ? "" : pending.operationId; + await service.decide(operationId, true, { rememberChoice: "once" }); + expect(responder).toHaveBeenCalledWith( + "req-src", + expect.objectContaining({ + ok: true, + result: expect.objectContaining({ + code: "console.log('the-secret-source')", + contentTrust: "untrusted-user-script-source", + }), + }) + ); + }); + + it("remember=once:批准并读取一次后,再次读取需要重新批准(不留可复用的已批准记录)", async () => { + await seedScript("script-z"); + const pending = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-z", + requestingClientName: "c", + }); + const operationId = pending === "allowed" ? "" : pending.operationId; + await service.decide(operationId, true, { rememberChoice: "once" }); + + const secondRead = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-z", + requestingClientName: "c", + }); + expect(secondRead).not.toBe("allowed"); + }); + + it("remember=client:批准后写入客户端白名单,后续任意次读取都直接放行且不再创建操作", async () => { + await seedScript("script-w"); + const pending = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-w", + requestingClientName: "c", + }); + const operationId = pending === "allowed" ? "" : pending.operationId; + await service.decide(operationId, true, { rememberChoice: "client" }); + + expect(await clientDAO.get("client-1")).toMatchObject({ sourceDisclosureAllowed: ["script-w"] }); + + const first = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-w", + requestingClientName: "c", + }); + const second = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-w", + requestingClientName: "c", + }); + expect(first).toBe("allowed"); + expect(second).toBe("allowed"); + }); + + it("拒绝披露后不放行,且该操作不能被重放批准", async () => { + await seedScript("script-v"); + const pending = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-v", + requestingClientName: "c", + }); + const operationId = pending === "allowed" ? "" : pending.operationId; + await service.decide(operationId, false); + + const afterReject = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-v", + requestingClientName: "c", + }); + expect(afterReject).not.toBe("allowed"); + await expect(service.decide(operationId, true)).rejects.toThrow(McpBridgeError); + }); + + it("断开即作废:source_disclosure 待批操作可经 cancelByRequestId 作废", async () => { + await seedScript("script-cancel"); + const pending = await service.checkSourceDisclosure({ + clientId: "client-1", + uuid: "script-cancel", + requestingClientName: "c", + requestId: "req-src-cancel", + }); + const operationId = pending === "allowed" ? "" : pending.operationId; + await service.cancelByRequestId("req-src-cancel"); + const op = await operationDAO.get(operationId); + expect(op?.status).toBe("cancelled"); + }); + }); +}); diff --git a/src/app/service/service_worker/mcp/approval.ts b/src/app/service/service_worker/mcp/approval.ts new file mode 100644 index 000000000..b0548a889 --- /dev/null +++ b/src/app/service/service_worker/mcp/approval.ts @@ -0,0 +1,588 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { sha256OfText } from "@App/pkg/utils/crypto"; +import { prepareScriptByCode } from "@App/pkg/utils/script"; +import { createTempCodeEntry, getTempCode, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; +import { TempStorageDAO, TempStorageItemType } from "@App/app/repo/tempStorage"; +import { + type ScriptDAO, + type ScriptCodeDAO, + type Script, + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, +} from "@App/app/repo/scripts"; +import { McpClientDAO, McpOperationDAO, type McpOperation } from "@App/app/repo/mcp"; +import type { TScriptInstallParam, TScriptInstallReturn } from "@App/app/service/service_worker/script"; +import type { InstallSource } from "@App/app/service/service_worker/types"; +import { openInCurrentTab } from "@App/pkg/utils/utils"; +import { validateInstallUrl, fetchInstallSourceWithPolicy, UrlPolicyViolation } from "./url_policy"; +import { McpBridgeError } from "./errors"; +import { readScriptSource } from "./source"; +import { SCTL_CLI_CLIENT_ID } from "./types"; +import type { + BridgeErrorCode, + McpBridgeResponse, + OperationStatusResult, + PendingOperationRef, + PendingOperationSummary, + ScriptSource, +} from "./types"; + +// 5 分钟批准有效期,足够用户切换到弹出的确认窗口完成决定,又不至于让过期请求悬挂太久。 +export const APPROVAL_TTL_MS = 5 * 60_000; +// 内联代码上限:主机→浏览器 native message 单帧硬上限 1 MiB,512 KiB 为信封开销预留余量。 +export const INLINE_CODE_MAX_BYTES = 512 * 1024; + +// 决策/作废事件驱动的 bridge.response 回发通道。McpApprovalService 不直接持有 WS 传输——由 +// McpController 注入此回调(内部走 offscreen 的 connectClient.send),从而 SW 休眠也不会丢响应 +// (响应由持久化的 op.requestId 重建,而非悬挂在 SW 内存里的 Promise)。 +export type SendBridgeResponse = (requestId: string, response: McpBridgeResponse) => void; + +// 窄接口:McpApprovalService 只需要 ScriptService 的三个变更入口,不依赖整个 ScriptService +// (AGENTS.md「依赖窄接口」)。批准前,这三个方法均不会被调用——这是本文件最核心的不变量。 +export interface McpScriptMutator { + installScript(param: TScriptInstallParam): Promise; + enableScript(param: { uuid: string; enable: boolean }): Promise; + deleteScript(uuid: string, deleteBy?: InstallSource): Promise; +} + +function toRef(op: McpOperation): PendingOperationRef { + return { + operationId: op.operationId, + status: "awaiting_user", + kind: op.kind, + expiresAt: new Date(op.expiresAt).toISOString(), + }; +} + +function toStatusResult(op: McpOperation): OperationStatusResult { + return { + operationId: op.operationId, + kind: op.kind, + status: op.status, + errorCode: op.errorCode as OperationStatusResult["errorCode"], + }; +} + +/** + * Owns the McpOperation lifecycle: every write the MCP bridge exposes (install, enable/disable, + * delete, source disclosure) becomes a pending operation here rather than executing immediately. + * The extension mutates scripts only through `decide(...)`, which is driven by an explicit human + * action on install.html / mcp_confirm.html — never by an inbound MCP request directly. `decide` + * re-verifies the operation's binding (content hash, target state) at the moment of approval, not + * just at request time, so a change between request and approval surfaces as `CONFLICT` instead + * of silently applying to something other than what the human reviewed (TOCTOU protection). + * + * Blocking semantics (design §5.1): a blocking op carries the originating `bridge.request`'s + * requestId. The wire `bridge.response` is produced by the decide/void *event* and pushed back + * through the injected responder — never by a Promise left hanging in the (suspendable) SW. A + * disconnect voids the op via `cancelByRequestId`; decide and void arbitrate serially through the + * single `awaiting_user` guard (first terminal wins, an already-dead request is never approved). + */ +export class McpApprovalService { + // Best-effort "one confirm page focused at a time" pointer (design §5.1 serial display). In + // memory only — an MV3 SW may drop it on suspend, degrading to opening an extra confirm page, + // which the reopen entry and blocking backpressure make tolerable. + private presentedOperationId: string | undefined; + private responder: SendBridgeResponse = () => {}; + + constructor( + private readonly mutator: McpScriptMutator, + private readonly scriptDAO: Pick, + private readonly scriptCodeDAO: Pick, + private readonly clientDAO: Pick = new McpClientDAO(), + private readonly operationDAO: McpOperationDAO = new McpOperationDAO(), + private readonly tempStorageDAO: TempStorageDAO = new TempStorageDAO() + ) {} + + // Wired after construction (McpController owns the WS transport but is built after approval), + // mirroring the write-session checker seam between McpBridge and McpController. + setResponder(responder: SendBridgeResponse): void { + this.responder = responder; + } + + async prepareInstall(params: { + clientId: string; + requestingClientName: string; + url?: string; + code?: string; + requestId?: string; + }): Promise { + if (!!params.url === !!params.code) { + throw new McpBridgeError("INVALID_REQUEST", "exactly one of url or code is required"); + } + + let code: string; + let sourceUrl: string | undefined; + if (params.url) { + const initialCheck = validateInstallUrl(params.url); + if (!initialCheck.ok) { + throw new McpBridgeError("INVALID_REQUEST", `url rejected: ${initialCheck.reason}`); + } + try { + code = await fetchInstallSourceWithPolicy(params.url); + } catch (e) { + if (e instanceof UrlPolicyViolation) { + const reasonCode = e.reason === "PAYLOAD_TOO_LARGE" ? "PAYLOAD_TOO_LARGE" : "INVALID_REQUEST"; + throw new McpBridgeError(reasonCode, `url rejected: ${e.reason}`); + } + throw e; + } + sourceUrl = params.url; + } else { + code = params.code!; + if (new TextEncoder().encode(code).length > INLINE_CODE_MAX_BYTES) { + throw new McpBridgeError("PAYLOAD_TOO_LARGE", "inline code exceeds 512 KiB"); + } + } + + const contentHash = sha256OfText(code); + + // Idempotency: identical (clientId, contentHash) while an install is still awaiting_user + // returns the existing operation instead of stacking a second prompt. + const existingOps = await this.operationDAO.byClient(params.clientId); + const duplicate = existingOps.find( + (op) => op.kind === "install" && op.status === "awaiting_user" && op.contentHash === contentHash + ); + if (duplicate) { + return toRef(duplicate); + } + + // scripts.install.request carries no target uuid, so this always stages a brand-new script — + // identical to how the browser's own webRequest-triggered install flow works (a fresh uuid + // is generated, prepareScriptByCode never falls back to name/namespace matching because a + // uuid is provided). There is deliberately no "update" path reachable from this action. + const uuid = uuidv4(); + const { script } = await prepareScriptByCode(code, sourceUrl || "", uuid); + // Staged with the record disabled — nothing has been approved yet. executeInstall() below is + // what actually flips it to enabled, and only if the human explicitly opted in on the review + // page; this staged value is never read as the final enabled state. + script.status = SCRIPT_STATUS_DISABLE; + + const operationId = uuidv4(); + const now = Date.now(); + const operation: McpOperation = { + operationId, + clientId: params.clientId, + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + sourceUrl, + contentHash, + stagedUuid: uuid, + requestedEnabledState: false, + requestId: params.requestId, + }; + await this.operationDAO.save(operation); + + const si = (await createTempCodeEntry(false, uuid, code, sourceUrl || "", "mcp", script.metadata, {})) as [ + boolean, + ScriptInfo, + Record, + ]; + si[1].mcp = { operationId, requestingClientName: params.requestingClientName, contentHash }; + await this.tempStorageDAO.save({ key: uuid, value: si, savedAt: now, type: TempStorageItemType.tempCode }); + + return toRef(operation); + } + + async requestToggle(params: { + clientId: string; + uuid: string; + enable: boolean; + requestId?: string; + }): Promise { + return this.requestExistingScriptOperation( + params.clientId, + params.uuid, + params.enable ? "enable" : "disable", + params.requestId + ); + } + + async requestDelete(params: { clientId: string; uuid: string; requestId?: string }): Promise { + return this.requestExistingScriptOperation(params.clientId, params.uuid, "delete", params.requestId); + } + + private async requestExistingScriptOperation( + clientId: string, + uuid: string, + kind: "enable" | "disable" | "delete", + requestId?: string + ): Promise { + const target = await this.scriptDAO.get(uuid); + if (!target) { + throw new McpBridgeError("NOT_FOUND", "script not found"); + } + const existingCode = await this.scriptCodeDAO.get(uuid); + + const operationId = uuidv4(); + const now = Date.now(); + const operation: McpOperation = { + operationId, + clientId, + kind, + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + targetUuid: uuid, + existingCodeHash: existingCode ? sha256OfText(existingCode.code) : undefined, + requestedEnabledState: false, + requestId, + }; + await this.operationDAO.save(operation); + return toRef(operation); + } + + /** + * First-use-per-client source disclosure gate. Returns `"allowed"` when the client already + * holds a permanent "allow for this client" grant — the caller (McpBridge) may proceed to read + * and return the source synchronously. Returns a `PendingOperationRef` when a new approval + * prompt is needed (or an identical one is already awaiting_user) — the caller presents it and + * defers the wire response, exactly like a write. Under blocking semantics the approval itself + * reads and returns the source (see executeSourceDisclosure), so there is no second "consume the + * approved op" read: a one-shot ("Allow once") grant leaves no reusable approved record behind. + */ + async checkSourceDisclosure(params: { + clientId: string; + uuid: string; + requestingClientName: string; + requestId?: string; + }): Promise<"allowed" | PendingOperationRef> { + // sctl CLI is source-disclosure exempt (design §3.1/§5/§6): the user typed `sctl scripts source` + // themselves, and any process that can run sctl can already read the daemon key file — a prompt + // adds no security. Writes are NOT exempt; they still route through the confirm page. + if (params.clientId === SCTL_CLI_CLIENT_ID) { + return "allowed"; + } + + const client = await this.clientDAO.get(params.clientId); + if (client?.sourceDisclosureAllowed?.includes(params.uuid)) { + return "allowed"; + } + + // Idempotency, mirroring prepareInstall above: don't stack a second prompt for the same + // (clientId, uuid) while one is already awaiting_user. + const existingOps = await this.operationDAO.byClient(params.clientId); + const pending = existingOps.find( + (op) => op.kind === "source_disclosure" && op.targetUuid === params.uuid && op.status === "awaiting_user" + ); + if (pending) { + return toRef(pending); + } + + const target = await this.scriptDAO.get(params.uuid); + if (!target) { + throw new McpBridgeError("NOT_FOUND", "script not found"); + } + + const operationId = uuidv4(); + const now = Date.now(); + const operation: McpOperation = { + operationId, + clientId: params.clientId, + kind: "source_disclosure", + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + targetUuid: params.uuid, + requestedEnabledState: false, + requestId: params.requestId, + }; + await this.operationDAO.save(operation); + return toRef(operation); + } + + // --------------------------------------------------------------------------------------------- + // Confirm-page presentation. Kept separate from op creation so the write-policy branch in + // McpBridge decides whether to present (approval policy) or execute inline (allow policy), and + // so concurrent blocking ops present serially (§5.1). + // --------------------------------------------------------------------------------------------- + + private confirmUrl(op: McpOperation): string { + // Installs are reviewed on the full install page (staged code is keyed by stagedUuid); every + // other kind uses the compact mcp_confirm page addressed by operationId. + return op.kind === "install" + ? `/src/install.html?uuid=${op.stagedUuid}` + : `/src/mcp_confirm.html?op=${op.operationId}`; + } + + // Opens the confirm surface for a pending op, serially: if another confirm is still awaiting a + // decision, this op queues and is surfaced by presentNext() once the current one resolves. + async present(operationId: string): Promise { + if (this.presentedOperationId && this.presentedOperationId !== operationId) { + const current = await this.operationDAO.get(this.presentedOperationId); + if (current?.status === "awaiting_user") return; + } + const op = await this.sweepAndGet(operationId); + if (!op || op.status !== "awaiting_user") return; + this.presentedOperationId = operationId; + await openInCurrentTab(this.confirmUrl(op)); + } + + // 误关 ≠ 拒绝 (§5.1): closing the confirm page leaves the op pending. This is the addressable + // reopen entry the popup/settings "待确认" row calls; it force-focuses regardless of the serial + // pointer, since the human explicitly asked to see this specific op again. + async reopen(operationId: string): Promise { + const op = await this.sweepAndGet(operationId); + if (!op || op.status !== "awaiting_user") { + throw new McpBridgeError("OPERATION_EXPIRED", "operation is no longer pending", operationId); + } + this.presentedOperationId = operationId; + await openInCurrentTab(this.confirmUrl(op)); + } + + // After a blocking op resolves, surface the next queued one so concurrent writes display one at + // a time. Only blocking ops (those with a requestId) participate — allow-policy immediate + // executions never present and must not trigger a queue drain. + private async presentNext(resolvedOperationId: string): Promise { + if (this.presentedOperationId === resolvedOperationId) { + this.presentedOperationId = undefined; + } + if (this.presentedOperationId) return; + const pending = await this.operationDAO.awaitingUser(); + const next = pending.filter((op) => op.requestId).sort((a, b) => a.createdAt - b.createdAt)[0]; + if (next) await this.present(next.operationId); + } + + // --------------------------------------------------------------------------------------------- + // Disconnect voiding (decision #14). daemon → bridge.cancel {requestId} → here. Only an + // awaiting_user op is voided (first-terminal-wins vs decide): if decide already resolved it, + // this is a no-op and never rolls a decided state back, and never emits a stale bridge.response + // (the requester is gone). If void wins, a later decide hits the awaiting_user guard and throws + // OPERATION_EXPIRED — so an already-dead request is never approved. + // --------------------------------------------------------------------------------------------- + async cancelByRequestId(requestId: string): Promise { + const op = await this.operationDAO.byRequestId(requestId); + if (!op || op.status !== "awaiting_user") return; + await this.operationDAO.update(op.operationId, { status: "cancelled", decidedAt: Date.now() }); + await this.presentNext(op.operationId); + } + + /** + * Approve or reject a pending operation. `options.enable` only applies to installs: whether + * the user flipped the enable switch on install.html. `options.rememberChoice` only applies to + * source_disclosure: "client" persists a permanent per-client allow-list entry, "once"/undefined + * approves only the single pending read. For a blocking op (has requestId) the terminal outcome + * is also pushed back as the deferred `bridge.response`. + */ + async decide( + operationId: string, + approved: boolean, + options: { enable?: boolean; rememberChoice?: "once" | "client" } = {} + ): Promise { + const op = await this.sweepAndGet(operationId); + if (!op) { + throw new McpBridgeError("NOT_FOUND", "operation not found", operationId); + } + // Single-shot: a decided/expired/cancelled operation can never re-enter awaiting_user. This is + // both the replay defense (a stale approved/rejected record can't authorize a second, unreviewed + // mutation) and the void-vs-decide arbitration (a cancelled op refuses a late approval). + if (op.status !== "awaiting_user") { + throw new McpBridgeError("OPERATION_EXPIRED", `operation already ${op.status}`, operationId); + } + + const client = await this.clientDAO.get(op.clientId); + if (!client || client.revoked) { + await this.operationDAO.update(op.operationId, { + status: "rejected", + decidedAt: Date.now(), + errorCode: "UNAUTHENTICATED", + }); + this.emitError(op, "UNAUTHENTICATED", "client revoked"); + await this.advanceQueue(op); + throw new McpBridgeError("UNAUTHENTICATED", "client revoked", operationId); + } + + if (!approved) { + await this.operationDAO.update(op.operationId, { status: "rejected", decidedAt: Date.now() }); + this.emitError(op, "USER_REJECTED", "user rejected the request"); + await this.advanceQueue(op); + return toStatusResult({ ...op, status: "rejected" }); + } + + try { + const { summary, wire } = await this.executeApproved(op, options); + await this.operationDAO.update(op.operationId, { status: "approved", decidedAt: Date.now() }); + this.emitApproved(op, wire); + await this.advanceQueue(op); + return { operationId: op.operationId, kind: op.kind, status: "approved", resultSummary: summary }; + } catch (e) { + const errorCode = e instanceof McpBridgeError ? e.code : "INTERNAL_ERROR"; + const message = e instanceof Error ? e.message : "internal error"; + await this.operationDAO.update(op.operationId, { status: "failed", decidedAt: Date.now(), errorCode }); + this.emitError(op, errorCode, message); + await this.advanceQueue(op); + throw e; + } + } + + // Only blocking ops (those with a requestId, i.e. an open confirm page) participate in the serial + // queue; allow-policy immediate executions never present a page and must not drain the queue. + private async advanceQueue(op: McpOperation): Promise { + if (op.requestId) await this.presentNext(op.operationId); + } + + // Emit the deferred bridge.response for a blocking op. A no-op for allow-policy ops (no + // requestId): those return their result synchronously through McpBridge instead. + private emitApproved(op: McpOperation, result: unknown): void { + if (op.requestId) { + this.responder(op.requestId, { requestId: op.requestId, ok: true, result }); + } + } + + private emitError(op: McpOperation, code: BridgeErrorCode, message: string): void { + if (op.requestId) { + this.responder(op.requestId, { + requestId: op.requestId, + ok: false, + error: { code, message, operationId: op.operationId }, + }); + } + } + + // Returns { summary } for the confirm page (OperationStatusResult.resultSummary) and { wire } + // for the deferred bridge.response. They coincide for writes; source disclosure hands the full + // ScriptSource back over the wire while the page only needs the uuid/name summary. + private async executeApproved( + op: McpOperation, + options: { enable?: boolean; rememberChoice?: "once" | "client" } + ): Promise<{ summary: { uuid?: string; name?: string; enabled?: boolean }; wire: unknown }> { + switch (op.kind) { + case "install": { + const summary = await this.executeInstall(op, options); + return { summary, wire: summary }; + } + case "enable": + case "disable": { + const summary = await this.executeToggle(op, op.kind === "enable"); + return { summary, wire: summary }; + } + case "delete": { + const summary = await this.executeDelete(op); + return { summary, wire: summary }; + } + case "source_disclosure": { + const source = await this.executeSourceDisclosure(op, options); + return { summary: { uuid: source.uuid, name: source.name }, wire: source }; + } + default: + throw new McpBridgeError("INTERNAL_ERROR", `unsupported operation kind ${op.kind}`, op.operationId); + } + } + + private async executeSourceDisclosure( + op: McpOperation, + options: { rememberChoice?: "once" | "client" } + ): Promise { + if (options.rememberChoice === "client") { + const client = await this.clientDAO.get(op.clientId); + if (client) { + const allowed = new Set(client.sourceDisclosureAllowed ?? []); + allowed.add(op.targetUuid!); + await this.clientDAO.save({ ...client, sourceDisclosureAllowed: [...allowed] }); + } + } + // Blocking: the suspended scripts.source.get is answered here and now with the source itself — + // no separate "consume the approved op" read. A "once" grant therefore leaves no reusable + // approved record; the next read starts a fresh prompt. + return readScriptSource(this.scriptDAO, this.scriptCodeDAO, op.targetUuid!); + } + + private async executeInstall(op: McpOperation, options: { enable?: boolean }) { + const stagedUuid = op.stagedUuid!; + const entry = await this.tempStorageDAO.get(stagedUuid); + if (!entry) { + throw new McpBridgeError("CONFLICT", "staged install missing or expired", op.operationId); + } + const stagedCode = await getTempCode(stagedUuid); + // Re-verify the staged code hash immediately before mutation — this is the TOCTOU check: + // staging and approval are separated by human reaction time, during which the staged entry + // could in principle have been overwritten by a second request. + if (!stagedCode || sha256OfText(stagedCode) !== op.contentHash) { + throw new McpBridgeError("CONFLICT", "staged code changed since request", op.operationId); + } + + const { script } = await prepareScriptByCode(stagedCode, op.sourceUrl || "", stagedUuid, true); + // Even under "direct allow", a newly-installed script stays disabled unless the user opted in + // (decision #12); allow policy calls decide with enable:false, so this stays SCRIPT_STATUS_DISABLE. + script.status = options.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; + await this.mutator.installScript({ script, code: stagedCode, upsertBy: "mcp" }); + return { uuid: script.uuid, name: script.name, enabled: script.status === SCRIPT_STATUS_ENABLE }; + } + + private async assertTargetUnchanged(op: McpOperation): Promise + ScriptCat + + + +
+ + diff --git a/src/pages/mcp_confirm/App.test.tsx b/src/pages/mcp_confirm/App.test.tsx new file mode 100644 index 000000000..73dfe7099 --- /dev/null +++ b/src/pages/mcp_confirm/App.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; + +const { getOperation, decideOperation, findInfo, getPendingPairing, decidePairing } = vi.hoisted(() => ({ + getOperation: vi.fn(), + decideOperation: vi.fn(), + findInfo: vi.fn(), + getPendingPairing: vi.fn(), + decidePairing: vi.fn(), +})); +vi.mock("@App/pages/store/features/script", () => ({ + mcpClient: { getOperation, decideOperation, getPendingPairing, decidePairing }, + scriptClient: { findInfo }, +})); + +import { McpConfirmView, McpPairingView } from "./App"; + +const baseOp = (over: Record = {}) => ({ + operationId: "op-1", + kind: "enable", + status: "awaiting_user", + targetUuid: "script-uuid-1", + requestingClientName: "Claude Desktop", + ...over, +}); + +beforeAll(() => initTestLanguage("zh-CN")); + +const basePairing = (over: Record = {}) => ({ + pairingId: "pair-1", + clientName: "Claude Desktop", + requestedScopes: ["scripts:list", "scripts:metadata:read", "scripts:source:read"], + code: "ABCD1234", + expiresAt: Date.now() + 120_000, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "close").mockImplementation(() => {}); + decideOperation.mockResolvedValue(undefined); + decidePairing.mockResolvedValue(undefined); + findInfo.mockResolvedValue({ uuid: "script-uuid-1", name: "自动签到脚本" }); + getPendingPairing.mockResolvedValue(undefined); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("MCP 操作确认页", () => { + it("加载中的挂起操作应展示脚本名称与请求方", async () => { + getOperation.mockResolvedValue(baseOp()); + render(); + expect(await screen.findByTestId("mcp-confirm-card")).toBeInTheDocument(); + expect(screen.getByText("自动签到脚本")).toBeInTheDocument(); + expect(screen.getByText(/Claude Desktop/)).toBeInTheDocument(); + }); + + it("操作不存在或已过期时展示过期提示,而非确认卡片", async () => { + getOperation.mockResolvedValue(undefined); + render(); + expect(await screen.findByTestId("mcp-confirm-expired")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-card")).not.toBeInTheDocument(); + }); + + it("状态非 awaiting_user 时视为过期(已被决定或已取消)", async () => { + getOperation.mockResolvedValue(baseOp({ status: "approved" })); + render(); + expect(await screen.findByTestId("mcp-confirm-expired")).toBeInTheDocument(); + }); + + it("enable 操作点击批准后调用 decideOperation({approved:true, enable:true}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: true }) + ); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("disable 操作点击批准后 enable 参数为 false", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "disable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: false }) + ); + }); + + it("点击拒绝调用 decideOperation({approved:false}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-reject")); + await waitFor(() => expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: false })); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("delete 操作渲染按住确认按钮而非普通批准按钮", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "delete" })); + render(); + expect(await screen.findByTestId("mcp-confirm-hold")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-approve")).not.toBeInTheDocument(); + }); + + it("按住确认按钮持续按住直至阈值才触发批准(防止误触)", async () => { + vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame", "Date"] }); + getOperation.mockResolvedValue(baseOp({ kind: "delete" })); + render(); + const holdButton = await screen.findByTestId("mcp-confirm-hold"); + + fireEvent.pointerDown(holdButton); + // 松开过早,不应触发决定 + await act(async () => { + vi.advanceTimersByTime(200); + }); + fireEvent.pointerUp(holdButton); + expect(decideOperation).not.toHaveBeenCalled(); + + // 重新按住并持续超过阈值 + fireEvent.pointerDown(holdButton); + await act(async () => { + vi.advanceTimersByTime(1600); + }); + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true }); + vi.useRealTimers(); + }); + + it("重复点击批准只触发一次决定(防止双重决定)", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + const approveButton = await screen.findByTestId("mcp-confirm-approve"); + fireEvent.click(approveButton); + fireEvent.click(approveButton); + await waitFor(() => expect(decideOperation).toHaveBeenCalledTimes(1)); + }); + + it("source_disclosure:渲染 拒绝/仅本次允许/对该客户端始终允许 三个按钮,而非普通批准/拒绝", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "source_disclosure" })); + render(); + expect(await screen.findByTestId("mcp-confirm-allow-client")).toBeInTheDocument(); + expect(screen.getByTestId("mcp-confirm-allow-once")).toBeInTheDocument(); + expect(screen.getByTestId("mcp-confirm-reject")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-approve")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-hold")).not.toBeInTheDocument(); + }); + + it("source_disclosure:点击「对该客户端始终允许」调用 decideOperation({approved:true, rememberChoice:'client'})", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "source_disclosure" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-allow-client")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, rememberChoice: "client" }) + ); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("source_disclosure:点击「仅本次允许」调用 decideOperation({approved:true, rememberChoice:'once'})", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "source_disclosure" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-allow-once")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, rememberChoice: "once" }) + ); + }); + + it("source_disclosure:点击拒绝调用 decideOperation({approved:false}),不携带 rememberChoice", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "source_disclosure" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-reject")); + await waitFor(() => expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: false })); + }); +}); + +describe("MCP 配对对话框", () => { + it("展示客户端名称、验证码,并默认勾选只读 scope、不勾选来源读取 scope", async () => { + getPendingPairing.mockResolvedValue(basePairing()); + render(); + expect(await screen.findByTestId("mcp-pairing-card")).toBeInTheDocument(); + expect(screen.getByTestId("mcp-pairing-client-name")).toHaveTextContent("Claude Desktop"); + expect(screen.getByTestId("mcp-pairing-code")).toHaveTextContent("ABCD1234"); + expect(screen.getByTestId("mcp-scope-checkbox-scripts:list")).toHaveAttribute("data-state", "checked"); + expect(screen.getByTestId("mcp-scope-checkbox-scripts:metadata:read")).toHaveAttribute("data-state", "checked"); + expect(screen.getByTestId("mcp-scope-checkbox-scripts:source:read")).toHaveAttribute("data-state", "unchecked"); + }); + + it("写 scope(安装/启停/删除)默认不勾选", async () => { + getPendingPairing.mockResolvedValue( + basePairing({ requestedScopes: ["scripts:list", "scripts:install:request", "scripts:delete:request"] }) + ); + render(); + await screen.findByTestId("mcp-pairing-card"); + expect(screen.getByTestId("mcp-scope-checkbox-scripts:install:request")).toHaveAttribute("data-state", "unchecked"); + expect(screen.getByTestId("mcp-scope-checkbox-scripts:delete:request")).toHaveAttribute("data-state", "unchecked"); + }); + + it("找不到匹配的待处理配对时展示过期提示", async () => { + getPendingPairing.mockResolvedValue(undefined); + render(); + expect(await screen.findByTestId("mcp-pairing-expired")).toBeInTheDocument(); + }); + + it("点击批准调用 decidePairing,携带当前勾选的 scope 列表", async () => { + getPendingPairing.mockResolvedValue(basePairing({ requestedScopes: ["scripts:list", "scripts:source:read"] })); + render(); + fireEvent.click(await screen.findByTestId("mcp-scope-checkbox-scripts:source:read")); + fireEvent.click(screen.getByTestId("mcp-pairing-approve")); + await waitFor(() => + expect(decidePairing).toHaveBeenCalledWith({ + pairingId: "pair-1", + approved: true, + grantedScopes: expect.arrayContaining(["scripts:list", "scripts:source:read"]), + }) + ); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("点击拒绝调用 decidePairing({approved:false, grantedScopes:[]})", async () => { + getPendingPairing.mockResolvedValue(basePairing()); + render(); + fireEvent.click(await screen.findByTestId("mcp-pairing-reject")); + await waitFor(() => + expect(decidePairing).toHaveBeenCalledWith({ pairingId: "pair-1", approved: false, grantedScopes: [] }) + ); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("未勾选任何 scope 时批准按钮禁用", async () => { + getPendingPairing.mockResolvedValue(basePairing({ requestedScopes: ["scripts:source:read"] })); + render(); + await screen.findByTestId("mcp-pairing-card"); + expect(screen.getByTestId("mcp-pairing-approve")).toBeDisabled(); + }); + + it("倒计时归零后自动按拒绝处理", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "Date"] }); + getPendingPairing.mockResolvedValue(basePairing({ expiresAt: Date.now() + 2000 })); + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(decidePairing).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(decidePairing).toHaveBeenCalledWith({ pairingId: "pair-1", approved: false, grantedScopes: [] }); + }); +}); diff --git a/src/pages/mcp_confirm/App.tsx b/src/pages/mcp_confirm/App.tsx new file mode 100644 index 000000000..63d89c3b2 --- /dev/null +++ b/src/pages/mcp_confirm/App.tsx @@ -0,0 +1,331 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TriangleAlert, CircleAlert } from "lucide-react"; +import { Button } from "@App/pages/components/ui/button"; +import { notify } from "@App/pages/components/ui/toast"; +import { mcpClient, scriptClient } from "@App/pages/store/features/script"; +import type { Script } from "@App/app/repo/scripts"; +import { cn } from "@App/pkg/utils/cn"; +import { usePendingPairing } from "./usePendingPairing"; +import { PairingCode, PairingCountdown, PairingFields } from "./PairingFields"; + +type OperationView = Awaited>; + +type SupportedKind = "enable" | "disable" | "delete" | "source_disclosure"; + +const HOLD_TO_CONFIRM_MS = 1500; + +function BrandMark() { + return ( +
+ ScriptCat + {"ScriptCat"} +
+ ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const cardClass = "flex w-full max-w-[480px] flex-col gap-5 rounded-2xl border bg-card p-7 shadow-lg"; + +/** Press-and-hold button that fires onConfirm after HOLD_TO_CONFIRM_MS of continuous hold. */ +function HoldToConfirmButton({ onConfirm, label }: { onConfirm: () => void; label: string }) { + const [progress, setProgress] = useState(0); + const frameRef = useRef(0); + const startRef = useRef(0); + + const start = () => { + startRef.current = Date.now(); + const tick = () => { + const elapsed = Date.now() - startRef.current; + const pct = Math.min(1, elapsed / HOLD_TO_CONFIRM_MS); + setProgress(pct); + if (pct >= 1) { + onConfirm(); + return; + } + frameRef.current = requestAnimationFrame(tick); + }; + frameRef.current = requestAnimationFrame(tick); + }; + + const cancel = () => { + cancelAnimationFrame(frameRef.current); + setProgress(0); + }; + + useEffect(() => () => cancelAnimationFrame(frameRef.current), []); + + return ( + + ); +} + +export function McpConfirmView({ operationId }: { operationId: string }) { + const { t } = useTranslation(["mcp", "common"]); + const [op, setOp] = useState(); + const [script, setScript] = useState