diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index c66c277f7..e6bc744b7 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. | +| [`external-access-guide.md`](./external-access-guide.md) | End-user how-to for External Access: install sctl, enable, enroll once (`sctl connect`), write/source-read policies, three-tier decisions, tool table, CLI verbs, worked examples. Security rationale lives in the sctl repo's `docs/threat-model.md`/`docs/protocol.md` — link, don't duplicate. `external-access-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 8db124fa5..b87b682aa 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)。**改/审文档前先读。** | +## 外部接入 / External Access + +内置于所有构建、**默认关闭**,从扩展设置开启;经本地伴随二进制 [`sctl`](https://github.com/scriptscat/sctl)(WebSocket daemon,仅回环 `127.0.0.1:8643`)通信,不新增浏览器权限、无 native-messaging 主机与安装器。信任扁平:接入(enrollment)一次建立长期密钥 K,CLI 与所有 MCP agent 都继承信任,不再逐客户端配对/scope/撤销。 + +| 文档 | 说明 | +| --- | --- | +| [`external-access-guide.md`](./external-access-guide.md) | 使用指南:安装 sctl、启用外部接入、一次性接入(`sctl connect` 带外配对码)、写操作/源码读取两条策略、三档决策(拒绝/允许/本会话允许)的实操步骤,附 MCP 工具表、CLI 动词与真实用例。**想实际用起来先读这份。**中文版见 [`external-access-guide_zh-CN.md`](./external-access-guide_zh-CN.md)。 | +| [`sctl` 仓库 `docs/protocol.md`](https://github.com/scriptscat/sctl/blob/main/docs/protocol.md) | 协议规范:扩展↔daemon WS 双向握手、接入握手、bridge action、错误码、写操作阻塞语义。常量单源 `protocol.json` 与本仓库 [`src/app/service/service_worker/external_access/protocol.json`](../src/app/service/service_worker/external_access/protocol.json) 逐字节镜像(由 `protocol.conformance.test.ts` 守护)。 | +| [`sctl` 仓库 `docs/threat-model.md`](https://github.com/scriptscat/sctl/blob/main/docs/threat-model.md) | 威胁模型:两个信任锚点(长期密钥 K + 0600 控制令牌)、扁平信任的取舍、Origin 白名单、攻击面与对策、写路径人工审批 + TOCTOU、落盘凭据一览。 | + ## 翻译 / Translation | 文档 | 说明 | diff --git a/docs/develop.md b/docs/develop.md index 065debd84..59b071391 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -40,6 +40,22 @@ using it — including links already shipped in installed builds, which keep sen 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. +### External Access (`external_access/` subsystem) + +External Access — the user-facing "外部接入 / External Access" feature +(`src/app/service/service_worker/external_access/`) — is **built into every profile and gated only at +runtime** by `external_access_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). Trust is +flat: a single enrollment establishes the long-term key K, and the CLI and every MCP agent inherit it +(no per-client pairing/scope/revocation). It connects, from an offscreen WebSocket client +(`src/app/service/offscreen/external-access-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 +[`external_access/protocol.json`](../src/app/service/service_worker/external_access/protocol.json), mirrored byte-for-byte with +the sctl repo and guarded by `protocol.conformance.test.ts`. See +[`external-access-guide.md`](./external-access-guide.md) for usage, and the sctl repo's +`docs/protocol.md` / `docs/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/external-access-guide.md b/docs/external-access-guide.md new file mode 100644 index 000000000..c73d51664 --- /dev/null +++ b/docs/external-access-guide.md @@ -0,0 +1,281 @@ +

+中文 English +

+ +# Using ScriptCat External Access + +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. + +External Access 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 `localhost: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. + +**Trust is flat.** You *enroll* the extension with the daemon exactly once (an out-of-band code you +read from the terminal and type into ScriptCat). After that, the `sctl` CLI **and** every MCP agent +reach the daemon over that one trusted channel and inherit its trust — there is no per-client +pairing, no per-client scopes, and no per-client revocation. What still gates every dangerous action +is a **human decision in the browser**, applied identically to the CLI and to MCP. + +For *why* it's built this way (threat model, handshake, TOCTOU guarantees) see the sctl repo's +[`docs/threat-model.md`](https://github.com/scriptscat/sctl/blob/main/docs/threat-model.md) and +[`docs/protocol.md`](https://github.com/scriptscat/sctl/blob/main/docs/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. +- Read a script's full source — gated by the **source-read policy** (approval by default) because + source can contain secrets. This applies to the CLI too — reading source is a privacy decision, so + `sctl scripts source` is **not** exempt. +- **Request** installing a new script, enabling/disabling one, or deleting one. Every one is a + *request*: the call blocks and nothing changes until you decide in a ScriptCat window that pops up + automatically. Installs and updates reuse ScriptCat's normal install page (with the identity, + permissions, code, and version diff you already know); the install page's own enable switch decides + the enabled state, so an approved install is usable immediately, just like a normal install. + +There is no code path from an MCP or CLI request to a script mutation that skips your decision +(unless you deliberately switch a policy to "allow directly" — see below). + +## 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 "version too old". 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 connect`, `sctl mcp`, and the CLI verbs auto-start a detached `serve` if one isn't +already running. + +## 3. Enable External Access in ScriptCat + +Open the extension's options page → **Tools** → **External Access**. Flip the enable switch — a +dialog first explains what you're turning on (agents can list/read metadata freely; everything else +needs your decision). The connection address defaults to `ws://localhost:8643`; you can edit it +before enrolling if you moved the daemon. Status stays "Pending enrollment" until you enroll (next +step). + +## 4. Enroll the extension with the daemon (one time) + +Enrollment is the one and only step that needs an out-of-band code — and it's the whole of trust +setup. Run: + +```bash +sctl connect +``` + +This prints an 8-character one-time code (valid 2 minutes) **in your terminal only** — the code +never travels over the connection. In ScriptCat's External Access card, click **Connect to sctl**, +type the code into the dialog, and confirm. 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**. + +That's it. **The CLI and every MCP agent now inherit this trust** — none of them enroll again. +Re-enrolling replaces the old key (only one extension instance is supported in this version). + +Why an out-of-band code and not "compare two codes on screen"? Anything the daemon sends over the +wire, a malicious local process that forged its Origin could also receive. A code that only ever +exists in your terminal and passes through your eyes and fingers is the one secret such a process +can't get. + +## 5a. Connect an MCP client (Claude Desktop, Claude Code, …) + +No per-client pairing. Once the extension is enrolled, just point your client's MCP config at the +serving command: + +```json +{ + "mcpServers": { + "scriptcat": { "command": "sctl", "args": ["mcp", "--name", "Claude Desktop"] } + } +} +``` + +Restart your client. It lists a `scriptcat` server exposing all script tools; every write and every +source read still stops at your decision in the browser. `--name` is purely an **audit label** — it +attributes this client's requests in ScriptCat's log and nothing more (you can run several client +configs with different names). If the extension isn't enrolled yet, tool calls return an +"extension not connected" error until you run `sctl connect`. + +## 5b. …or just use the CLI + +The `sctl` verbs drive the exact same channel with the same permissions: + +```bash +sctl scripts list # or --json for machine-readable output +sctl scripts info +sctl scripts source # raw source to stdout; gated by the source-read policy +sctl install ./my-script.user.js # or a URL; blocks until you decide 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. Policies and per-decision choices + +Two **global policies** live in the External Access card, and both apply identically to the CLI and +to MCP: + +- **Write policy** — *Require approval* (default) blocks every install / toggle / delete on a + confirm surface; *Allow directly* runs write requests immediately (an amber warning marks this as + a safety downgrade). +- **Source-read policy** — *Require approval* (default) blocks each source read on a disclosure + prompt; *Allow directly* returns source without a prompt. + +When a policy is set to *Require approval*, the confirm surface offers a **three-tier decision**: + +- **Reject** — this request only. +- **Allow** — this request only. +- **Allow this session** — stop asking for **this script and this operation** for the rest of the + **extension session** (it's keyed to the browser/extension session, not to an MCP connection or a + CLI process, which is exactly why the CLI and MCP share one notion of "session"). It resets on + browser restart, extension reload, or when you stop External Access. + +To stop being asked entirely, switch the matching policy to *Allow directly* (global, affects CLI +and MCP at once). + +## Available MCP tools + +| Tool | What it needs from you | Write? | +|---|---|---| +| `scripts_list` | nothing | No | +| `scripts_metadata_get` | nothing | No | +| `scripts_source_get` | a source-disclosure decision (unless the source-read policy is "allow directly") | No | +| `scripts_install_request` | an install decision on the install page (unless the write policy is "allow directly") | Yes | +| `scripts_toggle_request` | a toggle decision (unless the write policy is "allow directly") | Yes | +| `scripts_delete_request` | a hold-to-confirm delete decision (unless the write policy is "allow directly") | Yes | + +Write tools are **blocking**: the call suspends until you decide (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 surface invalidated. + +## Case studies + +### Case 1 — "What userscripts do I have installed, and which are enabled?" + +Read-only, works the moment you're enrolled: + +> **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 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 hits 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:** with the source-read policy on *Require approval*, the read blocks. ScriptCat pops up +> a confirm page: *"External Access wants to read the source of `Auto Login`. Source may contain +> secrets."* with **Reject**, **Allow this session**, **Allow**. +> +> - **Allow** — this read succeeds; the *next* read prompts again. +> - **Allow this session** — this and every future read of *this script* succeed with no further +> prompt until the extension session ends. +> +> Say you pick "Allow." The call returns the source, the agent spots the bug and calls +> `scripts_install_request` with the fix. ScriptCat's install page opens with a banner — +> *"Requested via External Access"* — plus the source, an expandable content SHA-256, and the normal +> permission/diff review. The enable switch behaves like a normal install; approve and the fixed +> version is live. + +### 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:** with the write policy on *Require approval*, ScriptCat opens a lightweight confirm page +> (script name, "triggered via External Access", Reject / Allow this session / Allow). You allow → +> the toggle runs → the blocking call returns success. +> +> Between your decision 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. If you close a confirm page by mistake, the request stays pending — reopen it from +> the **Awaiting confirmation** entry. + +### Case 5 — Stopping access when you're done + +> Tools → External Access → **Stop External Access**. This discards the long-term key, so every +> downstream client (CLI and every MCP agent) loses trust at once, clears any "allow this session" +> grants, and flips the enable switch off. To connect again later, enroll once more with +> `sctl connect`. (To cut off a single agent without stopping everything, remove the `scriptcat` +> server from that agent's own MCP config.) + +## Auditing what happened + +External Access records each operation through ScriptCat's existing **logger** under the +`external-access` component — allowed or denied, with the client's self-reported label, the action, and +the outcome. It never contains tokens or source. The card has a **View audit log** button that +deep-links to the Logs page pre-filtered to `component = external-access`, where you get all the usual +filters (level, time, text). The self-reported client name is recorded for forensics only; because +it's unauthenticated and forgeable, it never appears on an approval screen. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| Status stuck on "Connecting…" then "connection failed" | 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. | +| "version too old" | 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. | +| Enrollment never completes | `sctl connect` codes last 2 minutes; External Access must be enabled and the daemon reachable so ScriptCat can run the handshake. Read the code from the terminal and type it into the dialog before it expires. | +| `sctl mcp` tool calls return "extension not connected" | The extension isn't enrolled (or you stopped External Access). Run `sctl connect` and complete enrollment, then retry. | +| `scripts_source_get` prompts again after you approved | You chose "Allow" and the agent made a second read — expected; approve again, or pick "Allow this session". | +| 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 External Access 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. An **Origin whitelist** cheaply rejects ordinary web pages + (a browser stamps the Origin and page JS can't forge it), but a non-browser process can forge any + Origin, so every connection must still pass a bidirectional HMAC handshake before any business + message; an unauthenticated socket is dropped after 5 s with no information leaked. The handshake + is the real gate. +- It treats the client's self-reported name as an **audit label only** — flat trust doesn't + authenticate client identity, so the name never gates anything and never appears on an approval + screen. What authorizes a request is that it arrived over the enrolled channel; what bounds damage + is your per-operation decision. +- It can't defend against another process already running as your own OS user reading the daemon key + or control token (both `0600`) — a documented, accepted residual limitation, not a bug. See the + sctl [`docs/threat-model.md`](https://github.com/scriptscat/sctl/blob/main/docs/threat-model.md). diff --git a/docs/external-access-guide_zh-CN.md b/docs/external-access-guide_zh-CN.md new file mode 100644 index 000000000..5c32df764 --- /dev/null +++ b/docs/external-access-guide_zh-CN.md @@ -0,0 +1,234 @@ +

+中文 English +

+ +# 使用 ScriptCat 外部接入 + +一份面向实际任务的指南:把 AI 代理(Claude Desktop、Claude Code,或任何其他 +[Model Context Protocol](https://modelcontextprotocol.io/) 客户端)——或者你自己的终端——接入你的 +ScriptCat 用户脚本,并配有你真正会遇到的流程范例。 + +外部接入**内置于每个构建、但默认关闭**;你在扩展设置里主动开启。它对接一个很小的本地伴随二进制 +[`sctl`](https://github.com/scriptscat/sctl),后者在 `localhost:8643` 上运行一个 WebSocket daemon; +扩展从 offscreen 文档作为客户端连上它。不新增任何浏览器权限,也没有 native-messaging host 或安装器要注册。 + +**信任是扁平的。** 你只需把扩展与 daemon **接入(enrollment)一次**(一个带外配对码:你从终端读到、 +输入进 ScriptCat)。此后,`sctl` CLI **与**每一个 MCP 代理都经这条可信通道继承信任——没有逐客户端配对、 +没有逐客户端 scope、也没有逐客户端撤销。真正为每个危险操作把关的,是**浏览器里的人工决策**,对 CLI 与 +MCP 一视同仁。 + +想了解**为什么这么设计**(威胁模型、握手、TOCTOU 保证),见 sctl 仓库的 +[`docs/threat-model.md`](https://github.com/scriptscat/sctl/blob/main/docs/threat-model.md) 与 +[`docs/protocol.md`](https://github.com/scriptscat/sctl/blob/main/docs/protocol.md);本指南是「我到底怎么用」的 +配套。 + +## 你能得到什么 + +接入后,AI 代理(经 MCP)**或**你(经 `sctl` CLI)可以: + +- 列出已安装的用户脚本、读取其元数据(匹配、grant、启用状态)——只读,无需批准。 +- 读取脚本完整源码——由**源码读取策略**把关(默认需批准),因为源码可能含机密。这对 CLI 也一样: + 读源码是隐私决策,所以 `sctl scripts source` **不豁免**。 +- **请求**安装新脚本、启用/停用某脚本、或删除某脚本。每一个都是*请求*:调用会阻塞,在你于自动弹出的 + ScriptCat 窗口里做出决策前什么都不会改变。安装/更新复用 ScriptCat 常规的脚本安装页(带你已熟悉的身份、 + 权限、代码与版本 diff);启用状态由安装页自身的开关决定,因此批准后的安装即刻可用,与普通安装一致。 + +从 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`(或用发布产物)。 + +- macOS、Linux 或 Windows——`sctl` 仅监听 loopback 且跨平台,无平台专属安装步骤。 + +## 2. 启动 daemon + +```bash +sctl serve +``` + +这会在 `127.0.0.1:8643` 上绑定 WebSocket hub(仅 loopback——拒绝任何非本机地址),并写出一个 `0600` +控制令牌文件供本机 `sctl` 前端使用。你也可以跳过这步:`sctl connect`、`sctl mcp` 与各 CLI 动词在未运行时 +会自动拉起一个 detached 的 `serve`。 + +## 3. 在 ScriptCat 中开启外部接入 + +打开扩展选项页 → **工具** → **外部接入**。拨动启用开关——会先弹一个对话框说明你正在开启什么(代理可自由 +列出脚本/读元数据;其余都需你决策)。连接地址默认 `ws://localhost:8643`;接入前你可以在卡片里编辑它(如果 +你移动了 daemon)。状态会停在「待接入」,直到你完成接入(下一步)。 + +## 4. 将扩展与 daemon 接入(仅一次) + +接入是唯一需要带外配对码的环节——也是信任建立的全部。运行: + +```bash +sctl connect +``` + +这会**只在你的终端里**打印一个 8 位一次性配对码(2 分钟内有效)——该码绝不经连接传输。在 ScriptCat 的 +外部接入卡片里,点**接入 sctl**,把码输入对话框并确认。两端跑一次双向握手,daemon 经加密通道把长期密钥交给 +扩展,状态转为**已接入**。 + +就这样。**CLI 与每个 MCP 代理现在都继承了这份信任**——它们都不再各自接入。重新接入会替换旧密钥(本版本只 +支持一个扩展实例)。 + +为什么用带外配对码,而不是「两边对照同一个码」?任何 daemon 经线路发出的东西,一个伪造了 Origin 的恶意本机 +进程也能收到。只在你终端里存在、经你的眼睛与手指传递的码,才是这种进程拿不到的秘密。 + +## 5a. 接入一个 MCP 客户端(Claude Desktop、Claude Code……) + +无逐客户端配对。扩展一旦接入,直接把客户端的 MCP 配置指向服务命令即可: + +```json +{ + "mcpServers": { + "scriptcat": { "command": "sctl", "args": ["mcp", "--name", "Claude Desktop"] } + } +} +``` + +重启客户端。它会列出一个 `scriptcat` server,暴露全部脚本工具;每次写入、每次读源码仍会停在浏览器里等你 +决策。`--name` 纯粹是**审计标签**——它只在 ScriptCat 日志里标注这个客户端的请求,别无他用(你可以用不同名字 +跑多份客户端配置)。若扩展尚未接入,工具调用会返回「扩展未连接」错误,直到你运行 `sctl connect`。 + +## 5b. ……或者直接用 CLI + +`sctl` 动词驱动的是同一条通道,权限完全相同: + +```bash +sctl scripts list # 或 --json 输出机器可读结果 +sctl scripts info +sctl scripts source # 源码原样输出到 stdout;受源码读取策略把关 +sctl install ./my-script.user.js # 或一个 URL;阻塞至你在浏览器决策 +sctl enable +sctl disable +sctl rm +``` + +写动词会阻塞至你决策;**Ctrl-C** 取消该请求(浏览器确认页随之关闭)。退出码:**0** 批准/成功、**1** 你拒绝、 +**2** 作废(超时 / Ctrl-C / 断开)、**3** 其他错误。 + +## 6. 策略与逐次决策 + +外部接入卡片里有两条**全局策略**,二者对 CLI 与 MCP 一视同仁: + +- **写操作策略**——*需人工审批*(默认)会把每次安装 / 启停 / 删除挡在确认面上;*直接允许*则让写请求立即 + 执行(琥珀警示标明这是安全降级)。 +- **源码读取策略**——*需人工审批*(默认)会把每次读源码挡在披露提示上;*直接允许*则不提示直接返回源码。 + +当某条策略为*需人工审批*时,确认面给出**三档决策**: + +- **拒绝**——仅此次请求。 +- **允许**——仅此次请求。 +- **本会话允许**——在剩余的**扩展会话**内,对**这一脚本、这一操作**不再询问(它锚在浏览器/扩展会话,而非 + 某条 MCP 连接或某个 CLI 进程——这正是 CLI 与 MCP 能共享同一个「会话」概念的原因)。浏览器重启、扩展重载, + 或你停止外部接入时它都会清除。 + +想彻底不被询问 → 把对应策略切到*直接允许*(全局,同时对 CLI 与 MCP 生效)。 + +## 可用的 MCP 工具 + +| 工具 | 需要你做什么 | 写? | +|---|---|---| +| `scripts_list` | 无 | 否 | +| `scripts_metadata_get` | 无 | 否 | +| `scripts_source_get` | 一次源码披露决策(除非源码读取策略为「直接允许」) | 否 | +| `scripts_install_request` | 在安装页做一次安装决策(除非写操作策略为「直接允许」) | 是 | +| `scripts_toggle_request` | 一次启停决策(除非写操作策略为「直接允许」) | 是 | +| `scripts_delete_request` | 一次长按确认的删除决策(除非写操作策略为「直接允许」) | 是 | + +写工具是**阻塞**的:调用挂起至你决策(没有操作轮询 API——结果在同一次调用里返回)。等待期间 MCP server 会发 +progress 通知以防客户端超时;若客户端断开或超时,操作作废、其确认面失效。 + +## 案例 + +### 案例 1 —— 「我装了哪些用户脚本,哪些是启用的?」 + +只读,接入后立即可用: + +> **你:** 我现在装了哪些用户脚本? +> **代理:** *调用 `scripts_list`* → 一个 `{ uuid, name, type, enabled, updatedAt, hasUpdateUrl, … }` 数组—— +> 无源码,且只告知是否存在更新 URL(元数据层,非机密)。 +> **代理:** 「你装了 12 个脚本;其中 9 个已启用。」 + +不会弹任何提示——和你自己看脚本列表一样安全。(终端里同样的答案:`sctl scripts list`。) + +### 案例 2 —— 「找出并修复我自动登录脚本里的 bug」 + +这是会触发披露闸门的流程: + +> **你:** 我的「Auto Login」脚本有个 bug——能帮我找出并修好吗? +> **代理:** *调用 `scripts_list`* 找到 uuid,*调用 `scripts_metadata_get`* 确认,再 *调用 `scripts_source_get`*。 +> **结果:** 源码读取策略处于*需人工审批*时,读取会阻塞。ScriptCat 弹出确认页: +> *「外部接入请求读取「Auto Login」的源码。源码可能含机密。」*,带**拒绝**、**本会话允许**、**允许**。 +> +> - **允许**——此次读取成功;*下一次*读取会再次提示。 +> - **本会话允许**——此次以及之后对*这一脚本*的每次读取都不再提示,直到扩展会话结束。 +> +> 假设你选「允许」。调用返回源码,代理定位 bug 并带修复调用 `scripts_install_request`。ScriptCat 的安装页 +> 打开,顶部带横幅——*「通过外部接入请求」*——外加源码、可展开的内容 SHA-256,以及常规的权限/diff 审阅。 +> 启用开关的表现与普通安装一致;批准后修复版即刻生效。 + +### 案例 3 —— 「先关掉那个把这个网站搞坏的脚本,我调一下」 + +> **你:** 先把我的「Ad Blocker Tweaks」停用。 +> **代理:** *调用 `scripts_list`* 找到 uuid,再 `scripts_toggle_request`,参数 `{ uuid, enable: false }`。 +> **结果:** 写操作策略处于*需人工审批*时,ScriptCat 弹出一个轻量确认页(脚本名、「通过外部接入触发」、 +> 拒绝 / 本会话允许 / 允许)。你允许 → 停用执行 → 阻塞调用返回成功。 +> +> 在你决策与实际停用之间,ScriptCat 会复核脚本代码自请求以来未被改动(TOCTOU 保护)——若你其间编辑过它, +> 会得到 `CONFLICT`,代理据此重新发起请求。 + +### 案例 4 —— 「清理我不再用的脚本」 + +> **你:** 删掉这三个我几个月没用的:X、Y、Z。 +> **代理:** 调用 `scripts_delete_request` 三次,每个 uuid 一次。 +> **结果:** 这些请求阻塞,其确认页**逐个**显示(并发写入会排队)。每次删除需**长按 1.5 秒**——比点击更难误触, +> 因为删除还会移除脚本存储的值且不可撤销。你可以独立拒绝任何一个。若你误关了某个确认页,该请求仍处于待决—— +> 从**待确认**入口重新打开它。 + +### 案例 5 —— 用完后停止接入 + +> 工具 → 外部接入 → **停止外部接入**。这会废弃长期密钥,于是每个下游客户端(CLI 与每个 MCP 代理)同时失去 +> 信任,清除所有「本会话允许」授权,并把启用开关关掉。之后想再连,用 `sctl connect` 再接入一次。(若只想切断 +> 某一个代理而不停止全部,从那个代理自己的 MCP 配置里移除 `scriptcat` server 即可。) + +## 审计发生了什么 + +外部接入通过 ScriptCat 现有的**日志系统**、以 `external-access` 组件记录每次操作——允许或拒绝,附客户端自报 +标签、动作与结果。它绝不含令牌或源码。卡片里有一个**查看审计日志**按钮,深链到已按 `component = external-access` +预过滤的日志页,你在那里能用上全部常规筛选(级别、时间、正文)。自报的客户端名只用于取证;因为它未经认证、 +可伪造,永不出现在审批界面上。 + +## 排障 + +| 症状 | 可能原因 | +|---|---| +| 状态卡在「连接中…」然后「连接失败」 | daemon 没在运行,或在另一个地址上。启动 `sctl serve`(或跑任意 `sctl` 命令),并核对卡片里的连接地址一致。 | +| 「版本过旧」 | daemon 报告的版本低于 `minDaemonVersion`(`0.1.0`)——几乎总是普通 `go build`(`0.0.0-dev`)。用第 1 步的 `-ldflags "…Version=0.1.0"` 重新构建,或用发布产物。 | +| 接入始终不完成 | `sctl connect` 的码 2 分钟有效;外部接入必须已启用且 daemon 可达,ScriptCat 才能跑握手。从终端读到码并在它过期前输入对话框。 | +| `sctl mcp` 工具调用返回「扩展未连接」 | 扩展尚未接入(或你停止了外部接入)。运行 `sctl connect` 完成接入后重试。 | +| 批准后 `scripts_source_get` 又提示 | 你选了「允许」而代理又读了一次——符合预期;再批准一次,或改选「本会话允许」。 | +| CLI 写操作退出码 `2` | 请求被作废——你(或客户端)超时、Ctrl-C、或扩展在你决策前断开了。 | + +## 外部接入有意做与不做的事 + +- 它**确实**开了一个 loopback WebSocket 监听(`127.0.0.1:8643`)——这是换取零新增浏览器权限、无安装器的 + 代价。**Origin 白名单**廉价地挡掉普通网页(浏览器盖章 Origin、页面 JS 无法伪造),但非浏览器进程能伪造任意 + Origin,因此每条连接在发送任何业务消息前仍须通过双向 HMAC 握手;未认证的 socket 在 5 秒后被丢弃、不泄露 + 任何信息。握手才是真正的闸门。 +- 它把客户端自报的名字当作**审计标签**——扁平信任不认证客户端身份,故该名字既不 gate 任何东西、也不出现在 + 审批界面上。授权一个请求的是「它来自已接入的通道」;限制损害的是你的逐操作决策。 +- 它无法防御另一个以你自己 OS 用户身份运行的进程读取 daemon 密钥或控制令牌(均 `0600`)——一个有记录、已接受 + 的残余局限,而非 bug。见 sctl + [`docs/threat-model.md`](https://github.com/scriptscat/sctl/blob/main/docs/threat-model.md)。 diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 66a6101ac..9855fabb4 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -273,6 +273,18 @@ const setMarker = (name, value) => { else document.addEventListener("DOMContentLoaded", apply, { once: true }); }; +let backgroundReady = false; +const markBackgroundReady = (value) => { + if (backgroundReady || value !== ${JSON.stringify(backgroundValue)}) return; + backgroundReady = true; + setMarker(${JSON.stringify(readyAttribute)}, "true"); +}; + +GM_addValueChangeListener(${JSON.stringify(backgroundKey)}, (name, oldValue, newValue) => { + markBackgroundReady(newValue); +}); +markBackgroundReady(GM_getValue(${JSON.stringify(backgroundKey)}, "missing")); + document.addEventListener(${JSON.stringify(runEvent)}, () => { try { const sharedBackgroundValue = GM_getValue(${JSON.stringify(backgroundKey)}, "missing"); @@ -293,8 +305,6 @@ document.addEventListener(${JSON.stringify(runEvent)}, () => { setMarker(${JSON.stringify(resultAttribute)}, JSON.stringify({ ok: false, error: String(error) })); } }); - -setMarker(${JSON.stringify(readyAttribute)}, "true"); `; return { diff --git a/rspack.config.ts b/rspack.config.ts index 55470a397..f9e8ae578 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`, + external_access_confirm: `${src}/pages/external_access_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/external_access_confirm.html`, + template: `${src}/pages/external_access_confirm.html`, + inject: "head", + title: "ScriptCat", + minify: true, + chunks: ["external_access_confirm"], + }), new rspack.HtmlRspackPlugin({ filename: `${dist}/ext/src/batchupdate.html`, template: `${src}/pages/batchupdate.html`, diff --git a/src/app/repo/external_access.test.ts b/src/app/repo/external_access.test.ts new file mode 100644 index 000000000..eb1a3a708 --- /dev/null +++ b/src/app/repo/external_access.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { ExternalAccessOperationDAO } from "./external_access"; +import type { ExternalAccessOperation } from "./external_access"; + +function makeOperation(overrides: Partial = {}): ExternalAccessOperation { + const now = Date.now(); + return { + operationId: "op-1", + clientId: "client-1", + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + 5 * 60_000, + sessionKey: "install:test-ns:Demo", + ...overrides, + }; +} + +describe("ExternalAccessOperationDAO", () => { + let dao: ExternalAccessOperationDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new ExternalAccessOperationDAO(); + }); + + it("save / get 往返读写一条待批操作", async () => { + const op = makeOperation(); + await dao.save(op); + expect(await dao.get(op.operationId)).toEqual(op); + }); + + it("byRequestId 按发起请求的 requestId 定位待批操作", async () => { + await dao.save(makeOperation({ operationId: "op-a", requestId: "req-a" })); + await dao.save(makeOperation({ operationId: "op-b", requestId: "req-b" })); + const found = await dao.byRequestId("req-b"); + expect(found?.operationId).toBe("op-b"); + }); + + it("awaitingUser 只返回仍在等待用户决策的操作", async () => { + await dao.save(makeOperation({ operationId: "op-pending", status: "awaiting_user" })); + await dao.save(makeOperation({ operationId: "op-done", status: "approved" })); + const pending = await dao.awaitingUser(); + expect(pending.map((o) => o.operationId)).toEqual(["op-pending"]); + }); +}); diff --git a/src/app/repo/external_access.ts b/src/app/repo/external_access.ts new file mode 100644 index 000000000..8e185348a --- /dev/null +++ b/src/app/repo/external_access.ts @@ -0,0 +1,52 @@ +import { Repo } from "./repo"; +import type { + OperationKind, + OperationStatus, + BridgeErrorCode, +} from "@App/app/service/service_worker/external_access/types"; + +// 待批操作:绑定请求当时的内容哈希/目标脚本状态等字段全部保留,执行器(ExternalAccessApprovalService.decide) +// 在批准瞬间重新校验这些字段,防止请求与批准之间的 TOCTOU 篡改。扁平信任下不再有每客户端记录: +// clientId 只是 sctl 上报的连接标识,仅供审计归因,不参与鉴权。 +export interface ExternalAccessOperation { + operationId: string; // 加密安全随机 UUID + clientId: string; // sctl 上报的连接标识(审计归因用,可伪造,不做鉴权) + kind: OperationKind; + status: OperationStatus; + createdAt: number; + expiresAt: number; // createdAt + 5 分钟 + // 「本会话允许」授权键 = sessionAllowKey(kind, 脚本身份)。批准时若选「本会话允许」即以此键写入 + // SessionAllowStore;present() 命中该键则免弹自动批准。见 session_allow.ts。 + sessionKey: string; + sourceUrl?: string; + contentHash?: string; // 暂存代码的 SHA-256 + stagedUuid?: string; // TempStorageDAO 的 key + targetUuid?: string; // 更新/启用/禁用/删除/源码读取的目标脚本 + existingCodeHash?: string; // 请求时目标脚本当前代码的 SHA-256 + // 阻塞语义下发起该操作的 bridge.request.requestId:终态决策/断开作废时据此把 bridge.response + // 经 offscreen 回发给 daemon(不在 SW 内存里悬挂 Promise)。「直接允许」立即执行的操作不寻址 + // 任何挂起请求,故不带 requestId。 + requestId?: string; + decidedAt?: number; + errorCode?: BridgeErrorCode; +} + +export class ExternalAccessOperationDAO extends Repo { + constructor() { + super("mcpOperation"); + } + + save(operation: ExternalAccessOperation): Promise { + return this._save(operation.operationId, operation); + } + + // 断开作废按 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"); + } +} diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 0cb1d78ca..311c2ee75 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -9,6 +9,7 @@ import { sendMessage } from "@Packages/message/client"; import GMApi from "./gm_api"; import { MessageQueue, type IMessageQueue } from "@Packages/message/message_queue"; import { VSCodeConnect } from "./vscode-connect"; +import { ExternalAccessConnect } from "./external-access-connect"; import { HtmlExtractorService } from "./html_extractor"; import { makeBlobURL } from "@App/pkg/utils/utils"; import { type SandboxChannelHealth } from "./client"; @@ -133,6 +134,11 @@ export class BackgroundEnvManagerBase { gmApi.init(); const vscodeConnect = new VSCodeConnect(this.offscreenServer.group("vscodeConnect"), this.extMsgSender); vscodeConnect.init(); + const externalAccessConnect = new ExternalAccessConnect( + this.offscreenServer.group("externalAccessConnect"), + this.extMsgSender + ); + externalAccessConnect.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 7e81aea8c..c1f42c405 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 ExternalAccessConnectParam } from "./external-access-connect"; +import type { WSEnvelope } from "../service_worker/external_access/types"; export function preparationSandbox(windowMessage: WindowMessage) { return sendMessage(windowMessage, "offscreen/preparationSandbox"); @@ -117,3 +119,24 @@ export class VscodeConnectClient extends Client { return this.do("connect", params); } } + +// SW → offscreen driver for the MCP WS transport. ExternalAccessController uses it to open/close the socket +// and to hand the offscreen ExternalAccessConnect outbound envelopes (bridge.response / bridge.shutdown) +// to write onto the wire. +export class ExternalAccessConnectClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "offscreen/externalAccessConnect"); + } + + connect(params: ExternalAccessConnectParam): 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/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts new file mode 100644 index 000000000..fa4d27dd2 --- /dev/null +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -0,0 +1,483 @@ +import { initTestEnv } from "@Tests/utils"; +import { + ExternalAccessConnect, + bytesToHex, + computeHandshakeHmac, + constantTimeEqualHex, + decryptLongTermKey, + derivePairingKeys, + hexToBytes, + normalizePairingCode, + randomNonceHex, + utf8Bytes, + type ExternalAccessConnectParam, +} from "./external-access-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/external_access/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("ExternalAccessConnect", () => { + // 握手用例走真实 WebCrypto(HKDF/HMAC/AES-GCM),比纯逻辑用例重;在满载并行下会超出 fast + // 项目默认的 340ms 预算,故为本文件放宽超时(真实互操作向量不宜用 mock 替换)。 + beforeAll(() => vi.setConfig({ testTimeout: 5000 })); + + let externalAccessConnect: ExternalAccessConnect; + 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("externalAccessConnect"); + + externalAccessConnect = new ExternalAccessConnect(group, mockMessage); + relay = { + envelope: vi.fn().mockResolvedValue(undefined), + paired: vi.fn().mockResolvedValue(undefined), + disconnected: vi.fn().mockResolvedValue(undefined), + }; + (externalAccessConnect as any).relay = relay; + externalAccessConnect.init(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + function triggerConnect(param: ExternalAccessConnectParam): MockWebSocket { + (externalAccessConnect as any).startSession(param); + return wsInstances[wsInstances.length - 1]; + } + + const sessionParam = (key: string): ExternalAccessConnectParam => ({ + 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((externalAccessConnect 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((externalAccessConnect 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((externalAccessConnect as any).handshakeComplete).toBe(true); + // 握手后内部凭据切到会话模式,供后续自动重连 + expect((externalAccessConnect 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((externalAccessConnect as any).handshakeComplete).toBe(true)); + return ws; + } + + it("业务信封(bridge.request/bridge.cancel)转发给 SW", async () => { + const ws = await completeSessionHandshake(); + ws.simulateMessage({ v: 1, type: "bridge.request", requestId: "r1", payload: { action: "scripts.list" } }); + ws.simulateMessage({ v: 1, type: "bridge.cancel", requestId: "r2", payload: {} }); + await vi.waitFor(() => expect(relay.envelope).toHaveBeenCalledTimes(2)); + expect(relay.envelope.mock.calls.map((c) => c[0].type)).toEqual(["bridge.request", "bridge.cancel"]); + }); + + 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(); + (externalAccessConnect 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(); + (externalAccessConnect 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))); + (externalAccessConnect as any).currentParams = null; + (externalAccessConnect 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/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts new file mode 100644 index 000000000..7c6e63dd1 --- /dev/null +++ b/src/app/service/offscreen/external-access-connect.ts @@ -0,0 +1,427 @@ +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 { ExternalAccessConnectRelayClient } from "../service_worker/client"; +import protocol from "../service_worker/external_access/protocol.json"; +import type { + AuthChallengePayload, + AuthMode, + AuthOkPayload, + AuthResponsePayload, + WSEnvelope, +} from "../service_worker/external_access/types"; + +/** + * 外部接入桥接 · offscreen WebSocket client + * + * offscreen 拥有到 sctl daemon 的 WS 连接:epoch 守卫的重连 + 指数退避(骨架取自 + * vscode-connect.ts),以及 PROTOCOL §3 的双向 HMAC 握手(WebCrypto)。握手/心跳在本层自持, + * 业务信封经现有 Group 通道转发给 Service Worker 的 ExternalAccessController。 + * + * 之所以放 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 ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pairing"; code: string }; + +export interface ExternalAccessConnectParam { + url: string; + auth: ExternalAccessAuth; +} + +// --------------------------------------------------------------------------------------------- +// 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: ExternalAccessAuth, + 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 ExternalAccessConnect { + private readonly logger = LoggerCore.logger().with({ service: "ExternalAccessConnect" }); + private readonly relay: ExternalAccessConnectRelayClient; + + private ws: WebSocket | null = null; + private epoch = 0; + private currentParams: ExternalAccessConnectParam | 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 ExternalAccessConnectRelayClient(messageSender); + } + + init(): void { + this.messageGroup.on("connect", (params: ExternalAccessConnectParam) => { + 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: ExternalAccessConnectParam): 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 "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 / 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 4a618893e..7fc42a8fa 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 { ExternalAccessUIService } from "./external_access/service"; +import type { WSEnvelope } from "./external_access/types"; export class ServiceWorkerClient extends Client { constructor(msgSender: MessageSend) { @@ -502,3 +504,72 @@ export class AgentClient extends Client { return this.doThrow("mcpApi", request); } } + +// Page-facing client for the external-access 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 ExternalAccessClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/externalAccess"); + } + + getBridgeStatus(): Promise> { + return this.doThrow("status"); + } + + // Enrollment (接入): dial the daemon with the one-time code the user read from `sctl connect`. + enroll(code: string) { + return this.do("enroll", code); + } + + getOperation(operationId: string): ReturnType { + return this.doThrow("operation", operationId); + } + + decideOperation(param: { + operationId: string; + approved: boolean; + enable?: boolean; + rememberSession?: boolean; + }): ReturnType { + return this.doThrow("operationDecision", param); + } + + // Re-opens a still-pending op's confirm page (误关重开入口). The "待确认" reopen 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 "待确认" reopen list. + getPendingOperations(): ReturnType { + return this.doThrow("pendingOperations"); + } + + // "停止外部接入" kill switch: discard key K + drop 本会话允许 grants + stop + disable. + stopExternalAccess() { + return this.do("stopExternalAccess"); + } +} + +// offscreen → SW relay for the MCP WS transport. The offscreen ExternalAccessConnect 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 ExternalAccessController 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 ExternalAccessConnectRelayClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/externalAccessConnect"); + } + + 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/external_access/approval.test.ts b/src/app/service/service_worker/external_access/approval.test.ts new file mode 100644 index 000000000..d080404fb --- /dev/null +++ b/src/app/service/service_worker/external_access/approval.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ExternalAccessApprovalService, type ExternalAccessScriptMutator, type SendBridgeResponse } from "./approval"; +import { ExternalAccessOperationDAO } from "@App/app/repo/external_access"; +import { SessionAllowStore } from "./session_allow"; +import { + ScriptDAO, + ScriptCodeDAO, + SCRIPT_STATUS_ENABLE, + SCRIPT_STATUS_DISABLE, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; +import { TempStorageDAO } from "@App/app/repo/tempStorage"; +import { createMockOPFS } from "@App/app/repo/test-helpers"; +import * as utilsModule from "@App/pkg/utils/utils"; + +const VALID_SCRIPT_CODE = `// ==UserScript== +// @name Demo +// @namespace test-ns +// @version 1.0.0 +// ==/UserScript== +console.log("hi");`; + +const TARGET_UUID = "22222222-2222-4222-8222-222222222222"; + +describe("ExternalAccessApprovalService(三档决策 + 会话授权)", () => { + let approval: ExternalAccessApprovalService; + let scriptDAO: ScriptDAO; + let scriptCodeDAO: ScriptCodeDAO; + let operationDAO: ExternalAccessOperationDAO; + let sessionAllow: SessionAllowStore; + let responder: ReturnType; + let mutator: ExternalAccessScriptMutator & { + installScript: ReturnType; + enableScript: ReturnType; + deleteScript: ReturnType; + }; + + beforeEach(async () => { + chrome.storage.local.clear(); + await chrome.storage.session.clear(); + createMockOPFS(); + vi.spyOn(utilsModule, "openInCurrentTab").mockResolvedValue(undefined); + scriptDAO = new ScriptDAO(); + scriptCodeDAO = new ScriptCodeDAO(); + operationDAO = new ExternalAccessOperationDAO(); + sessionAllow = new SessionAllowStore(); + mutator = { + installScript: vi.fn().mockResolvedValue({ update: false, updatetime: Date.now() }), + enableScript: vi.fn().mockResolvedValue(undefined), + deleteScript: vi.fn().mockResolvedValue(undefined), + }; + approval = new ExternalAccessApprovalService( + mutator, + scriptDAO, + scriptCodeDAO, + operationDAO, + new TempStorageDAO(), + sessionAllow + ); + responder = vi.fn(); + approval.setResponder(responder as SendBridgeResponse); + }); + + afterEach(() => vi.restoreAllMocks()); + + async function seedScript(uuid: string, code = "console.log('v1')") { + await scriptDAO.save({ + uuid, + name: "Seed", + author: "dao", + namespace: "test-ns", + originDomain: "", + origin: "", + checkUpdate: true, + checkUpdateUrl: "", + downloadUrl: "", + config: undefined, + metadata: { name: ["Seed"], namespace: ["test-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 } as any); + } + + it("prepareInstall 暂存代码并按 namespace:name 生成 sessionKey", async () => { + const ref = await approval.prepareInstall({ clientId: "c", code: VALID_SCRIPT_CODE, requestId: "r1" }); + const op = await operationDAO.get(ref.operationId); + expect(op?.kind).toBe("install"); + expect(op?.sessionKey).toBe("install:test-ns:Demo"); + expect(op?.requestId).toBe("r1"); + expect(op?.stagedUuid).toBeTruthy(); + }); + + it("批准安装默认启用(enable:true 即装即用),回发 bridge.response", async () => { + const ref = await approval.prepareInstall({ clientId: "c", code: VALID_SCRIPT_CODE, requestId: "r1" }); + await approval.decide(ref.operationId, true, { enable: true }); + expect(mutator.installScript).toHaveBeenCalled(); + expect(mutator.installScript.mock.calls[0][0].script.status).toBe(SCRIPT_STATUS_ENABLE); + expect(responder).toHaveBeenCalledWith("r1", expect.objectContaining({ ok: true })); + }); + + it("拒绝安装回发 USER_REJECTED,且操作转为 rejected", async () => { + const ref = await approval.prepareInstall({ clientId: "c", code: VALID_SCRIPT_CODE, requestId: "r1" }); + await approval.decide(ref.operationId, false); + expect(mutator.installScript).not.toHaveBeenCalled(); + expect(responder).toHaveBeenCalledWith( + "r1", + expect.objectContaining({ ok: false, error: expect.objectContaining({ code: "USER_REJECTED" }) }) + ); + expect((await operationDAO.get(ref.operationId))?.status).toBe("rejected"); + }); + + it("「本会话允许」批准后,同一 (脚本, 操作类别) 的后续请求由 present 免弹自动批准", async () => { + await seedScript(TARGET_UUID); + const ref1 = await approval.requestToggle({ clientId: "c", uuid: TARGET_UUID, enable: false, requestId: "r1" }); + await approval.decide(ref1.operationId, true, { rememberSession: true }); + expect(await sessionAllow.has(`disable:${TARGET_UUID}`)).toBe(true); + + // 第二次同类请求:present 命中会话授权,自动批准,不打开确认页。 + (utilsModule.openInCurrentTab as ReturnType).mockClear(); + const ref2 = await approval.requestToggle({ clientId: "c", uuid: TARGET_UUID, enable: false, requestId: "r2" }); + await approval.present(ref2.operationId); + expect(utilsModule.openInCurrentTab).not.toHaveBeenCalled(); + expect((await operationDAO.get(ref2.operationId))?.status).toBe("approved"); + expect(responder).toHaveBeenCalledWith("r2", expect.objectContaining({ ok: true })); + }); + + it("未命中会话授权时 present 打开确认页且操作保持待批", async () => { + await seedScript(TARGET_UUID); + const ref = await approval.requestDelete({ clientId: "c", uuid: TARGET_UUID, requestId: "r1" }); + await approval.present(ref.operationId); + expect(utilsModule.openInCurrentTab).toHaveBeenCalledWith( + `/src/external_access_confirm.html?op=${ref.operationId}` + ); + expect((await operationDAO.get(ref.operationId))?.status).toBe("awaiting_user"); + }); + + it("TOCTOU:批准前目标脚本代码变化则 enable 返回 CONFLICT", async () => { + await seedScript(TARGET_UUID, "console.log('v1')"); + const ref = await approval.requestToggle({ clientId: "c", uuid: TARGET_UUID, enable: false, requestId: "r1" }); + await scriptCodeDAO.save({ uuid: TARGET_UUID, code: "console.log('tampered')" } as any); + await expect(approval.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("断开作废:cancelByRequestId 后再次 decide 命中 awaiting_user 闸门抛 OPERATION_EXPIRED", async () => { + await seedScript(TARGET_UUID); + const ref = await approval.requestDelete({ clientId: "c", uuid: TARGET_UUID, requestId: "r1" }); + await approval.cancelByRequestId("r1"); + expect((await operationDAO.get(ref.operationId))?.status).toBe("cancelled"); + await expect(approval.decide(ref.operationId, true)).rejects.toMatchObject({ code: "OPERATION_EXPIRED" }); + }); + + it("批准源码读取时回发完整源码", async () => { + await seedScript(TARGET_UUID, "console.log('secret')"); + const ref = await approval.requestSourceDisclosure({ clientId: "c", uuid: TARGET_UUID, requestId: "r1" }); + await approval.decide(ref.operationId, true); + expect(responder).toHaveBeenCalledWith( + "r1", + expect.objectContaining({ ok: true, result: expect.objectContaining({ code: "console.log('secret')" }) }) + ); + }); + + it("clearSessionAllow 清空所有本会话授权", async () => { + await seedScript(TARGET_UUID); + const ref = await approval.requestToggle({ clientId: "c", uuid: TARGET_UUID, enable: true, requestId: "r1" }); + await approval.decide(ref.operationId, true, { rememberSession: true }); + expect(await sessionAllow.has(`enable:${TARGET_UUID}`)).toBe(true); + await approval.clearSessionAllow(); + expect(await sessionAllow.has(`enable:${TARGET_UUID}`)).toBe(false); + }); + + it("直接允许路径(无 requestId)不回发 bridge.response", async () => { + const ref = await approval.prepareInstall({ clientId: "c", code: VALID_SCRIPT_CODE }); + await approval.decide(ref.operationId, true, { enable: false }); + expect(mutator.installScript.mock.calls[0][0].script.status).toBe(SCRIPT_STATUS_DISABLE); + expect(responder).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/service_worker/external_access/approval.ts b/src/app/service/service_worker/external_access/approval.ts new file mode 100644 index 000000000..6962296ff --- /dev/null +++ b/src/app/service/service_worker/external_access/approval.ts @@ -0,0 +1,556 @@ +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 { ExternalAccessOperationDAO, type ExternalAccessOperation } from "@App/app/repo/external_access"; +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 { ExternalAccessBridgeError } from "./errors"; +import { readScriptSource } from "./source"; +import { SessionAllowStore, sessionAllowKey } from "./session_allow"; +import type { + BridgeErrorCode, + ExternalAccessBridgeResponse, + OperationStatusResult, + PendingOperationRef, + PendingOperationSummary, + ScriptSource, +} from "./types"; + +// 5 分钟批准有效期,足够用户切换到弹出的确认窗口完成决定,又不至于让过期请求悬挂太久。 +export const APPROVAL_TTL_MS = 5 * 60_000; +// 内联代码上限:单条 WS 文本帧下的合理上限,512 KiB 为信封开销预留余量。 +export const INLINE_CODE_MAX_BYTES = 512 * 1024; + +// 决策/作废事件驱动的 bridge.response 回发通道。ExternalAccessApprovalService 不直接持有 WS 传输——由 +// ExternalAccessController 注入此回调(内部走 offscreen 的 connectClient.send),从而 SW 休眠也不会丢响应 +// (响应由持久化的 op.requestId 重建,而非悬挂在 SW 内存里的 Promise)。 +export type SendBridgeResponse = (requestId: string, response: ExternalAccessBridgeResponse) => void; + +// 窄接口:ExternalAccessApprovalService 只需要 ScriptService 的三个变更入口,不依赖整个 ScriptService +// (AGENTS.md「依赖窄接口」)。批准前,这三个方法均不会被调用——这是本文件最核心的不变量。 +export interface ExternalAccessScriptMutator { + installScript(param: TScriptInstallParam): Promise; + enableScript(param: { uuid: string; enable: boolean }): Promise; + deleteScript(uuid: string, deleteBy?: InstallSource): Promise; +} + +function toRef(op: ExternalAccessOperation): PendingOperationRef { + return { + operationId: op.operationId, + status: "awaiting_user", + kind: op.kind, + expiresAt: new Date(op.expiresAt).toISOString(), + }; +} + +function toStatusResult(op: ExternalAccessOperation): OperationStatusResult { + return { + operationId: op.operationId, + kind: op.kind, + status: op.status, + errorCode: op.errorCode as OperationStatusResult["errorCode"], + }; +} + +// 「本会话允许」自动批准时用什么选项执行:安装默认启用(即装即用,设计 §6),其余无附加选项。 +function autoApproveOptions(op: ExternalAccessOperation): { enable?: boolean } { + return op.kind === "install" ? { enable: true } : {}; +} + +/** + * Owns the ExternalAccessOperation lifecycle: every write the bridge exposes (install, enable/disable, + * delete) and every source read under the "approval" policy becomes a pending operation here + * rather than executing immediately. The extension mutates scripts / discloses source only through + * `decide(...)`, driven by an explicit human action on install.html / external_access_confirm.html — never by + * an inbound request directly. `decide` re-verifies the operation's binding (content hash, target + * state) at approval time, not just 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). + * + * Three-tier decision (design §3): 拒绝 / 允许(once) / 本会话允许. The third tier records a + * (kind, script) key in the extension-session SessionAllowStore; a later matching request is then + * auto-approved inside `present()` without opening a page. There are no per-client records — trust + * is flat, `clientId` is an audit label only. + * + * Blocking semantics (design §5.1): a blocking op carries the originating 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 ExternalAccessApprovalService { + // 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: ExternalAccessScriptMutator, + private readonly scriptDAO: Pick, + private readonly scriptCodeDAO: Pick, + private readonly operationDAO: ExternalAccessOperationDAO = new ExternalAccessOperationDAO(), + private readonly tempStorageDAO: TempStorageDAO = new TempStorageDAO(), + private readonly sessionAllow: SessionAllowStore = new SessionAllowStore() + ) {} + + // Wired after construction (ExternalAccessController owns the WS transport but is built after approval). + setResponder(responder: SendBridgeResponse): void { + this.responder = responder; + } + + // "停止外部接入" kill switch drops every 本会话允许 grant along with the key K. + clearSessionAllow(): Promise { + return this.sessionAllow.clear(); + } + + async prepareInstall(params: { + clientId: string; + url?: string; + code?: string; + requestId?: string; + }): Promise { + if (!!params.url === !!params.code) { + throw new ExternalAccessBridgeError("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 ExternalAccessBridgeError("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 ExternalAccessBridgeError(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 ExternalAccessBridgeError("PAYLOAD_TOO_LARGE", "inline code exceeds 512 KiB"); + } + } + + const contentHash = sha256OfText(code); + + // Idempotency: an identical contentHash still awaiting_user returns the existing operation + // instead of stacking a second prompt (flat trust — dedup by content, not by client). + const awaiting = await this.operationDAO.awaitingUser(); + const duplicate = awaiting.find((op) => op.kind === "install" && 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 the browser's own webRequest-triggered install flow (a fresh uuid is generated). + const uuid = uuidv4(); + const { script } = await prepareScriptByCode(code, sourceUrl || "", uuid); + + const operationId = uuidv4(); + const now = Date.now(); + const operation: ExternalAccessOperation = { + operationId, + clientId: params.clientId, + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + sessionKey: sessionAllowKey("install", `${script.namespace}:${script.name}`), + sourceUrl, + contentHash, + stagedUuid: uuid, + requestId: params.requestId, + }; + await this.operationDAO.save(operation); + + // Stage the code so install.html (opened at ?uuid=) can render identity/permissions/ + // code exactly like a normal install. si[1].externalAccess signals the bridge-triggered context (banner + + // three-tier action bar); it deliberately carries no client name (design §3.0.1). + const si = (await createTempCodeEntry( + false, + uuid, + code, + sourceUrl || "", + "external_access", + script.metadata, + {} + )) as [boolean, ScriptInfo, Record]; + si[1].externalAccess = { operationId, 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 ExternalAccessBridgeError("NOT_FOUND", "script not found"); + } + const existingCode = await this.scriptCodeDAO.get(uuid); + + const operationId = uuidv4(); + const now = Date.now(); + const operation: ExternalAccessOperation = { + operationId, + clientId, + kind, + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + sessionKey: sessionAllowKey(kind, uuid), + targetUuid: uuid, + existingCodeHash: existingCode ? sha256OfText(existingCode.code) : undefined, + requestId, + }; + await this.operationDAO.save(operation); + return toRef(operation); + } + + /** + * Source-read gate under the "approval" policy. Source may embed secrets, so it keeps its own + * pending-op prompt (unlike list/metadata). Always returns a ref for the caller to present(); the + * "本会话允许" fast path lives in present() like every other kind. Idempotent: won't stack a second + * prompt for the same script while one is already awaiting_user. + */ + async requestSourceDisclosure(params: { + clientId: string; + uuid: string; + requestId?: string; + }): Promise { + const awaiting = await this.operationDAO.awaitingUser(); + const pending = awaiting.find((op) => op.kind === "source_disclosure" && op.targetUuid === params.uuid); + if (pending) { + return toRef(pending); + } + + const target = await this.scriptDAO.get(params.uuid); + if (!target) { + throw new ExternalAccessBridgeError("NOT_FOUND", "script not found"); + } + + const operationId = uuidv4(); + const now = Date.now(); + const operation: ExternalAccessOperation = { + operationId, + clientId: params.clientId, + kind: "source_disclosure", + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + sessionKey: sessionAllowKey("source_disclosure", params.uuid), + targetUuid: params.uuid, + requestId: params.requestId, + }; + await this.operationDAO.save(operation); + return toRef(operation); + } + + // --------------------------------------------------------------------------------------------- + // Confirm-page presentation. Kept separate from op creation so the policy branch in ExternalAccessBridge + // decides whether to present (approval policy) or execute inline (allow policy), and so + // concurrent blocking ops present serially (§5.1). + // --------------------------------------------------------------------------------------------- + + private confirmUrl(op: ExternalAccessOperation): string { + // Installs are reviewed on the full install page (staged code is keyed by stagedUuid); every + // other kind uses the compact external_access_confirm page addressed by operationId. + return op.kind === "install" + ? `/src/install.html?uuid=${op.stagedUuid}` + : `/src/external_access_confirm.html?op=${op.operationId}`; + } + + // Opens the confirm surface for a pending op. First honours the third decision tier: a + // session-allowed (kind, script) auto-approves without a page (design §3). Otherwise displays + // serially — if another confirm is still awaiting a decision, this op queues and presentNext() + // surfaces it once the current one resolves. + async present(operationId: string): Promise { + const op = await this.sweepAndGet(operationId); + if (!op || op.status !== "awaiting_user") return; + if (await this.sessionAllow.has(op.sessionKey)) { + await this.decide(op.operationId, true, autoApproveOptions(op)); + return; + } + if (this.presentedOperationId && this.presentedOperationId !== operationId) { + const current = await this.operationDAO.get(this.presentedOperationId); + if (current?.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; it force-focuses regardless of the serial pointer, since the human explicitly + // asked to see this specific op again. Unlike present(), it never auto-approves. + async reopen(operationId: string): Promise { + const op = await this.sweepAndGet(operationId); + if (!op || op.status !== "awaiting_user") { + throw new ExternalAccessBridgeError("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 left the enable switch on install.html on. `options.rememberSession` records the third + * decision tier — 「本会话允许」— persisting a (kind, script) key so this session skips the prompt + * next time (applies to every kind). 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; rememberSession?: boolean } = {} + ): Promise { + const op = await this.sweepAndGet(operationId); + if (!op) { + throw new ExternalAccessBridgeError("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 ExternalAccessBridgeError("OPERATION_EXPIRED", `operation already ${op.status}`, 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); + // Only remember after a successful execution — a failed op must not silently auto-approve + // the next identical request. + if (options.rememberSession) { + await this.sessionAllow.add(op.sessionKey); + } + 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 ExternalAccessBridgeError ? 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: ExternalAccessOperation): 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 ExternalAccessBridge instead. + private emitApproved(op: ExternalAccessOperation, result: unknown): void { + if (op.requestId) { + this.responder(op.requestId, { requestId: op.requestId, ok: true, result }); + } + } + + private emitError(op: ExternalAccessOperation, 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: ExternalAccessOperation, + options: { enable?: boolean } + ): 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); + return { summary: { uuid: source.uuid, name: source.name }, wire: source }; + } + default: + throw new ExternalAccessBridgeError("INTERNAL_ERROR", `unsupported operation kind ${op.kind}`, op.operationId); + } + } + + private async executeSourceDisclosure(op: ExternalAccessOperation): Promise { + // Blocking: the suspended scripts.source.get is answered here and now with the source itself. + return readScriptSource(this.scriptDAO, this.scriptCodeDAO, op.targetUuid!); + } + + private async executeInstall(op: ExternalAccessOperation, options: { enable?: boolean }) { + const stagedUuid = op.stagedUuid!; + const entry = await this.tempStorageDAO.get(stagedUuid); + if (!entry) { + throw new ExternalAccessBridgeError("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 ExternalAccessBridgeError("CONFLICT", "staged code changed since request", op.operationId); + } + + const { script } = await prepareScriptByCode(stagedCode, op.sourceUrl || "", stagedUuid, true); + // Enabled state follows the decision (install page switch, or enable:true under direct allow) — + // there is no forced-disabled安全带 anymore (设计 §6:直接允许即装即用). + script.status = options.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; + await this.mutator.installScript({ script, code: stagedCode, upsertBy: "external_access" }); + return { uuid: script.uuid, name: script.name, enabled: script.status === SCRIPT_STATUS_ENABLE }; + } + + private async assertTargetUnchanged(op: ExternalAccessOperation): Promise + ScriptCat + + + +
+ + diff --git a/src/pages/external_access_confirm/App.test.tsx b/src/pages/external_access_confirm/App.test.tsx new file mode 100644 index 000000000..4c8c6ee76 --- /dev/null +++ b/src/pages/external_access_confirm/App.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; + +const { getOperation, decideOperation, findInfo } = vi.hoisted(() => ({ + getOperation: vi.fn(), + decideOperation: vi.fn(), + findInfo: vi.fn(), +})); +vi.mock("@App/pages/store/features/script", () => ({ + externalAccessClient: { getOperation, decideOperation }, + scriptClient: { findInfo }, +})); + +import { ExternalAccessConfirmView } from "./App"; + +const baseOp = (over: Record = {}) => ({ + operationId: "op-1", + kind: "enable", + status: "awaiting_user", + targetUuid: "script-uuid-1", + expiresAt: Date.now() + 5 * 60_000, + ...over, +}); + +beforeAll(() => initTestLanguage("zh-CN")); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "close").mockImplementation(() => {}); + decideOperation.mockResolvedValue(undefined); + findInfo.mockResolvedValue({ + uuid: "script-uuid-1", + name: "自动签到脚本", + metadata: { version: ["1.2.0"] }, + author: "dao", + }); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("外部接入 · 操作确认页(三档决策)", () => { + it("挂起操作展示脚本名称与基于渠道的描述(不显示客户端名)", async () => { + getOperation.mockResolvedValue(baseOp()); + render(); + expect(await screen.findByTestId("external-access-confirm-card")).toBeInTheDocument(); + expect(screen.getByText("自动签到脚本")).toBeInTheDocument(); + expect(screen.getByText(/通过外部接入触发/)).toBeInTheDocument(); + }); + + it("顶栏展示「外部接入 · 操作确认」面包屑与由 expiresAt 计算的 TTL 倒计时", async () => { + getOperation.mockResolvedValue(baseOp({ expiresAt: Date.now() + 90_000 })); + render(); + await screen.findByTestId("external-access-confirm-card"); + expect(screen.getByText("外部接入 · 操作确认")).toBeInTheDocument(); + const chip = screen.getByTestId("external-access-confirm-countdown"); + const secs = Number(chip.textContent!.match(/(\d+)s/)![1]); + expect(secs).toBeGreaterThan(85); + expect(secs).toBeLessThanOrEqual(90); + }); + + it("操作不存在或已过期时展示过期提示,而非确认卡片", async () => { + getOperation.mockResolvedValue(undefined); + render(); + expect(await screen.findByTestId("external-access-confirm-expired")).toBeInTheDocument(); + expect(screen.queryByTestId("external-access-confirm-card")).not.toBeInTheDocument(); + }); + + it("状态非 awaiting_user 时视为过期", async () => { + getOperation.mockResolvedValue(baseOp({ status: "approved" })); + render(); + expect(await screen.findByTestId("external-access-confirm-expired")).toBeInTheDocument(); + }); + + it("enable 点「允许」调用 decideOperation({approved:true, enable:true}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("external-access-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("external-access-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: false }) + ); + }); + + it("点「本会话允许」携带 rememberSession:true", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("external-access-confirm-session-allow")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ + operationId: "op-1", + approved: true, + enable: true, + rememberSession: true, + }) + ); + }); + + it("点「拒绝」调用 decideOperation({approved:false}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("external-access-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(); + fireEvent.click(await screen.findByTestId("external-access-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: false }) + ); + }); + + it("重复点击「允许」只触发一次决定", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + const approveButton = await screen.findByTestId("external-access-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("external-access-confirm-card")).toBeInTheDocument(); + expect(screen.getByTestId("external-access-confirm-session-allow")).toBeInTheDocument(); + expect(screen.getByTestId("external-access-confirm-approve")).toBeInTheDocument(); + expect(screen.getByTestId("external-access-confirm-reject")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("external-access-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: false }) + ); + }); +}); diff --git a/src/pages/external_access_confirm/App.tsx b/src/pages/external_access_confirm/App.tsx new file mode 100644 index 000000000..ebc59cf3c --- /dev/null +++ b/src/pages/external_access_confirm/App.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Power, PowerOff, Trash2, FileCode, CircleAlert, History, ShieldAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Button } from "@App/pages/components/ui/button"; +import { notify } from "@App/pages/components/ui/toast"; +import { externalAccessClient, scriptClient } from "@App/pages/store/features/script"; +import type { Script } from "@App/app/repo/scripts"; +import { cn } from "@App/pkg/utils/cn"; +import { CountdownChip } from "./CountdownChip"; + +type OperationView = Awaited>; + +// install/update 走脚本安装页;这里只处理无代码的轻量确认(设计 §3.0)。 +type SupportedKind = "enable" | "disable" | "delete" | "source_disclosure"; + +function BrandMark() { + return ( +
+ ScriptCat + {"ScriptCat"} +
+ ); +} + +function PageShell({ topRight, children }: { topRight?: React.ReactNode; children: React.ReactNode }) { + const { t } = useTranslation("external_access"); + return ( +
+ {/* 顶栏:品牌 + 「外部接入 · 操作确认」面包屑 + 右侧 TTL 倒计时(设计稿 Th6Hv) */} +
+
+ + + {t("external_access:confirm_topbar")} +
+ {topRight} +
+
{children}
+
+ ); +} + +const cardClass = "flex w-full max-w-[520px] flex-col overflow-hidden rounded-2xl border bg-card shadow-lg"; + +const KIND_META: Record = { + enable: { icon: Power, titleKey: "external_access:confirm_enable_title", source: false }, + disable: { icon: PowerOff, titleKey: "external_access:confirm_disable_title", source: false }, + delete: { icon: Trash2, titleKey: "external_access:confirm_delete_title", source: false }, + source_disclosure: { icon: FileCode, titleKey: "external_access:confirm_source_title", source: true }, +}; + +export function ExternalAccessConfirmView({ operationId }: { operationId: string }) { + const { t } = useTranslation(["external_access", "common"]); + const [op, setOp] = useState(); + const [script, setScript] = useState