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
+
+
+
+
+
+