Skip to content

✨ 安全重构 ScriptCat MCP 桥接:分级授权、人工确认与商店构建隔离#1573

Draft
cyfung1031 wants to merge 36 commits into
scriptscat:mainfrom
cyfung1031:pr/ScriptCat-MCP-Bridge
Draft

✨ 安全重构 ScriptCat MCP 桥接:分级授权、人工确认与商店构建隔离#1573
cyfung1031 wants to merge 36 commits into
scriptscat:mainfrom
cyfung1031:pr/ScriptCat-MCP-Bridge

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes mentioned issues / 修复或回应已提及的问题(回应 feat(native-messaging): Add Native Messaging Host with MCP Server for external script management #1465 的安全、权限与商店审核风险)
  • Code reviewed by human / 代码通过人工检查
  • Automated test coverage added / 已补充自动化测试覆盖
  • CI checks added for native host and build-profile isolation / 已补充 Native Host 与构建 Profile 隔离的 CI 检查
  • Pull request CI passed / 本 PR 的 CI 已全部通过
  • Manual end-to-end smoke test completed / 已完成真实浏览器、Native Host 与 MCP 客户端的全链路冒烟测试
  • Windows installer manually verified / 已在 Windows 环境实际验证安装、卸载及回滚脚本

背景 / Background

本 PR 是对 #1465 所提出的 ScriptCat MCP / Native Messaging 能力的一次完整安全重构,而不是在原实现上追加几个校验条件。

PR #1465 首先证明了这个方向的价值:AI 助手、CLI 和其他 MCP 客户端确实可以帮助用户查看、维护和安装用户脚本。感谢 @icacaca 提出最初方案、协议与使用场景;本 PR 延续这个产品方向,并保留对原作者的明确致谢。

PR #1465 demonstrated that the product direction is valuable: AI assistants, CLI tools, and other MCP clients can help users inspect and manage userscripts. Many thanks to @icacaca for the original proposal, protocol work, and end-to-end prototype. This PR continues that direction, but redesigns the trust boundary and write path from the ground up.

#1465 的讨论同时指出了一个不能靠局部补丁解决的根本问题:原方案在 127.0.0.1:3333 暴露 HTTP/SSE 服务,并将安装、启用、禁用和删除脚本等高权限能力直接连接到扩展。即使监听地址是 localhost,这仍然构成一个本机控制面;网页、被提示词注入的 agent、同用户进程或错误配置的 MCP 客户端都有机会滥用它。主商店构建直接增加 nativeMessaging 权限,也会扩大 Chrome Web Store / Edge Add-ons 的审核与信任风险。

The blocker identified in #1465 is architectural, not cosmetic. A localhost HTTP/SSE endpoint is still a local control plane. When that endpoint can directly install or enable browser-executed code, CORS mistakes, DNS rebinding, prompt injection, a confused agent, or another same-user process can become a code-execution path. Adding nativeMessaging to normal store builds also creates an unnecessary permission and review burden for users who never use MCP.

因此,本 PR 的目标不是“让原桥接可以通过审核”,而是重新定义安全边界:

  1. 网页不能接触桥接入口
  2. 客户端必须先配对并获得最小权限
  3. 写操作永远只能提出请求,不能直接修改脚本
  4. 用户批准的必须是经过绑定和复核的确切内容
  5. 商店构建完全不包含该权限、UI 或后台集成代码
  6. 每个允许、拒绝、撤销和状态变化都有可审计记录

The design goals are therefore:

  1. web pages must have no route to the bridge;
  2. every client must pair and receive least-privilege scopes;
  3. write tools can only create requests, never mutate scripts directly;
  4. the user approves the exact content and target state that will be applied;
  5. store builds contain neither the permission nor compiled MCP integration code;
  6. allowed, denied, revoked, and transitioned operations remain auditable.

本次改动 / What changed

1. 移除 localhost HTTP 服务,改为 stdio + OS 本地 IPC

新的数据流为:

AI client (MCP stdio)
  -> scriptcat-mcp shim
  -> Unix domain socket / Windows named pipe
  -> native messaging host
  -> Chrome Native Messaging
  -> ScriptCat extension

整个功能中不再存在 HTTP 监听器、TCP 端口或 CORS 配置。这样不是“缓解”网页访问 localhost 的风险,而是从架构上删除 CORS、端口扫描和 DNS rebinding 这一整类攻击面。

The new bridge has no HTTP listener at all. MCP remains stdio-facing, while the shim and host communicate through an OS-local Unix socket or Windows named pipe. This removes the browser-to-localhost threat class by construction rather than relying on CORS correctness.

2. 引入交互式配对、挑战应答和最小权限 scope

每个 MCP 客户端在调用工具前必须完成配对:

  • 终端与 ScriptCat 同时显示 8 位验证码,用户必须核对;
  • socket 会话先完成 HMAC-SHA-256 challenge-response;
  • token 只显示一次,主机只持久化 SHA-256 hash;
  • 原始 token 不进入 URL、命令行、环境变量、日志或扩展存储;
  • scope 没有“完全访问”通配符,按能力拆分为:
    • scripts:list
    • scripts:metadata:read
    • scripts:source:read
    • scripts:install:request
    • scripts:toggle:request
    • scripts:delete:request

客户端在 tools/list 中甚至看不到未授权工具。主机先做 AuthZ,扩展再根据自己的客户端记录独立复核一次,避免单一组件被攻破后自行扩大权限。

Each client must pair interactively. Tool visibility itself is scope-filtered, and authorization is checked independently by both the native host and the extension. There is no catch-all full-access scope.

3. 所有写操作改成“两阶段请求 + 人工批准”

以下工具不再直接执行变更:

  • request_script_install
  • request_script_toggle
  • request_script_delete

它们只会创建一个有 5 分钟 TTL 的 pending operation,并返回 operationId。实际安装、启用、禁用或删除,只能在 ScriptCat 自己的 install.html / mcp_confirm.html 中由用户明确批准后执行。

Every write tool now creates a pending operation only. The agent can poll or cancel it, but cannot approve it. The actual mutation exists exclusively behind ScriptCat's human-facing approval UI.

安装脚本还有额外默认保护:即使用户批准安装,新脚本仍默认为禁用,除非用户在同一个批准界面中主动选择启用。

Approved installs are disabled by default unless the human explicitly opts in to enabling them in the same review surface.

4. 通过内容 hash 和目标状态复核解决 TOCTOU

pending operation 会绑定:

  • 待安装代码的 contentHash
  • 目标脚本请求时的 existingCodeHash
  • 请求客户端;
  • 操作类型和参数;
  • 过期时间。

在用户点击批准的最后一刻,扩展会重新验证:

  • 操作仍处于 awaiting_user 且未过期;
  • staged code 仍与用户审查时的 hash 一致;
  • enable / disable / delete 的目标脚本仍存在,且代码没有在等待期间变化;
  • 客户端尚未被撤销;
  • 决策只能执行一次。

这避免了“用户看到 A,实际执行 B”或等待批准期间目标脚本已变化的竞态。

The approval path re-verifies the exact staged content and target script state immediately before mutation. This closes the review-to-execution TOCTOU gap: the user cannot be shown one payload while another is applied.

5. 将源码读取与普通元数据读取分开

脚本源码可能包含 API key、私有端点或商业逻辑,因此 scripts:source:read 不与普通 list / metadata 权限混在一起:

  • 配对时默认不勾选;
  • 即使客户端拥有该 scope,每个客户端首次读取每个脚本的源码,仍需要用户选择:拒绝、仅允许一次、或永久允许该客户端读取此脚本;
  • agent 收到的脚本字段均使用 structured JSON 和 contentTrust 标记,不把用户脚本控制的内容拼进 Markdown、工具描述或提示文本。

Source disclosure is treated separately from metadata. It is off by default, and the first source read per client/script requires an additional disclosure decision. Script-controlled text is returned only as structured, trust-tagged data, never interpolated into tool descriptions or Markdown.

6. 开发者构建专用,商店构建在产物层面强制排除

nativeMessaging 虽然保留在源 manifest 中供 developer profile 使用,但打包流程会:

  • store-stable / store-beta 移除 nativeMessaging
  • 不生成 MCP 确认页面入口;
  • 使用模块替换移除 Tools 页的 MCP UI 实现,而不只依赖运行时隐藏;
  • 扫描构建产物,若商店 profile 中仍出现 native host 集成字符串则直接构建失败;
  • 在 CI 中分别打包 store-stable、store-beta 和 developer profile 验证以上约束。

The feature is developer-build only. Store profiles remove the permission and compile out the UI/background integration. Packaging fails loudly if MCP native-host code leaks into a store artifact. This is a build-output guarantee, not merely a runtime flag.

这直接回应了 #1465 中最重要的商店审核担忧:普通商店用户不会因为此功能获得额外权限,也不会收到隐藏但仍编译存在的桥接代码。

This directly addresses the store-review concern raised in #1465: normal store users receive neither the permission nor dormant compiled bridge code.

7. 安装器不再提交机器相关路径或固定扩展 ID

新增 macOS/Linux 与 Windows 安装/卸载脚本:

  • manifest.template.json 在安装时生成真实 native host manifest;
  • 写入实际 Node 路径和用户提供的扩展 ID;
  • allowed_origins 使用精确 extension ID,不使用通配;
  • 支持版本并存、rollback 和卸载;
  • 对配置目录、socket 和凭据文件设置用户级权限;
  • 主机启动时再次验证调用 origin。

Installers now generate the native-host manifest at install time. No developer-specific absolute path or hardcoded extension origin is committed. The runtime additionally validates the launching extension origin.

8. 审计、限流、撤销和紧急停止

新增:

  • 每客户端读取与写请求限流;
  • 配对尝试和认证失败限流;
  • 500 条 extension-side audit ring buffer;
  • 按客户端筛选、清除和导出 JSON;
  • 单客户端即时撤销;
  • “撤销全部客户端并停止桥接”的 kill switch;
  • 关闭运行时开关即断开 native messaging port。

The bridge adds rate limits, source-free audit events, immediate per-client revocation, and a one-click revoke-all-and-stop kill switch.

9. 补齐协议、威胁模型、使用指南和商店审核说明

新增并交叉链接:

  • packages/native-messaging-host/PROTOCOL.md
  • packages/native-messaging-host/THREAT-MODEL.md
  • packages/native-messaging-host/README.md
  • packages/native-messaging-host/README_zh-CN.md
  • docs/mcp-bridge-guide.md
  • docs/mcp-bridge-guide_zh-CN.md
  • docs/store-review/mcp.md

文档分别说明协议、资产/攻击者/残余风险、安装配对流程、权限表、用户同意界面、token 处理、撤销、kill switch 与已知限制,避免把安全判断只留在代码审查者脑中。

The PR documents the protocol, threat model, operational guide, consent surfaces, store-build exclusion, residual risks, and known follow-ups in both English and Simplified Chinese where most useful.

为什么这比 #1465 更安全 / Why this is safer than #1465

关注点 #1465 本 PR
对外入口 localhost HTTP/SSE :3333 MCP stdio + OS-local socket/pipe;无 HTTP/TCP listener
客户端认证 无明确认证模型 交互式配对、验证码、challenge-response、hashed token
权限模型 连接后可调用全部操作 最小权限 scopes;工具目录按 scope 过滤;主机与扩展双重检查
安装/启停/删除 外部请求可直接执行 只创建 pending request;每次必须在 ScriptCat UI 人工批准
安装后状态 可直接进入可运行状态 默认 disabled;启用需用户明确选择
TOCTOU 未绑定用户审查内容 contentHash / existingCodeHash 在批准时重新验证
源码隐私 get_script 直接返回源码 source 独立 scope + 每客户端/每脚本首次披露批准
Prompt injection 脚本数据可进入 agent 上下文 structured JSON + contentTrust;工具描述为静态常量
商店权限 主 manifest 加 nativeMessaging store profile 移除权限并编译排除代码;产物断言 + CI
原生主机清单 含环境特定路径/来源 安装时生成;精确 origin;多平台安装/卸载/rollback
追踪与止损 无完整审计/撤销模型 audit log、限流、即时撤销、revoke-all kill switch

这并不表示本 PR 能防御“同一操作系统用户账户已经完全失陷”的情况。威胁模型明确记录:同用户恶意进程最终仍可能读取客户端凭据文件或调试浏览器。这里的目标是阻止网页直接访问、未经配对客户端、越权客户端、被提示词注入的合法 agent,以及在用户没有看到并批准确切操作时发生脚本变更。

This PR does not claim to defend an already-compromised OS user account. A same-user malicious process may ultimately read the shim credential file or debug the browser. The intended boundary is narrower and explicit: no web-page entry point, no unauthenticated client, no scope escalation, and no script mutation without the human reviewing and approving the exact operation.

实现考虑 / Design considerations

为什么不用“localhost + token”作为最小修改?

因为 token 只能解决部分未认证访问,不能删除浏览器可到达本机 HTTP 服务所带来的 CORS、DNS rebinding、端口探测与错误暴露风险。既然 MCP 客户端天然支持 stdio,就没有必要保留一个网页协议入口。

A bearer token on localhost would reduce unauthenticated access but would retain the browser-reachable HTTP attack surface. Since MCP clients already support stdio, keeping HTTP provides risk without a necessary product benefit.

为什么写 scope 仍不能直接写?

scope 表示“允许客户端提出这类请求”,不是“允许 agent 代替用户最终决定”。AI agent 可能被网页内容提示词注入,也可能错误理解上下文。对于会让代码进入浏览器执行环境的操作,长期 token 不应等同于长期执行授权。

A write scope authorizes requesting a capability, not exercising final authority. Agents can be prompt-injected or simply wrong; a durable token must not become durable permission to install browser-executed code.

为什么源码读取也需要额外批准?

元数据和源代码的敏感级别不同。脚本名称、类型和启用状态适合低权限自动化;完整源码可能包含密钥和私有逻辑。将两者拆开可以让只需要 inventory 的客户端保持最小权限。

Metadata and full source have different confidentiality levels. Separating them allows inventory clients to operate without receiving secrets or proprietary code.

为什么使用独立 native-host package 和独立 lockfile?

native host 是在扩展外运行的独立可信组件,运行时依赖与浏览器 bundle 不同。独立 package 让依赖面、Node 版本、构建、测试和分发边界更清楚,也便于精确 pin MCP SDK / zod 并单独执行跨平台 CI。

The host is a separately executed trusted component with a different runtime and distribution boundary from the extension. A standalone package makes its dependency, build, test, and release surface explicit.

为什么 store profile 要做“编译排除”而不只隐藏 UI?

运行时条件隐藏无法保证 bundler 不把代码和字符串放进共享 chunk。商店审核与权限承诺应该针对最终产物,因此本 PR 使用模块替换、manifest 处理、产物扫描和 CI pack assertions 建立可验证的不变量。

Runtime hiding is insufficient because bundlers may retain code in shared chunks. Store guarantees must be made against the produced artifact, so this PR combines module replacement, manifest transformation, artifact scanning, and CI assertions.

测试 / Tests

本分支新增或扩展了以下自动化覆盖:

  • native messaging framing、channel 与 origin 验证;
  • socket / named-pipe IPC;
  • pairing、challenge-response、token store 与 scope gate;
  • rate limiting、session ownership 与 revocation;
  • strict MCP tool input schemas 与协议 conformance;
  • URL 安装策略(scheme、私网/loopback、redirect、大小限制);
  • pending operation、用户批准、过期、单次决策和 TOCTOU conflict;
  • source disclosure:deny / allow once / allow for client;
  • bridge/controller/service worker 生命周期与运行时开关;
  • 配对、批准、Tools 设置和安装 banner UI;
  • build profile / manifest / compiled-output assertions;
  • native host 在 Ubuntu、macOS、Windows 的 build/test CI matrix。

Automated coverage includes native framing, IPC, authentication, scopes, sessions, rate limits, strict schemas, URL policy, approval/TOCTOU behavior, source disclosure, lifecycle behavior, UI flows, and build-profile assertions. The native-host package is configured to build and test on Linux, macOS, and Windows in CI.

尚待完成 / Still required before ready for merge

  • 本 PR 的 GitHub Actions 全部通过;
  • maintainer / human review of all modified files;
  • 使用真实 Chrome developer build、真实安装的 native host 和真实 MCP client 完成手工端到端 smoke test;
  • 实际执行并验证 Windows install.ps1 / uninstall.ps1 / rollback 流程;
  • 为配对、安装确认、删除长按确认、撤销和 kill switch 补充截图或演示录屏。

The PR intentionally does not claim that the real-browser/manual and Windows-installer verification has already happened. These remain explicit pre-merge review items.

已知限制 / Known limitations

  • 当前仅支持 Chromium 的 connectNative 路径;Firefox event-page 生命周期尚未验证,UI 与 controller 会明确隐藏/跳过;
  • native host 目前以 Node.js package 形式分发,signed binary / single-file packaging 留待后续;
  • 扩展层无法获得可靠 DNS resolution API,因此 URL 策略能拒绝字面 private/loopback 地址并逐跳检查 redirect,但不能完全阻止“公开 hostname 在请求时解析到私网”的 DNS rebinding;
  • 同一 OS 用户账户完全失陷不在防御范围内;
  • 当前 PR 提供 store-review 说明材料,但并不代表实际商店提交已经完成。

建议审查重点 / Suggested review focus

  • 是否接受“write scope 只允许 request、每次写入必须人工批准”的权限语义;
  • host-side 和 extension-side 双重 scope / client / session 检查是否完整;
  • approval 时的 contentHash / existingCodeHash / TTL / single-shot 不变量;
  • source disclosure 的 once/permanent grant 是否存在绕过路径;
  • store-stable / store-beta 的最终产物是否确实不含权限、UI 和 native-host integration code;
  • IPC、token 文件、配置目录与日志中是否仍有 secret 泄漏可能;
  • URL policy 的 redirect、size cap 与 private-target 限制是否符合预期;
  • UI 是否清楚表达“agent 只能请求,用户才有最终执行权”。

参考 / References

再次感谢 @icacaca#1465 中完成的探索。这个 PR 并不是否定原方向,而是把原型中已证明有价值的能力,放进一个更适合浏览器扩展、AI agent 和应用商店分发场景的安全模型里。

Thanks again to @icacaca for the exploration in #1465. This PR does not reject the original direction; it takes the useful capability demonstrated by that prototype and places it behind a trust model suitable for a browser extension, AI agents, and store distribution.

icaca and others added 30 commits May 24, 2026 10:42
… external script management

- Add NativeMessageHandler in Service Worker to handle Native Messaging connections
  - Supports 6 operations: list_scripts, get_script, install_script, uninstall_script, enable_script, disable_script
  - Proactively connects to native host on startup via chrome.runtime.connectNative()
  - Handles bidirectional message passing with proper error handling

- Add packages/native-messaging-host: unified Native Host + MCP Server process
  - NativeHost: stdio protocol (4-byte LE length-prefixed JSON) for browser communication
  - MCP Server: HTTP+SSE transport on port 3333 for AI/CLI integration
  - Internal message bus via EventEmitter for bridging both protocols
  - Port conflict handling (EADDRINUSE graceful skip)

- Add ScriptService.getScriptAndCode() for retrieving script metadata + source code

- Add nativeMessaging permission to manifest.json

- Add PROTOCOL.md: complete JSON message protocol documentation

This enables external tools (AI assistants, CLI tools) to manage ScriptCat
user scripts through the MCP protocol, while the native messaging bridge
maintains secure communication with the browser extension.
The pr/secure-MCP prelim combined an unauthenticated loopback HTTP+SSE
MCP server with direct installByUrl/installByCode/deleteScript/
enableScript calls from inbound native-messaging requests, a hardcoded
Windows manifest path, and an unconditional connectNative() on every
service worker init. Per the security redesign in
workspace/.ref-docs/00-README.md, this is being replaced (not patched)
by a stdio MCP bridge with OS-IPC transport, two-phase human-approved
writes, and build/runtime gating — landing in following commits.

Removes packages/native-messaging-host/{src/index.ts,manifest.json,
install.ps1,launch.js,launch.mjs,PROTOCOL.md} and
src/app/service/service_worker/native_msg.ts, plus the unconditional
NativeMessageHandler wiring in service_worker/index.ts. The
nativeMessaging permission stays in src/manifest.json (kept in source,
stripped by pack.js per build profile — matches the existing debugger/
agent pattern) and packages/native-messaging-host/package.json is left
in place, to be rewritten when the host package is reconstructed.
Mirrors the existing EnableAgent build-gate pattern, but with reversed
polarity: EnableMCP (src/app/const.ts) defaults off even on local
developer builds, requiring an explicit SC_ENABLE_MCP=true. This is
the first of the two gates required by the security redesign in
workspace/.ref-docs/01-implementation-plan.md D3 — the second,
runtime opt-in gate is mcp_enabled below.

scripts/build-config.js gains resolveMcpEnabled/applyMcpManifest
alongside the existing agent equivalents: resolveMcpEnabled derives
the flag from build profile (developer -> on, store-* -> off) unless
SC_ENABLE_MCP explicitly overrides it; applyMcpManifest strips the
nativeMessaging permission when disabled. Deliberately does not
special-case "store profile with explicit override" here — that
combination is caught as a hard build-time assertion in scripts/pack.js
(store artifacts must never contain nativeMessaging), landing in a
later commit once there is MCP code for the assertion to guard.

Adds the runtime opt-in flag mcp_enabled: registered in
STORAGE_LOCAL_KEYS (device-local, never chrome.storage.sync, matching
enable_script/vscode_url) with SystemConfig.getMcpEnabled/
setMcpEnabled (default false).
Adds the normative wire protocol for the MCP bridge (workspace/.ref-
docs/03-protocol-spec.md §2-3, gitignored reference doc):
packages/native-messaging-host/src/shared/protocol.ts is the canonical
source — native-messaging envelope types, the McpBridgeRequest/
McpBridgeResponse envelope, the 6 scopes, 9 bridge actions, 12 error
codes, operation kinds/statuses, and per-action input/result schemas.

src/app/service/service_worker/mcp/types.ts is an independently
maintained mirror, not an import: the host package is standalone (own
lockfile, own CI job, not a pnpm workspace member) so it can't share a
build graph with the extension. protocol.conformance.test.ts guards
against the two copies drifting apart by comparing their literal
unions and the action->scope mapping; verified the test actually
catches drift by temporarily injecting a mismatched action into the
extension copy and confirming the test failed, then reverting.

WS-0 in workspace/.ref-docs/01-implementation-plan.md §3 — blocks the
extension services and native host work landing in following commits.
Implements the extension-side half of WS-A (workspace/.ref-docs/
05-extension-implementation.md §2-4), TDD-first with Chinese BDD
titles per repo convention. McpController + service_worker/index.ts
wiring land in a follow-up commit — this one is self-contained and
independently testable.

- src/app/repo/mcp.ts: McpClientDAO / McpOperationDAO / McpAuditDAO
  (Repo<T> pattern, entity+DAO colocated per convention).
  McpAuditDAO.append prunes to a 500-event ring buffer.
- mcp/url_policy.ts: validateInstallUrl + fetchInstallSourceWithPolicy
  (doc 04 §5) — https-only, rejects embedded credentials and
  syntactically local/private/loopback/link-local/multicast targets,
  2 MiB stream-abort cap. Documented residual limitation: the Fetch
  API forces redirect:"manual" responses to be opaque, so true
  per-hop redirect revalidation isn't achievable from an extension
  service worker; this validates the initial and final (post-redirect)
  URL instead (doc 04 §2 asset A3 residual risk).
- mcp/errors.ts: McpBridgeError, the stable-code error type threaded
  through approval and bridge.
- mcp/approval.ts: McpApprovalService owns the McpOperation lifecycle
  and every doc 04 §4 TOCTOU invariant — re-verifies staged/target
  code hashes immediately before mutation, single-shot decisions,
  installs always start disabled, request idempotency, lazy expiry
  sweep, per-client ownership on get/list/cancel. Depends on a narrow
  McpScriptMutator interface (install/enable/delete only), not the
  full ScriptService. Extends InstallSource with "mcp" and ScriptInfo
  with an optional `mcp` staging marker (same extension point the
  skillScript flow uses at script.ts:957-966).
- mcp/bridge.ts: McpBridge — strict manual allow-list input validation
  per action (unknown fields and malformed UUIDs rejected), re-checks
  scope from McpClientDAO independent of whatever the host already
  checked (doc 04 §3 defense in depth), gates write actions on the
  session write flag, dispatches reads directly and writes through
  McpApprovalService, writes exactly one audit event per request.
  Script-derived strings (name/description) pass through untouched as
  JSON fields, never formatted into prose — verified with a test using
  a script named "Ignore previous instructions..." as the injection
  probe.
- sha256OfText added to pkg/utils/crypto.ts (existing crypto-js dep).

pnpm typecheck clean; full suite green (3179 tests, up from 3161).
…wiring

Completes the extension-side half of WS-A (workspace/.ref-docs/05-
extension-implementation.md §4.2, §4.5-4.6). MCP is now fully wired
behind EnableMCP + mcp_enabled, mirroring the removed prelim's
connectNative-availability check.

- protocol.ts / mcp/types.ts: added the "hello" native-message type
  (doc 03 §6 versioning describes the host announcing its version but
  the layer-1 message table in the same doc omitted it — added to
  both copies, conformance test re-verified green).
- mcp/controller.ts: McpController owns the chrome.runtime.connectNative
  port lifecycle — connects only when mcp_enabled flips true, capped
  exponential backoff (1s*2^n, 60s cap, 5 attempts) before giving up
  at status "host_unreachable", routes hello/ping/bridge.request,
  refuses to dispatch bridge calls below MIN_HOST_VERSION (status
  "host_outdated"), and owns the session-only write-mode flag in
  chrome.storage.session (never SystemConfig, so it never survives a
  restart).
- mcp/service.ts: McpUIService, the page-facing Group endpoints
  (status/setWriteSession/clients/revokeClient/revokeAllAndStop/
  operation/operationDecision/audit/auditClear). Deliberately omits
  setEnabled (mcp_enabled already flows through the generic
  SystemConfig get/set every other device-local setting uses) and
  auditExport (would just re-serialize what `audit` already returns)
  to avoid duplicate endpoints; pairingDecision is deferred to the
  commit that adds the pairing dialog, since nothing calls it yet.
- client.ts: MCPClient, mirroring PermissionClient's pattern. Named
  distinctly in a comment from the pre-existing unrelated AgentClient
  .mcpApi (ScriptCat's agent acting as an MCP *client* of external
  servers — the opposite direction from this feature, which makes
  ScriptCat itself an MCP *server*).
- approval.ts: getOperationForUI — like getOperation but without the
  clientId ownership gate, for the human-facing approval pages that
  reach an operation only via a URL the extension itself generated.
- bridge.ts: setWriteSessionChecker setter, so McpController and
  McpBridge can be constructed without a circular reference (each
  needs the other) while keeping both `const` at the call site.
- index.ts: constructs McpClientDAO/McpApprovalService/McpBridge/
  McpController/McpUIService behind
  `EnableMCP && typeof chrome.runtime?.connectNative === "function"`.

Verified: pnpm typecheck clean; full suite green (3197 tests, up from
3179); `pnpm build` and `SC_ENABLE_MCP=true pnpm build` both compile
cleanly (only pre-existing monaco-worker warnings, unrelated).
…ules

Starts WS-B (workspace/.ref-docs/06-native-host-and-installers.md):
the standalone packages/native-messaging-host package, own lockfile,
own pnpm install/build/test, not part of the root pnpm workspace or
the extension bundle (doc 06 §7). Adds @modelcontextprotocol/sdk
(1.29.0, exact pin, latest v1 production line) and zod (3.25.76,
exact pin, SDK peer) as this package's own dependencies — the root
extension's dependency tree is untouched.

Excludes the package from the root tsconfig.json and vitest.config.ts:
it uses ESM "bundler" module resolution (no .js extensions on
relative imports) where the root project uses "nodenext" (requires
them), and its tests need its own local node_modules for the SDK/zod
— running them from the root suite would break on a fresh clone
before anyone runs `pnpm install` inside the package. Root eslint
still covers it directly (`pnpm exec eslint packages/native-messaging-
host/src/` — verified clean), matching doc 06 §7's "lint only" line.

Core security modules landing in this commit, each with real tests
(69 total, package runs standalone via `cd packages/native-messaging-
host && pnpm test`):

- shared/limits.ts: doc 04 §7 rate/size constants; resolveLimits only
  allows a config override to tighten a limit, never loosen it.
- shared/logging.ts: stderr-only structured logger (doc 04 §10 "stdout
  is exclusively the native-messaging channel") + URL/secret redaction
  helpers (doc 04 §8-9 — tokens and query-string credentials never
  reach a log line).
- shared/config.ts: per-platform config dir resolution, symlink-
  resolved group/world-writable rejection (doc 04 §8), atomic
  temp-file-then-rename writes with 0600 perms.
- native/framing.ts: the 4-byte-LE native-messaging frame codec.
  Explicit regression test against the prelim's `buf = Buffer.alloc(0)`
  bug on oversize messages, which discarded buffered bytes belonging
  to the NEXT message and permanently desynchronized the stream (doc
  03 §2, doc 08 §6) — this decoder drops only the oversize message and
  stays aligned, and switches to a streaming-skip mode instead of
  buffering a multi-chunk oversize body in memory.
- native/origin.ts: exact-match caller-origin verification against
  Chrome's argv-passed chrome-extension:// origin (doc 04 §3 A6).
- auth/scopes.ts, token-store.ts, pairing.ts: scope visibility
  filtering for tools/list, the hashed-token client registry (raw
  token never persisted — verified in a test that greps the persisted
  file for it), and the pairing state machine (8-char code from an
  unambiguous alphabet, 2 min TTL, 3/hour global rate limit, 1 pending
  per connection).

Verified: package's own `pnpm test` (69/69) and `tsc --noEmit` clean;
root `pnpm typecheck` and `pnpm vitest run` (296 files / 3197 tests)
unaffected and clean; root `pnpm exec eslint
packages/native-messaging-host/src/` clean.
…lockout)

Adds packages/native-messaging-host/src/broker/rate-limit.ts, the
three doc 04 §7 limiter primitives the broker will apply per client
connection: WindowedRateLimiter (read 60/min, write 10/hour),
ConcurrencyLimiter (4 in-flight calls/client), and AuthFailureLockout
(3 failures/min/endpoint -> 5 min lockout). Kept as three focused
single-purpose classes rather than one generic configurable bucket,
matching the distinct units doc 04 §7 specifies.

12 new tests (84/84 package total); package's own tsc --noEmit,
prettier, and root eslint all clean.
…n state machine

Adds the pieces that turn the auth/rate-limit primitives from the
previous two commits into an actually-running broker (doc: workspace/
.ref-docs/06-native-host-and-installers.md §1, doc 03 §4):

- auth/challenge.ts: HMAC-SHA-256 challenge-response. The host only
  ever persists tokenHash = SHA-256(token) (doc 04 §8), never the raw
  token, so the MAC is keyed on tokenHash rather than the token itself
  — HMAC_SHA256(key=tokenHash, nonce + "|" + endpointName). This
  reconciles an ambiguity between doc 03 §4 (which shows
  HMAC_SHA256(token, ...)) and doc 04 §8 (host never stores the raw
  token): a verifier that only has tokenHash cannot compute an HMAC
  keyed on the raw token, so tokenHash-as-key is the only construction
  that satisfies both constraints simultaneously. Documented inline.
  Endpoint name bound into the MAC blocks replay against a different
  socket.
- broker/messages.ts: Layer-2 shim<->host message shapes (doc 03 §4)
  — internal to this package, not mirrored to the extension.
- broker/ipc.ts: creates the Unix domain socket (POSIX) / named pipe
  (Windows) endpoint with a random name, chmod 600 on POSIX. Peer-UID
  verification (SO_PEERCRED) has no portable Node core API without a
  native addon, so it's not implemented — documented as a residual
  limitation; the enforced boundary is filesystem permissions (0700
  containing directory + 0600 socket file) instead.
- broker/session.ts: the per-connection protocol state machine —
  hello/challenge/auth/ready handshake, pairing (delegates to
  PairingManager), and steady-state call dispatch gated by scope
  (checked by the caller), write-session, and the rate/concurrency
  limiters from the previous commit.
- broker/server.ts: accepts connections on an IpcEndpoint, decodes the
  doc 03 §4 line-delimited JSON framing (max 4 MiB/line), and routes
  parsed messages into a SessionHandler per connection — same
  don't-desynchronize-the-stream discipline as native/framing.ts, for
  the socket side of the protocol.

44 new tests (116/116 package total), including end-to-end handshake
and call dispatch over a real Unix domain socket (this dev machine is
POSIX; Windows-only paths are behind `describe.skipIf` and exercised
by the Windows leg of the CI matrix once that job exists). Package's
own tsc --noEmit clean; root pnpm typecheck and root eslint over the
package both clean.
- native/channel.ts: host-side duplex channel over native messaging
  (doc 03 §2, doc 02 §6). request()/response correlation on top of the
  framing codec — random (not sequential) requestId, bounded pending
  map with per-request 30s timeout, rejectAllPending() so a closed
  stdin doesn't leave callers hanging forever. Unsolicited
  extension-initiated messages (pair.decision, client.revoke,
  operations.changed, pong) flow to onMessage listeners.
- shim/socket-client.ts: shim-side counterpart to broker/session.ts —
  connects to the broker's socket, does the hello/challenge/auth
  handshake (or pair for first-run), then call()/requestPairing(). All
  incoming bytes flow through exactly one line-buffer + dispatch path;
  originally wrote authenticate() with a second raw socket listener
  and caught it in review before committing — a single TCP chunk
  spanning a handshake message and the start of the next message
  would have been parsed by two independent buffers, risking dropped
  or duplicated bytes at the boundary. Reworked to have authenticate()
  observe handshake messages through the same onEvent dispatch every
  other message type uses.

14 new tests (130/130 package total), including true end-to-end
coverage: SocketClient talking to a real BrokerServer over a real Unix
domain socket for the full handshake, auth-failure, unauthenticated-
call, pairing, and concurrent-non-interleaving-calls cases. Package's
own tsc --noEmit clean; root eslint over the package clean.
…ring

Completes WS-B's shim layer (doc: workspace/.ref-docs/06-native-host-
and-installers.md §1, doc 03 §5) on the official
@modelcontextprotocol/sdk rather than hand-rolled JSON-RPC:

- shim/tools.ts: the full tool catalog with zod .strict() input
  schemas mirroring doc 03 §3 exactly (unknown fields rejected),
  scope-filtered visibility (visibleTools), compile-time-constant
  descriptions where every write tool states the human-approval
  contract up front, and toToolResult — the structured
  content/structuredContent wrapper that replaces the prelim's
  Markdown-templated executeToolCall (the injection vector doc 04 §6
  targets). Verified with an explicit injection probe: a script named
  "Ignore all previous instructions..." passes through as an untouched
  JSON string, never formatted into prose.
- shim/resources.ts: scriptcat://scripts/<uuid>/source URI build/parse,
  independent of the SDK's ResourceTemplate wiring so it's directly
  testable.
- shim/server.ts: buildMcpServer wires McpServer + tool registration +
  the source resource (only when scripts:source:read is granted) onto
  the real SDK types. callBridge correlates through SocketClient.call.

19 new tests (155/155 package total). Package's own tsc --noEmit
clean against the real SDK types; root eslint over the package clean.
Deep protocol-level tools/list dispatch is the SDK's own
responsibility (requires a connected Transport) and intentionally not
re-tested here — this package owns and verifies schema validation,
scope filtering, description content, and injection-safe output
shaping, all independent of the transport.
Wires everything built so far into the two actual binaries doc 06 §1
declares (scriptcat-native-host, scriptcat-mcp):

- shared/host-config.ts / shared/shim-config.ts: config.json and
  credentials.json read/write on top of shared/config.ts's atomic
  writer, plus path helpers (doc 06 §2).
- broker/pairing-decision.ts: extracted the pair.decision handling
  (mint token on approval, persist to TokenStore, resolvePairing) into
  its own testable module rather than burying it in the CLI
  entrypoint — host.ts is now just wiring. Caught and fixed a test-
  fixture bug while writing this: the mock session didn't replicate
  SessionHandler's real side effect of resolving the pairing in the
  shared PairingManager, which silently let a pairingId "survive" a
  second approval in the test but never in production.
- host.ts: origin verification -> load token store -> create IPC
  endpoint -> publish its name to config.json -> native channel on
  stdio -> BrokerServer wired to dispatch through the channel ->
  20s ping keepalive -> graceful shutdown on stdin close or
  bridge.shutdown. `--doctor` diagnostic mode.
- shim.ts: `--pair` flow (prints the verification code, saves
  credentials on approval) and the normal run path (load credentials,
  discover the endpoint, authenticate, build and connect the MCP
  server over its own stdio).

Fixed a real bug surfaced by actually running the built output:
package.json declares "type": "module" (doc 06 §1), but
moduleResolution "bundler" doesn't require or add the .js extensions
Node's ESM loader needs on relative imports — `node dist/host.js`
failed immediately with ERR_MODULE_NOT_FOUND. Switched to "nodenext"
(matching "module": "nodenext") and added .js to every relative
production import; several implicit-`any` errors elsewhere turned out
to be cascading from the same broken resolution and disappeared once
fixed. Verified by actually building and running both binaries:
`node dist/host.js --doctor` and `node dist/shim.js` both produce the
correct real output (confirmed, then cleaned up, the incidental
directories they created outside the repo during that verification).

8 new tests (168/168 package total). Package's own tsc --noEmit
clean; root pnpm typecheck and root eslint over the package both
clean.
Completes WS-B's installer surface (doc: workspace/.ref-docs/06-
native-host-and-installers.md §5):

- manifest.template.json: committed template with empty path/
  allowed_origins — the prelim's committed manifest had a hardcoded
  C:\Users\Administrator\... path and a BOM; this stays a template,
  the real manifest is generated at install time.
- installers/lib/manifest-gen.ts: typed manifest generation (object ->
  JSON.stringify, never string replacement). Strictly validates every
  extension ID against ^[a-p]{32}$ — the prelim's default
  "fomrtutthjerocmw" is not a valid ID and is explicitly tested as
  rejected, so an installer can't reproduce that mistake. No BOM,
  trailing newline, allowed_origins never contains a wildcard.
- host.ts: `--print-manifest --extension-id <id>... --host-path
  <path>` prints the generated manifest JSON for the installer scripts
  to consume; verified against real output.
- installers/install.sh + uninstall.sh (macOS/Linux): copies versioned
  files, pins the resolved node binary's absolute path in a launcher
  script (PATH-hijack guard, doc 06 §6), generates the manifest via
  the typed generator (not string replacement), atomic write (temp +
  rename) into each browser's NativeMessagingHosts directory,
  verifies by re-reading and parsing, also writes the same extension
  origins into the host's own config.json (doc 04 §3 defense in depth
  — the host never trusts the registered manifest's allowed_origins
  alone), writes install-metadata.json for uninstall. Syntax-checked
  with `bash -n` (clean); not executed against this dev machine's real
  browser registrations, since that would modify actual system state
  outside the repo.
- installers/install.ps1 + uninstall.ps1 (Windows): registry keys
  under HKCU per browser (Chrome/Edge/Chromium/Brave), icacls to
  restrict the config dir to the current user, same launcher-pinning
  and typed-manifest-generation approach as the POSIX script. No
  PowerShell interpreter is available in this environment to execute
  or syntax-check it — written carefully against the doc 06 §5 spec
  and reviewed, but unverified by execution; flagging this honestly
  rather than claiming a check that didn't happen. The Windows leg of
  the CI matrix (not yet wired) is where this gets real verification.

11 new tests (184/184 package total, all in manifest-gen.ts — the
shell/PowerShell scripts themselves aren't unit-testable in this
environment). Package's own tsc --noEmit clean; root pnpm typecheck
and root eslint over the package both clean; verified --print-manifest
against real built output.
… bug fix

Adds the MCP Bridge settings card (workspace/.ref-docs/07-ux-spec.md
§1-2, §4, §6) and its i18n, and fixes a real gap discovered while
verifying store-build cleanliness end-to-end rather than assuming it.

- src/locales/{en-US,zh-CN}/mcp.json + registration in each locale's
  index.ts and locales.ts's NS array — mirrors the existing agent.json
  precedent (ships in every build; other locales via docs/
  translation.md workflow, not touched here since only en-US/zh-CN are
  authored this PR per doc 05 §6).
- McpSection.tsx: status pill (off/connecting/connected/host_unreachable/
  host_outdated), first-enable warning dialog before mcp_enabled flips
  true, write-session switch, paired-client list with per-client
  Popconfirm-gated revoke, audit log list with client-side JSON export
  and Popconfirm-gated clear, and the emergency "revoke all & stop"
  action. Registered into Tools/index.tsx and the sidebar category list
  behind EnableMCP, matching how EnableAgent gates AgentMenu.
- 8 new tests mirroring DevToolsSection.test.tsx's mocking convention.

While verifying the store-build exclusion claim end-to-end (not just
trusting the doc's "tree-shaking removes the mcp/ service graph" line)
found two real gaps:

1. rspack.config.ts's DefinePlugin never had an entry for
   process.env.SC_ENABLE_MCP (only SC_DISABLE_AGENT existed) — so
   EnableMCP was never actually a build-time constant in the bundle;
   `SC_ENABLE_MCP=true` vs default builds were byte-identical for this
   flag. Added the matching DefinePlugin entry.
2. Even with that fixed, JSX-conditional tree-shaking across module
   boundaries (`{EnableMCP && <McpSection/>}`) did not eliminate
   McpSection's code from the shared options-page chunk — confirmed by
   grepping the actual built output before and after. Rather than
   trust minifier behavior further, added a NormalModuleReplacementPlugin
   that deterministically swaps McpSection.tsx for a trivial stub
   (McpSection.stub.tsx) at module-resolution time when MCP is
   disabled, so the real component and its transitive MCPClient/mcp-
   repo-type imports are never compiled into a non-MCP build at all.

Verified with concrete build evidence, not assumption: default
`pnpm build` now has zero occurrences of MCP UI strings in dist/ext;
`SC_ENABLE_MCP=true pnpm build` has them. Full suite green (297 files /
3205 tests); pnpm typecheck and eslint over the touched files clean.
Completes the packaging half of workspace/.ref-docs/05-extension-
implementation.md §1.3 and doc 08 §5.

- scripts/pack.js: --profile <store-stable|store-beta|developer> (env
  SC_PACK_PROFILE, default store-stable; new `pnpm pack:dev` script
  sets developer + SC_ENABLE_MCP=true for local convenience). Threads
  SC_ENABLE_MCP into the child build exactly like SC_DISABLE_AGENT
  already is. Applies applyMcpManifest beside applyAgentManifest for
  both Chrome and Firefox manifests — Firefox stays MCP-disabled
  unconditionally this PR, matching doc 01's non-goal.
- scripts/build-config.js: checkMcpPackProfileCompliance, a pure,
  fully unit-tested decision function (store profiles must have
  neither the nativeMessaging permission nor MCP code compiled into
  the bundle; developer-with-MCP-enabled must have both). pack.js
  itself only does the I/O (scanning the built dist/ext .js files for
  the literal string "com.scriptcat.native_host" — a minification-
  resistant proxy for "MCP host-integration code actually got
  compiled in", not just present in source).

Did not execute the full pack.js against this working tree to verify
end-to-end: it rewrites src/manifest.json's version field to match
package.json (a file this task has no reason to touch) and needs a
signing key that isn't present here. Instead verified the two things
that actually matter can be checked independently: the decision logic
via the new unit tests (8 cases covering all three profiles x
compliant/non-compliant), and the underlying signal it depends on via
the concrete build-output greps already done in the previous commit
(zero occurrences of MCP strings in a default build, one occurrence
with SC_ENABLE_MCP=true) — the same DefinePlugin fix from that commit
is exactly what checkMcpPackProfileCompliance's nativeHostCompiledIn
check is designed to catch a regression of.

34 total build-config tests green; pnpm typecheck and full vitest
suite (297 files / 3212 tests) both clean.
Extends the existing install.html flow to carry MCP-sourced install
requests through to human approval (doc: workspace/.ref-docs/
05-extension-implementation.md §5.1, doc 07 §5, doc 04 §4).

- store/features/script.ts: mcpClient = new MCPClient(message),
  alongside the existing scriptClient/subscribeClient/agentClient.
- useInstallData.ts: InstallView.mcp carries the staged
  {operationId, requestingClientName, contentHash} through to the
  page. install() branches on info.mcp — for MCP-sourced requests it
  calls mcpClient.decideOperation({approved: true, enable}) and never
  calls scriptClient.install() directly, keeping the actual mutation
  server-side in McpApprovalService.decide (which re-verifies the
  staged code hash immediately before installing — the TOCTOU
  invariant only holds if the page never performs the install itself).
  New rejectMcp() sends decideOperation({approved: false}) — kept
  separate from the existing close(), which continues to make no
  decision at all (doc 04 §4 invariant 7: closing the window is
  neither approval nor rejection).
- McpBanner.tsx: "Requested by «client»" + source + truncated content
  hash + the enable-defaults-off note, using the warning design
  tokens. Renders script-controlled/client-controlled strings as
  plain text (verified with an injection probe using an <img
  onerror> payload as the client name — no HTML is parsed).
- InstallActions.tsx: onMcpReject prop renders an explicit Reject
  button (distinct from Close) only for MCP-sourced requests.
- Non-MCP install flow verified unaffected: existing 21 useInstallData
  tests plus 10 existing InstallActions tests all still pass
  unmodified, plus a new explicit regression test confirming a
  non-MCP install still calls scriptClient.install and never touches
  mcpClient.

16 new tests across useInstallData/McpBanner/InstallActions (137/137
install-page tests total). Full suite green (298 files / 3223 tests);
typecheck, eslint, and both build profiles (default and
SC_ENABLE_MCP=true) all clean.
Human-facing confirmation page opened by McpApprovalService.decide()'s
caller (mcp/approval.ts already navigates to mcp_confirm.html?op=<id>).
Reads the operation via mcpClient.getOperation, renders per doc 07 §5:
enable/disable get a standard approve/reject pair, delete requires a
press-and-hold confirm to avoid one-click destructive approval.

Scoped to kind "enable" | "disable" | "delete" only. "source_disclosure"
is not rendered here because no backend pending-operation flow exists
for it yet — scripts.source.get in mcp/bridge.ts reads and returns
source directly with no consent gate, unlike install/enable/disable/
delete which go through McpApprovalService. Documented as a known
follow-up rather than building UI for a flow the backend can't emit.

Wired into rspack.config.ts: both the entry and the HtmlRspackPlugin
registration are conditional on enableMCP, so store profile builds
never emit mcp_confirm.html/.js — verified via before/after build output
inspection (file absent under default build, present under
SC_ENABLE_MCP=true).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the pairing flow doc 05 §5.4 describes: McpController now
handles native `pair.request` (stores it as a 2-minute-TTL pending
pairing, broadcasts mcpPairingRequested, opens mcp_confirm.html?pairing=
<id>) and `client.sync` (mirrors the host's authoritative client list —
including tokenHash, which the extension never mints itself — into
McpClientDAO). decidePairing() sends `pair.decision` to the host, which
mints the token/clientId on approval and reports back via client.sync.

McpUIService exposes `pendingPairing`/`pairingDecision` endpoints
(previously deferred with "would have nothing to call yet" — this
commit is that call). mcp_confirm/App.tsx gets a new McpPairingView:
client name, 8-char verification code, a scope checklist (read scopes
pre-checked only if requested, write scopes and source-read always
unchecked by default per doc 07 §3), and a 2-minute countdown that
auto-rejects at zero.

Scope trim, documented in McpController.onPairRequest: this commit
always opens the mcp_confirm popup for pairing, never the in-page
options-tab dialog doc 05 §5.4 also describes — detecting an open
options tab needs its own chrome.tabs plumbing for no security benefit
over the popup, so it's deliberately deferred rather than building a
second surface for the same decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… wire CI

Rewrites packages/native-messaging-host/PROTOCOL.md and THREAT-MODEL.md
from scratch against what's actually implemented in this branch (the
prelim PROTOCOL.md described a different, HTTP-based design that no
longer exists). Adds docs/store-review/mcp.md, assembling the data-flow
diagram, tool privilege table, consent-surface descriptions, token/audit
model, and kill-switch/rollback story a store reviewer would need —
explicitly marks the two items this session couldn't produce (consent
screenshots, a demo recording) as not yet captured rather than claiming
they exist.

Adds a "Build profiles & MCP gate" subsection to docs/develop.md and
indexes all three new docs in docs/README.md and the doc-maintenance
"Doc set & responsibilities" table, per docs/DOC-MAINTENANCE.md's own
checklist. Verified every new relative link resolves.

CI: adds `native-host` (ubuntu/macos/windows matrix, Node 20 — builds
and tests the standalone packages/native-messaging-host package with
its own lockfile) and `pack-profiles` (asserts store-stable/store-beta
builds contain no nativeMessaging/MCP strings and the developer profile
does, using a throwaway signing key generated in-job — no real signing
secret is available or needed for this verification-only pack run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Firefox

Closes two doc 08 gaps found in review: doc 08 §9's "no-listener"
structural proof (assert the native host never opens a TCP port — only
a Unix domain socket / named pipe) and §7's "no outbound network" static
check (no http/https imports, no fetch() calls in production source).
Added ipc.test.ts case asserting server.address() returns a string path
rather than a {port} object, plus a new structural.test.ts doing
source-level grep-style checks across the whole package.

Also fixes a real gap: doc 01's non-goals note that Firefox exposes
chrome.runtime.connectNative too, so the MCP controller "must degrade
gracefully (feature hidden)" there, but the existing gate only checked
`typeof chrome.runtime?.connectNative === "function"` — true on Firefox
as well. Added an explicit isFirefox() check to the service-worker
construction gate and to both places the Tools settings card is offered
(categories.ts, Tools/index.tsx), with new tests for the categories
gating logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coverage was previously auth 86.84%/broker 91.11%/native 88.23% branch
— under doc 08 §11's ≥90% line+branch target for the security-core
modules. Closed the real gaps found by inspection, not just chasing the
number:

- token-store.ts: load()'s non-ENOENT rethrow was untested (a real
  permission-denied error was silently swallowed as "no file" without
  a test proving otherwise); touchLastUsed/updateScopes on a missing
  clientId had no test for the early-return/no-persist path.
- challenge.ts: verifyMac's catch block (Buffer.from throwing on a
  runtime-mismatched candidateMac, e.g. parsed-JSON null) was
  unreachable by any existing test — the "invalid hex string" test
  actually exercises the length-mismatch branch, not the catch.
- pairing.ts: resolve() on an unknown/already-resolved pairingId had
  no test for its no-op branch.
- broker/server.ts: getSession() — used by host.ts and
  pairing-decision.ts — had zero test coverage of its own.
- native/channel.ts: feed() dropping a PARSE_ERROR/OVERSIZE frame
  without disrupting the stream, and double-unsubscribing from
  onMessage(), were both untested.
- broker/ipc.ts: the Windows named-pipe branch only runs on win32;
  added ipc.win32.test.ts (separate file — vi.mock("node:net") is
  file-scoped and would have broken ipc.test.ts's real Unix-socket
  tests) so it's covered on every OS, not only the Windows CI leg.

auth/broker/native now at 97.36%/92.22%/94.11% branch respectively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither entrypoint had any test coverage — every module they call is
unit-tested individually, but main() itself runs unconditionally at
module load (main().catch(...) at the bottom of each file), so
importing either file in-process would launch the real host/shim
rather than let a test exercise one code path in isolation.

Exercised as real subprocesses against the built dist/host.js and
dist/shim.js instead — this is the automated equivalent of doc 09 §2's
reviewer script (`node dist/host.js --doctor`) and §3's manual smoke
test step 1, with each run isolated to a fresh HOME/LOCALAPPDATA temp
dir so it never touches the developer machine's real ScriptCat config.
Covers host.ts's --print-manifest (valid, missing-extension-id, and
invalid-extension-id cases) and --doctor (all four check lines), plus
shim.ts's "no credentials yet" early-exit path — the one shim.ts branch
testable without a live broker socket; the rest of shim.ts's behavior
(--pair, authenticated run) is already integration-tested at the
SessionHandler/BrokerServer/SocketClient level.

Both test files skip cleanly (describe.skipIf) when dist/ hasn't been
built yet, so a plain `pnpm test` without a prior `pnpm build` doesn't
fail confusingly — the native-host CI job already runs build before
test, matching doc 08 §10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real bugs found while writing installer.test.ts (doc 08 §8, doc 09
row 15 — install.sh/uninstall.sh had zero behavioral test coverage
before this commit, only manifest-gen.ts's pure function was tested):

1. install.sh used `declare -A` (bash 4+) for its per-browser directory
   map. macOS ships bash 3.2 as /bin/bash (and therefore as whatever
   `#!/usr/bin/env bash` resolves to for most users) for licensing
   reasons and hasn't updated it in over a decade — so this installer
   would fail outright on stock macOS, exactly the platform doc 06 §5
   "macOS (install.sh)" targets. Replaced with a `case`-based
   browser_dir() function, portable back to bash 3.x.

2. --rollback (doc 06 §5 "Upgrades": "keep previous version dir for
   rollback (--rollback restores prior manifest)") was documented but
   never implemented in either install.sh or install.ps1. Implemented
   for both: installing over an existing install-metadata.json now
   records the superseded version as `previous` (version/installDir/
   launcher [+manifests/browsers on Windows, where each browser's
   manifest lives at a version-specific path]); `--rollback` restores
   each registered manifest to point at the previous launcher (POSIX:
   regenerated from the extension IDs recovered from the current
   manifest's allowed_origins, since the shared per-browser manifest
   path gets overwritten on every install; Windows: the previous
   manifest file was never overwritten in the first place, so this is
   just re-pointing the registry value) and never deletes the newer
   version's install dir.

installer.test.ts drives the real scripts as subprocesses against a
fake HOME — install/uninstall happy paths, invalid extension ID,
unknown browser, permissions (0600 manifest, 0700 install dir), no-op
uninstall, and the full upgrade-records-previous / rollback-restores
round trip (seeded with a synthetic "previous version" fixture, since
install.sh's VERSION is read from this package's own package.json with
no override flag). install.ps1/uninstall.ps1 aren't covered here — no
PowerShell interpreter in this environment — but exercised by the
Windows leg of the native-host CI matrix building/running this package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…90% bar

The extension-side security-core modules doc 08 §11 names alongside
auth/framing were never actually checked in this pass until now:
bridge.ts was at 51% branch coverage, approval.ts 78.57%, url_policy.ts
86.04% — well under the ≥90% target, with real gaps, not just numbers:

- bridge.ts: the per-action VALIDATORS table (doc 03 §1's "unknown
  properties rejected", doc 08 §2's INVALID_REQUEST matrix) was almost
  entirely untested on its rejection paths — non-object input, unknown
  fields, malformed enable/url/code/operationId — across 9 actions.
  scripts.toggle.request/delete.request/operations.list/cancel had no
  dispatch coverage at all (only list/metadata.get/source.get/
  install.prepare were exercised). Added a parameterized matrix plus
  per-action edge cases and the four missing dispatch tests.
- approval.ts: the URL-based install path only had "rejected before
  fetch" coverage — a successful URL fetch, a mid-fetch policy
  violation (redirect to a private host), an oversize download, and a
  non-UrlPolicyViolation error (network failure, must propagate
  unwrapped rather than being mis-wrapped as a URL rejection) were all
  untested. Also added: decide/getOperation/getOperationForUI/
  cancelOperation NOT_FOUND on a nonexistent operationId,
  cancelOperation CONFLICT on an already-decided operation, a staged
  install's TempStorageDAO entry vanishing before approval (CONFLICT),
  a toggle target script deleted entirely before approval (CONFLICT,
  not treated as a hash match), and the executeApproved default branch
  for a kind with no real create path (source_disclosure/update).
- url_policy.ts: IPv6 multicast (ff00::/8) and a public/global IPv6
  address were untested; fetchInstallSourceWithPolicy's non-streaming-
  body fallback (resp.text()) had no coverage at all, including its
  own oversize check.

bridge.ts/approval.ts/url_policy.ts now at 84%/94.04%/95.34% branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… gate

Closes the one remaining backend gap acknowledged since the pairing
commit: scripts.source.get previously read and returned script source
directly once a client held scripts:source:read, with no per-client
consent step — contrary to doc 02 §4.2's "first use per client" model
and the tool description in packages/native-messaging-host/src/shim/
tools.ts, which already promised this behavior.

McpApprovalService.checkSourceDisclosure() gates every source read: a
client with a permanent "allow for this client" grant (McpClient.
sourceDisclosureAllowed) or a just-approved one-shot operation for the
exact (clientId, uuid) pair proceeds; everyone else gets a new pending
McpOperation (kind "source_disclosure", same TOCTOU/TTL/idempotency
machinery as install/toggle/delete) and the bridge call returns
USER_APPROVAL_REQUIRED with the operationId instead of the source.
decide()'s new `rememberChoice: "once" | "client"` option controls
whether the grant is one-shot (consumed by the very next read via
checkSourceDisclosure's own expiry-on-consume) or persisted to the
client record.

McpConfirmView (mcp_confirm/App.tsx) renders the "source_disclosure"
kind with the three options doc 07 §5 specifies — Deny / Allow once /
Allow for this client — reusing i18n keys that were already present in
the locale files from the pairing-commit pass. No native-host or shim
changes needed: USER_APPROVAL_REQUIRED and the source_disclosure kind
already existed in the shared protocol/types, and the shim's generic
error-to-tool-result mapping already surfaces operationId for any
bridge error code.

Updated two bridge.test.ts cases whose old behavior (unconditional
direct source return) was exactly the bug being fixed, plus
PROTOCOL.md and docs/store-review/mcp.md to describe the real gate
instead of listing it as a known gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes the other deliberately-deferred item from the pairing commit:
doc 05 §5.4 says "if the options page is open, show dialog in place;
otherwise open a focused popup window" — the earlier commit always
opened the popup, on the reasoning that detecting an open options tab
needed its own chrome.tabs plumbing for no clear security benefit.
Implemented properly now since correctness relative to the spec
outweighs that plumbing cost.

McpController.onPairRequest queries chrome.tabs for an open
src/options.html tab before opening the mcp_confirm.html popup; if one
is open, it skips the popup and relies solely on the existing
mcpPairingRequested broadcast, which now has a real in-page listener.

Extracted the pairing decision state machine (fetch, scope defaults,
countdown, decide()) out of mcp_confirm/App.tsx's McpPairingView into a
shared usePendingPairing hook, plus the scope-checklist/code/countdown
JSX into PairingFields.tsx — both the standalone popup and the new
McpSection.tsx in-page McpPairingDialog (a Dialog, not a full page)
render from the same hook and field components rather than duplicating
the logic. The hook's decide() now returns a promise and accepts an
onDecided callback so each surface can react its own way (close the
popup window vs. dismiss the dialog and refresh the client list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task-oriented companion to PROTOCOL.md/THREAT-MODEL.md/store-review —
those explain the design and security rationale, this explains how to
actually get an AI agent connected and using it: build with
SC_ENABLE_MCP=true, build+register the native host (install.sh usage,
--doctor verification, upgrade/rollback), enable the bridge, pair a
client (--pair/--scopes flags, verifying the code, the scope
checklist), register it in an MCP client config, and turn on write
mode. Includes a tool table and five worked case studies grounded in
the actual approval flows: read-only listing, the source-disclosure
gate (once vs. permanent), toggle approval with TOCTOU re-verification,
hold-to-confirm delete, and client revocation — plus a troubleshooting
table for the states the settings card can show.

Every concrete claim (CLI flags, default pairing scopes, credential
paths, audit-event fields, hold duration) was checked against the
actual source rather than written from the design docs' aspirational
language. Linked from docs/README.md and docs/DOC-MAINTENANCE.md's
doc-set table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…omments

workspace/.ref-docs/ is gitignored planning material that never ships
in this repo's history — comments citing "doc 03 §4", "doc 04 §7",
etc. point PR readers at files they can't open. Rewrote every such
comment across src/app/service/service_worker/mcp/, src/app/repo/mcp.ts,
the install/mcp_confirm/Tools UI, and scriptInstall.ts to state the
rationale inline instead of citing a section number. Two were also
stale beyond the citation and got corrected along the way: types.ts's
"entities move to repo/mcp.ts in the next commit" (they already moved)
and url_policy.ts's claim that install fetches go through
`fetchScriptBody` (they call the global fetch directly — verified by
reading the code, not assumed).

No behavior change; comment-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uction code

Same cleanup as the extension-side pass, applied to
packages/native-messaging-host/src/{shared,auth,broker,native,shim,
installers/lib}/*.ts and the host.ts/shim.ts entrypoints (test files
still to follow). Every comment citing "doc 03 §4", "doc 04 §7", etc.
now states its rationale inline. Also softened a couple of "the
prelim's ..." references (framing.ts, manifest-gen.ts) encountered
along the way — they described a previous, removed implementation
that no longer exists in this repo's history either, so citing it was
equally unhelpful to a PR reader.

protocol.ts's header now points at packages/native-messaging-host/
PROTOCOL.md — a real, committed spec doc — instead of the gitignored
planning file it previously cited.

No behavior change; comment-only. Verified with a full build + test
run (27 files / 214 tests) after the edits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cyfung1031 and others added 5 commits July 12, 2026 16:59
… files

Completes the citation cleanup in packages/native-messaging-host —
every describe()/it() title and comment across the package's test
suite that cited "doc 03 §4", "doc 04 §7", etc. now either drops the
citation (where the title was already self-explanatory) or states the
rationale inline. Also touched two adjacent "prelim"-referencing
comments in manifest-gen.test.ts (a previous, removed implementation
no PR reader has access to) and corrected a factual slip introduced
along the way: the invalid test ID "fomrtutthjerocmw" is invalid
because of its r/t/u/w characters specifically, not the f/o/h/c/m ones
my first pass wrongly listed.

Verified with a full build + test run in both the native-host package
(27 files / 214 tests) and the root extension workspace (301 files /
3313 tests) after the edits. No behavior change; comment-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final sweep of installer scripts, docs, build configs, and test files
missed by the earlier three cleanup commits, plus four leftover
references to the removed prelim implementation branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…guide

Adds a developer README (+ zh-CN translation) for the previously
undocumented native-messaging-host package, a zh-CN translation of the
MCP bridge usage guide, and a GPLv3 LICENSE pointer for the package.
Wires all four into docs/README.md and DOC-MAINTENANCE.md's doc-set
table, and adds a language switcher to the English bridge guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@CodFrm

CodFrm commented Jul 12, 2026

Copy link
Copy Markdown
Member

我想用go来做本地的进程,考虑复用vscode的websocket,这样也方便提供远程的

@cyfung1031

cyfung1031 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

我想用go来做本地的进程,考虑复用vscode的websocket

用 node 会比较好吧。同一套ts 代码
本地 MCP Bridge 用 go 好像没什么好处

node 跟 python3 都是可以在用户本地环境容易跑起来
go 的话有一定挑战

而且 node 可以用 @modelcontextprotocol/sdk
这是 Agent MCP 的标准库

你用 go 自行写的话,安全层面一定不够这些做得好

我看错了。有 go 版

https://github.com/modelcontextprotocol/go-sdk

代码提交了。你拿来用吧。改成 go 或是什么的都可以。你喜欢吧。
重点是那些安全和认证问题都有处理好就好

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

我想用go来做本地的进程,考虑复用vscode的websocket,这样也方便提供远程的

AI 不建议复用直接 websocket.

websocket 只是一个简单的协定,不够安全。

我觉得你可以先接受本地的吧
需要远程的话日后再搞

不然 MCP控制ScriptCat 这一块会一直是个缺口

老实说,用远程去直接控制你的本地ScriptCat不是很理想
应该是远程控制 Codex / Claude / Hermes 之类的,它们在你本地的环境跑再呼叫本地MCP
根本不用这样跳过

有远程的话,你的电脑网络保安做不好的话,就会被直接攻击起来
安装一大堆恶意脚本之类


结论

维护者的原话其实是一条架构偏好,不是两个明确的 blocking review:

“我想用 go 来做本地的进程,考虑复用 vscode 的 websocket,这样也方便提供远程的”

其中“我想用”“考虑”都比“必须修改”为弱。建议在开始大规模重写前,先把三个方案的边界说明清楚。

按可合并、保留现有安全语义的实现估算:

方案 逻辑改动组 预计涉及文件 总体判断
仅 1:本地进程改 Go 7–9 组 约 60–85 个 大规模语言移植,但架构不变
仅 2:复用 VS Code WebSocket 9–12 组 ScriptCat 约 35–55 个;VS Code 仓库约 3–8 个 不是简单复用,需要重新设计网络安全
1+2:Go + WebSocket/远程 13–16 组 两仓库约 75–110 个 实际上接近重新实现整个本地桥接
仅做“能跑”的 WebSocket 复用 3–5 组 约 10–20 个 改动少,但安全倒退,不建议合并

当前 PR 自身已经接近 140 个 changed files。因此无论选择哪一种,都应避免在现有 PR 上继续叠加没有拆分的重构。


情况一:只做(1)——本地进程改用 Go

保持的架构

MCP client
  -> Go MCP shim
  -> Unix socket / Windows named pipe
  -> Go Native Messaging Host
  -> Chrome Native Messaging
  -> ScriptCat

也就是只更换实现语言,不改变当前安全边界。

当前实现是一个 Node/TypeScript 包,同时提供两个命令:

  • scriptcat-native-host
  • scriptcat-mcp

并使用官方 TypeScript MCP SDK。

需要修改的部分

大约有 8 个工作组:

  1. 建立 Go module 和两个 executable。
  2. 移植 Chrome Native Messaging framing。
  3. 移植 Unix socket / Windows named pipe。
  4. 移植配对、token、challenge-response、scope。
  5. 移植 session、rate limit、revocation。
  6. 使用 Go MCP SDK 实现 stdio MCP server。
  7. 重写安装器、manifest 生成和 doctor
  8. 将 TypeScript 测试移植为 Go 测试,并保留协议一致性测试。

扩展侧的以下部分基本可以不动:

  • 用户确认页面
  • pending operation
  • TOCTOU 检查
  • scope 二次验证
  • 审计记录
  • 安装、删除、启停的人为确认

前提是 Go 实现严格兼容现有协议。

预计改动量

  • 逻辑模块:7–9 个
  • Git diff 文件:约 60–85 个
  • 如果把删除旧 TypeScript 文件和新增 Go 文件分别计算,changed-file 数量可能超过 100。

主要原因是当前 native-messaging-host 包包含大量实现文件和相应测试,而不是一个简单的 200 行入口程序。

对当前 PR 设计的影响

这是三种方案中风险最低的,因为当前 PR 的主要安全模型不依赖 TypeScript:

  • 每条连接经过 challenge-response。
  • host 和扩展分别检查 scope。
  • 所有写操作仍需要人工批准。
  • staged content 和目标代码通过 hash 防止 TOCTOU。

当前 Node 方案的缺点

1. 分发不是单文件

当前命令指向编译后的 JavaScript,用户仍需要可工作的 Node 环境或完整封装。

Go 可以提供每个平台的单一可执行文件,安装路径和 Native Messaging manifest 更容易稳定下来。

2. 安装器复杂

当前安装器必须处理:

  • Node 实际路径
  • JS 入口路径
  • package 安装位置
  • 不同 shell/PowerShell 行为

Go binary 可以明显简化这一部分。

3. Node 本地 IPC 无法方便地检查 peer UID

当前代码明确记录:Node core 没有跨平台 peer credential API,因此 Unix socket 只能依靠目录 0700 和 socket 0600,没有验证连接进程的实际 UID。

Go 在 Unix 平台更容易调用 SO_PEERCRED 一类平台 API,不过 Windows named pipe ACL 仍需单独实现。

4. 两个 Node 进程和多跳转发

目前存在:

MCP stdio -> shim -> socket -> native host -> Native Messaging -> extension

实现可靠,但部署、日志和生命周期相对复杂。

只做(1)的不足

完全不解决远程能力

当前设计刻意没有 TCP listener,host 甚至声明不使用网络;这是安全设计,不是偶然限制。

因此,维护者若把“方便提供远程”视为最终目标,仅改 Go 只能解决运行时和分发问题。

判断

适合作为当前 PR 的收敛方案。

它满足维护者对 Go 的偏好,同时最大程度保留已经完成的安全工作。但是需要接受这仍是一场较大的代码移植,而不是几处修改。


情况二:只做(2)——保留 Node,但复用 VS Code WebSocket

这里要先明确“复用”的含义。

最可能的架构是:

MCP client
  -> Node MCP process / WebSocket server
  <- WebSocket / WSS
ScriptCat offscreen page
  -> service worker

这意味着 WebSocket 替换:

  • Unix socket / Windows named pipe
  • Native Messaging host 与扩展之间的通信

否则,如果只把 shim 与 host 之间的 Unix socket 改成 WebSocket,但仍保留 Native Messaging,实际上没有获得真正的远程能力。

现有 VS Code WebSocket 能复用多少

现有 ScriptCat 侧连接器已经具备:

  • 配置 URL
  • 自动重连
  • connection timeout
  • hello
  • onchange

但其协议目前只处理 helloonchange,收到 onchange 后直接通过 installByCode 安装或更新脚本。

VS Code 端目前:

  • 固定默认端口 8642
  • 创建普通 WebSocket server
  • 未指定监听 host
  • 没有 TLS
  • 没有 client authentication
  • 没有 scope
  • 没有配对
  • 连接后直接发送 hello

所以可以复用的是:

  • offscreen WebSocket 生命周期管理
  • reconnect 逻辑
  • VS Code 仓库里现有的 server 启停框架

不能直接复用的是安全协议和 MCP 操作协议。

安全可合并版本需要的修改

至少有 10 个工作组:

  1. 定义新的版本化 WebSocket envelope。
  2. 支持 request / response ID 和异步事件。
  3. 将现有 pairing 移植到 WebSocket。
  4. 加入 challenge-response 或等价认证。
  5. 绑定 client ID、scope 和 session。
  6. 加入 rate limit、revocation 和 kill switch。
  7. 支持 pending operation 与审批事件。
  8. 本地模式限制为 loopback,并检查连接来源。
  9. 远程模式加入 WSS、服务器身份验证和证书策略。
  10. 修改配置 UI、测试、文档和 threat model。

预计改动量

在保留当前安全模型的情况下:

  • ScriptCat 仓库约 35–55 个文件
  • scriptcat-vscode 仓库约 3–8 个文件
  • 逻辑工作组约 9–12 个

若只是把现有 hello/onchange 协议扩展几个 action:

  • 可能只需要约 10–20 个文件
  • 但那不是安全等价实现

最大缺点:安全边界发生根本变化

当前 PR 的关键声明是:

不存在 HTTP/TCP listener,恶意网页无法通过 localhost HTTP/CORS/DNS rebinding 接触桥接入口。

这被列为“通过设计消除”的攻击入口。

改成 WebSocket 后,该结论不再成立。

网页也可以尝试连接本地 WebSocket 服务。WebSocket 的 Origin 可以辅助识别浏览器来源,但:

  • 非浏览器客户端可以伪造 Origin
  • Origin 不能替代 token authentication
  • 本地端口可能被扫描
  • 固定端口可能被其他进程抢占
  • 若监听所有网卡,局域网设备也可能尝试连接

因此必须重新建立安全模型,而不能仅说“WebSocket 不是 HTTP,所以安全”。

远程模式新增的问题

1. 初次配对需要安全信道

当前初次配对发生在用户级本地 socket 上,token 通过本地受权限保护的 channel 传输。

如果改为远程 WebSocket,首次 token 或 pairing 信息会经过网络。至少需要以下之一:

  • 正确验证的 TLS 证书
  • certificate/public-key pinning
  • PAKE 类配对协议
  • 用户手动核对服务端公钥指纹

单纯 8 位显示码不足以承担完整的远程 MITM 防护。

2. WSS 证书体验

自托管远程服务常见的是:

  • 自签证书
  • 内网域名
  • IP 地址
  • 动态 DNS

浏览器扩展不能随意忽略证书错误,因此需要明确的证书和部署方案。

3. 商店构建边界需要重审

当前 PR 将 MCP 功能从 store build 编译排除,以避免普通用户获得 nativeMessaging 权限和相关集成。

WebSocket 不需要 nativeMessaging,但如果打算让商店版本支持任意远程服务,就需要重新评估:

  • 网络连接声明
  • CSP/connect-src
  • 用户同意页面
  • 远程代码控制面的审核风险
  • 默认是否允许非 loopback 地址

判断

如果维护者真正想要远程能力,这个方向合理,但不能把它称为简单“复用”。

它实际上是:

保留审批和权限领域模型,替换整个 host-to-extension transport 与 trust boundary。


情况三:(1)+(2)——Go 本地进程,同时复用 WebSocket并支持远程

最合理的目标架构是:

MCP client
  -> stdio
Go ScriptCat bridge
  -> WS(本地)
  -> WSS(远程)
ScriptCat offscreen page
  -> service worker
  -> approval / script services

这可以删除:

  • Node runtime
  • Node shim
  • Native Messaging host
  • Unix socket / Windows named pipe
  • Native Messaging manifest
  • 大部分原有安装脚本逻辑

需要的工作组

大约 14 个:

  1. Go executable 和 MCP stdio server。
  2. MCP tools/schema。
  3. WebSocket/WSS server。
  4. 协议 envelope 和版本协商。
  5. 本地 loopback 模式。
  6. 远程 WSS 模式。
  7. pairing。
  8. token storage。
  9. challenge/session authentication。
  10. scope/rate limit/revocation。
  11. 服务器身份和证书验证。
  12. ScriptCat offscreen/client 生命周期。
  13. 跨平台 binary 打包、安装和升级。
  14. 跨仓库测试、文档和 threat model。

预计改动量

  • ScriptCat 与 VS Code 两仓库合计约 75–110 个文件
  • 删除旧 TypeScript host 和新增 Go 文件同时计数时,可能超过 120
  • 原 PR 中许多扩展侧安全逻辑可保留,但 host 侧实现和所有 transport 测试需要重做

优点

1. 分发体验最好

Go 单文件 binary 适合:

  • Windows
  • macOS
  • Linux
  • system service
  • Docker/远程部署
  • 自动升级和签名

2. 本地和远程可以共用协议

只需两种安全 profile:

local:  ws://127.0.0.1:随机端口
remote: wss://指定服务

业务消息和审批语义保持一致。

3. 去掉 Native Messaging 权限

若 WebSocket 由扩展主动建立,可以不使用 nativeMessaging。这能简化商店权限面,但不自动意味着商店审核一定简单,因为远程控制面仍需说明。

缺点

1. 这是重新设计,不是处理两条小评论

它会同时改变:

  • 语言
  • 进程模型
  • transport
  • trust boundary
  • 安装方式
  • remote threat model
  • store profile

在一个已有约 140 个 changed files 的 PR 上继续做,审查难度会非常高。

2. 当前安全证明大部分需要重写

当前 threat model 的核心前提是“没有 TCP listener”。

采用 WebSocket 后,需要重新证明:

  • 网页不能未授权连接
  • 本地其他进程不能冒充客户端
  • 远程服务器身份可信
  • pairing 不受 MITM
  • token 不会通过 URL 或日志泄露
  • 断线重连不会恢复已撤销 session
  • local 与 remote 配置不会意外降级

3. 两个仓库产生协议耦合

复用 VS Code WebSocket 意味着 scriptcatscriptcat-vscode 共享或共同演进协议。需要:

  • protocol version
  • backward compatibility
  • capability negotiation
  • conformance tests
  • 发布顺序策略

否则任一仓库先发布都可能破坏另一个。

判断

这是长期架构最统一的方案,但不适合作为现有 PR 的追加修改。

更合理的是单独做新的 architecture PR 或 RFC。


当前提交设计本身的主要缺点

即使不考虑维护者意见,现有实现有以下真实代价。

1. 进程链较长

MCP stdio
 -> shim
 -> local IPC
 -> native host
 -> Native Messaging
 -> extension

优点是安全边界清楚,缺点是部署、诊断和生命周期更复杂。

2. Node 运行时和 npm 分发依赖

当前 host/shim 都是 JS entrypoint,并依赖 MCP SDK 和 Zod。

这比单个 Go binary 更容易开发,但安装和供应链面更大。

3. Native Messaging host 受浏览器生命周期控制

host 由 connectNative 启动,shim 又需要从配置发现 host 发布的 socket endpoint。

这引入了:

  • host 尚未运行时 shim 无法连接
  • extension service worker/offscreen 生命周期协调
  • endpoint stale 状态
  • 浏览器关闭后 bridge 不可用

4. 暂不支持远程

这是明确的设计结果,不是遗漏。

5. scope 动态变化尚未完整通知 MCP client

shim 收到 scopes.changed 后只更新本地变量,但代码明确说明 tools/list_changed 的重新注册仍被延后。

这可能导致:

  • 已撤销工具仍短暂显示在客户端
  • 新授权工具不能马上出现
  • 需要重连才能完全同步能力列表

这是当前实现里一个具体的待完善点。

6. 大 PR 审查风险

PR 描述自身仍列出:

  • CI 尚未全部通过
  • 未完成真实浏览器全链路 smoke test
  • Windows installer 未实际验证
  • 仍需 human review。

在此基础上再加入 Go 和远程 WebSocket,会显著增加无法有效审查的风险。


推荐决策

当前 PR 最稳妥的处理方式

选择(1)only,或者保持当前 Node 实现并把 Go 迁移拆成后续 PR。

原因是:

  • Go 不改变安全架构。
  • 扩展侧的大部分实现和测试可以保留。
  • 容易向维护者证明是等价移植。
  • 不会把 remote threat model 混进当前 PR。

长期方案

将(2)拆成独立设计:

  1. 先定义与语言无关的 bridge protocol。
  2. 做 Go local bridge,但先只允许 loopback。
  3. 保留所有 pairing、scope、approval、TOCTOU。
  4. 最后单独加入 WSS remote profile。
  5. 远程模式默认关闭,并要求显式配置 TLS/server identity。

不建议

不建议为了减少改动,直接扩展现有 VS Code 的 hello/onchange WebSocket。当前协议是面向开发热更新的,不是面向拥有脚本读取、安装、启停和删除权限的远程控制面。其现有认证和授权能力不足以承载 MCP。

@CodFrm

CodFrm commented Jul 12, 2026

Copy link
Copy Markdown
Member

考虑go是方便跨平台,做cli之类的工具,自己也熟悉一些

另外远程的话,扩展只能作为client,不是server,而且一般默认连接的是localhost,很难出现网络问题

其次考虑ws也是不想增加nativeMessaging权限

等周一再看看

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants