REA Switch
在 DSH 输入框左侧放一个紧凑的 REA 开关:一键开关 REA MCP 工具面,关闭时立即拦截执行、从 live agent 的 schema 中移除继承工具、shadow 对应 MCP 提示词,并在 chip 上显示被隐藏工具的数量与 token 估算。
| 项目 | 值 |
|---|---|
| 插件包 | @local/rea-switch |
| 版本 | 1.3.0 |
| 宿主 | DSH 0.2.2-rc.1(engines.dsh:>=0.2.1-rc.5 <0.3.0-0) |
| Node | >=24.9.0 |
| 默认 MCP server | rea |
| 许可证 | MIT |
为什么
REA 这类逆向工程 MCP server 往往一次暴露几百个工具。它们的 JSON Schema 会随每次模型请求一起发送,占用可观的上下文与 token 预算;而在不调试二进制的普通对话里,这些工具通常用不上。
REA Switch 把“这次对话是否需要 REA”变成一个可见、可点击的本机偏好:
- 不需要时一键关闭,下一次工具调用立即生效,不用重启 DSH;
- 关闭后同时处理执行层、工具列表和提示词三层,减少模型看到无关工具与说明的机会;
- chip 直接显示关闭后隐藏了多少工具、约省下多少 token;
- 状态保存在本机文件,重启后延续上次选择;
- 状态文件损坏、不可读、版本不符等异常按关闭处理;首次运行(文件不存在)默认开启,保留原首启体验。
功能
- 输入框 chip:注册在
conversation.input.left,位于权限/计划 chip 旁边;点击切换,支持键盘焦点与aria-pressed。 - 执行层 guard:关闭时,全局
tools.guard拦截mcp__<server>__*,以及list_mcp_resources、list_mcp_resource_templates、read_mcp_resource中指向该 server 的调用;参数缺失或指向不明时保守拦截。 - 按 agent mask:通过
tools.restrict({ deny })从每个 live agent 的可见 schema 中移除继承的 REA 工具。 - 提示词 shadow:注册空的
mcp:<server>section 遮蔽 server 描述;重写mcp-resource-servers列表并保留其他 server;追加一条关闭提示。 - 真实状态:agent 自己 scope 注册的工具落在 host mask 的覆盖范围之外,插件用
tools.definitions(agent)(不可用时回退schemas(agent))检测并把mode标为partial,不会谎报“全部隐藏”。 - token 估算:
toolBytes是当前 REA schema 投影的 JSON UTF-8 字节数,approxTokens = round(bytes / 4);4 字节/token 只是近似。统计只覆盖全局注册表投影,agent scope 的 REA 工具不计入toolBytes,但仍计入visible。 - 持久化:默认
<DSH_HOME>/rea-switch.state.json,原子写入;损坏、不可读、未知版本、符号链接、特殊文件或超大文件一律按关闭处理。 - 本机控制接口:仅 loopback 绑定时注册,JSON-only、同源校验、Host allowlist、Host 端口一致性、代理头拒绝、1024 字节请求体上限。
- 中英双语:中文为主,英文界面按页面/浏览器语言回退。
快速开始
前置条件
- DSH
0.2.2-rc.1(桌面版或本机 Web GUI); - Node.js
>=24.9.0; - 一个可用的 REA MCP server,默认注册名为
rea(自定义名称见配置); - 插件源码位于
<WORKSPACE>/plugins/rea-switch。
方式一:link 安装(开发工作区)
在 DSH profile 的 package.json 中加入本地依赖,路径替换为实际工作区:
{
"dependencies": {
"@local/rea-switch": "link:<WORKSPACE>/plugins/rea-switch"
}
}
然后在 profile 目录执行:
pnpm install
路径包含空格或中文时,link: 仍然可用;工作区移动或重命名后需要重新安装。
方式二:tarball 安装
cd <WORKSPACE>/plugins/rea-switch
pnpm pack
把生成的 tarball 放进 profile 依赖:
{
"dependencies": {
"@local/rea-switch": "file:C:/path/to/local-rea-switch-1.3.0.tgz"
}
}
pnpm install
tarball 包含运行时文件与 README、LICENSE、CHANGELOG、SECURITY、ARCHITECTURE;测试与 .gitignore 留在仓库中。
包标记 private: true 是本地插件的有意设置,用来避免误发布到 npm。要正式发布时,先改成公开包名(例如 rea-switch)并移除 private 字段,再核对 files 与 exports。
启用插件
插件通过 cordis.patch.yml 插入:
- insert:
- id: rea-switch
name: '@local/rea-switch'
需要自定义 server 名称或状态文件路径时:
- insert:
- id: rea-switch
name: '@local/rea-switch'
config:
serverName: rea
stateFile: C:/path/to/rea-switch.state.json
重新加载
保存配置后重新加载 DSH profile,或重启 DSH,然后刷新 Web 页面。chip 出现在已有会话的输入框左侧;新会话的 Hero 空态可能暂时没有 chip,进入一则已有对话即可看到。
验证控制接口(端口替换为当前 DSH Web 端口):
curl.exe -s http://127.0.0.1:<PORT>/dsh-local/rea-switch/state
使用
点击 chip 即切换。开启时 chip 显示绿点与“开”,关闭时显示空心点与“关”,并给出隐藏工具数量。
| chip 状态 | data-state |
含义 | 典型文案 |
|---|---|---|---|
| 读取中 | loading |
首次 GET /state 尚未返回;保持中性色,不沿用上次的绿色 |
正在读取 REA 开关状态 |
| 已开启 | on |
enabled: true |
REA 工具已开启,点击关闭 · 每次请求约 +19.4 万 tokens |
| 已关闭 | off |
关闭且 mask 正常(mode: restrict) |
REA 工具已关闭,点击开启 · 已隐藏 207 个工具,约 19.4 万 tokens |
| 离线 | off |
关闭、有 live agent,但注册表里没有 REA 工具(mode: offline) |
REA 未连接或无可用工具 |
| 部分隐藏 | off + data-degraded=true |
mode: partial 或 guard,或注册表漂移(drift: true);执行层仍拦截 |
REA 已关闭,但部分工具仍可见,执行层已拦截 |
| 不可用 | error |
控制路由未注册、网络失败或响应异常;点击重试 | REA 开关不可用,点击重试 |
切换期间 chip 进入 busy/pending:按钮禁用,pending 时圆点脉冲;关闭完成前不显示“全部隐藏”的终态文案。
mode 的完整语义:
mode |
条件 | 含义 |
|---|---|---|
idle |
开启,或没有 live agent | 没有需要展示的关闭态 mask |
offline |
关闭、有 agent、注册表没有 REA 工具且 visible 为 0 |
REA server 未连接或未注册工具 |
restrict |
关闭,所有 live agent 都安装了 mask | 工具面已隐藏 |
partial |
关闭,部分工具仍可见,或 settle() 发现注册表漂移 |
执行层仍拦截,但列表未完全清空 |
guard |
关闭,没有任何 agent 成功安装 mask | 只剩全局 guard 在执行层拦截 |
partial 和 guard 都是真实状态,不代表开关失效:执行层的 guard 与 mask 相互独立,guard 每次调用都读取当前开关值。
安全模型
[!WARNING] REA Switch 是本机偏好开关,不是访问控制,也不是权限边界。 关闭它只改变模型看到的工具面与提示词,并拦截经过 DSH 工具调度器的调用;它不停止 MCP server 进程,也不阻止本机其他进程直接访问该 server。
- MCP 进程仍在运行:关闭开关不会卸载 server,也不会撤销其凭据或网络访问。
- 旁路始终存在:带
pwsh、PTC 或 shell 的 preset 可以直接调用rea-agents、MCP JSON-RPC 或其他本地接口;需要硬隔离时,应在 preset、OS 账户、网络或 MCP 配置层限制。 - 本机进程边界:控制路由没有 API 凭据。同一台机器上的任意进程都可以请求 loopback 端口;这是本机偏好接口的设计边界。
- 多进程不实时同步:同一进程内多个页面通过
BroadcastChannel与焦点事件刷新;不同 DSH 进程只保证状态文件每次是完整 JSON,最后写入者胜。 - 反向代理:把 DSH Web 暴露到非 loopback 地址时,必须在代理层拦截
/dsh-local/rea-switch,并启用认证。插件在非 loopback 绑定下不注册该路由,还会拒绝带forwarded、via、x-forwarded-*、x-real-ip的请求;会剥离这些头的代理仍必须自行拦截该路径。这属于纵深防御,不是远程部署的访问控制方案。 - 无 secret:状态文件只保存
{ version, enabled },不保存 token、cookie 或 server 凭据。
控制路由的防护:
- 仅在
webServer.host明确为 loopback 时注册:127.0.0.0/8、::1、IPv4-mapped::ffff:127.*、localhost;缺失、空白或解析失败的 bind 值按非 loopback 处理,路由不注册。 - 每个请求的
Hostauthority 必须是 loopback,且其端口必须与当前webServer.port一致(端口可读时);Origin存在时必须与Host一致;Sec-Fetch-Site只接受same-origin或none。这可以挡住 DNS rebinding 与跨站表单。 - 带
forwarded、via、x-forwarded-for、x-forwarded-host、x-forwarded-proto、x-real-ip任一请求头的访问按代理流量处理,返回403;这能挡住把 loopback 端口转成远程入口的常见代理配置。 POST /set只接受application/json,请求体上限 1024 字节;不返回 CORS 头;响应带x-content-type-options: nosniff;500 只返回通用错误文本。- 访问日志只记录 method、action、status,不记录请求体。
- 状态文件要求
version: 1,对符号链接、特殊文件与超过 64 KiB 的文件按关闭处理;写入使用不可预测名字的wx临时文件、0600权限与rename。
更完整的威胁模型、部署检查清单与已知边界见 SECURITY.md。
工作原理
flowchart LR
Chip["composer chip<br/>conversation.input.left"] -->|"GET /state"| Route["loopback HTTP route<br/>/dsh-local/rea-switch"]
Chip -->|"POST /set {enabled}"| Route
Route --> State["内存 flag +<br/>rea-switch.state.json"]
State --> Guard["全局 tools.guard<br/>拦截 mcp__server__* 与 3 个资源工具"]
State --> Mask["每个 live agent<br/>tools.restrict({ deny })"]
State --> Prompt["prompt shadow<br/>mcp:server / mcp-resource-servers<br/>+ 关闭提示"]
Guard --> Model["下一次模型工具调用"]
Mask --> Model
Prompt --> Model
三层职责:
- Guard(权威、即时):注册一次,读取
state.enabled;关闭时拦截目标工具。即使 mask 尚未安装、安装失败或注册表正在变化,执行层仍然拦截。 - Mask(按 agent、尽力):对每个 live agent 调用
tools.restrict({ deny }),从继承工具中移除 REA 名称;tools.view(agent).restrictableNames预过滤不可 mask 的名称,失败时从错误信息解析未知名称并保留部分 mask。 - Prompt(按 agent、尽力):空
mcp:<server>覆盖 server 描述;mcp-resource-servers现算列表并剔除目标 server;追加rea-switch:disabled:<server>提示。systemPrompt或mcpResources取不到时只损失对应提示,guard 与 mask 继续工作。
一次关闭会启动一个 arming pass:每个 live agent 在独立 macrotask 中处理,避免一次性发布大量 tools/change 造成长任务;pass 结束后 settle() 再读一次注册表,发现漂移则指数退避重试,最多 5 次。
HTTP API
控制路由前缀:/dsh-local/rea-switch。只在 loopback 绑定时存在。
| 方法 | 路径 | 请求体 | 成功 | 主要错误 |
|---|---|---|---|---|
GET / HEAD |
/state |
无 | 200 + snapshot JSON |
403 跨站;405 方法错误;路由未注册时由宿主返回 404 |
POST |
/set |
{"enabled": true|false} |
200 + 新 snapshot |
400 JSON/字段错误;403 跨站;405 方法错误;413 超过 1024 字节;415 非 JSON;500 内部错误 |
约束:
Host必须是 loopback authority,端口与当前webServer.port一致(端口可读时);Origin若存在必须与之同源;Sec-Fetch-Site只接受same-origin/none。- 带
forwarded、via、x-forwarded-for、x-forwarded-host、x-forwarded-proto、x-real-ip的请求返回403;代理需要自行拦截该路径。 POST的Content-Type必须以application/json开头;body 必须是对象,且enabled必须是布尔值,"true"、1等一律400。- 响应统一
application/json; charset=utf-8、cache-control: no-store、x-content-type-options: nosniff;不发送 CORS 头。 state的HEAD与GET状态码、头一致,不返回 body。
GET /state 示例(数值随实际 REA 工具集变化):
{
"enabled": false,
"prefix": "mcp__rea__",
"server": "rea",
"tools": 207,
"toolBytes": 775022,
"approxTokens": 193756,
"agents": 2,
"visible": 0,
"mode": "restrict",
"drift": false,
"pending": false,
"persisted": true,
"note": ""
}
字段说明:
| 字段 | 类型 | 含义 |
|---|---|---|
enabled |
boolean | 当前开关值 |
prefix |
string | mcp__<serverName>__ |
server |
string | 当前 server 名 |
tools |
number | 注册表中匹配前缀的工具数 |
toolBytes |
number | 匹配工具 schema 投影的 JSON UTF-8 字节数 |
approxTokens |
number | round(toolBytes / 4),近似值 |
agents |
number | 已 attach 的 live agent 数 |
visible |
number | 各 agent 仍可见的 REA 工具数之和 |
mode |
string | idle / offline / restrict / partial / guard |
drift |
boolean | 最近一次 settle() 是否发现注册表与 mask 不一致 |
pending |
boolean | arming pass 是否仍在运行或排队 |
persisted |
boolean | 最近一次写入是否成功 |
note |
string | 状态文件异常或不可写时的说明 |
写入示例:
curl.exe -X POST http://127.0.0.1:<PORT>/dsh-local/rea-switch/set `
-H "content-type: application/json" `
--data "{\"enabled\":false}"
非浏览器客户端可以省略 Origin,但 Host 仍必须是 loopback authority 且端口与监听端口一致;带 Host: evil.example 的请求即使 Origin 与 Host 相同也会返回 403,带 x-forwarded-* / forwarded / via 的请求同样按代理流量拒绝。
状态文件
默认路径:
<DSH_HOME>/rea-switch.state.json
DSH_HOME 解析规则与宿主一致:非空的 $DSH_HOME 优先(纯空白视为未设置),否则回退 ~/.dsh,最终归一化为绝对路径;支持 ~、~/、~\ 前缀。
默认是跨 profile 的全局开关:所有 profile 共享同一文件,最后写入者胜。需要按 profile 隔离时,用 config.stateFile 钉住路径:
- insert:
- id: rea-switch
name: '@local/rea-switch'
config:
stateFile: C:/path/to/profiles/desktop/rea-switch.state.json
文件格式:
{
"version": 1,
"enabled": true
}
读取语义:
| 情况 | 结果 |
|---|---|
| 文件不存在 | 首次运行,enabled: true |
正常 JSON,enabled 为布尔值且 version 为 1 |
使用该值 |
JSON 损坏、不可读、enabled 非布尔值 |
enabled: false + note |
version 缺失或不是 1 |
enabled: false + note |
| 符号链接、目录、FIFO 等非普通文件 | enabled: false + note |
| 文件大于 64 KiB | enabled: false + note |
写入语义:
- 使用
<file>.<pid>.<random>.tmp,以wx排他创建,0600权限,写完后rename原子替换; - 目录按需递归创建;
- 写入失败时返回
persisted: false,本次运行保留内存中的选择,并在 chip 的 title 中显示提示; - 状态文件只保存开关值,不保存任何凭据。
配置
apply(ctx, config) 读取两个可选字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
serverName |
string | rea |
同时决定工具前缀、guard 目标、prompt section 名、资源匹配与 chip 标签;须匹配 ^[A-Za-z0-9_-]{1,32}$ |
stateFile |
string | <DSH_HOME>/rea-switch.state.json |
钉住状态文件路径;支持 ~ 前缀 |
serverName 区分大小写,并与 MCP 工具前缀完全匹配。名称不符合 ^[A-Za-z0-9_-]{1,32}$ 时,插件记录 invalid serverName 警告并回退到 rea,避免 guard/mask 因错误前缀漏掉工具。cordis.patch.yml 不传 config 时使用 rea 与全局状态文件。
兼容性与已知限制
- 宿主:在 DSH
0.2.2-rc.1上验证;engines.dsh允许>=0.2.1-rc.5 <0.3.0-0。更老的宿主可能没有tools.definitions(agent)、tools.view()或tools.schemas(),此时插件会降级并记录 warn。 - Node:
>=24.9.0。 - serverName 白名单:名称须匹配
^[A-Za-z0-9_-]{1,32}$;非法名称回退rea并记录警告。回退可以保证 guard/mask 仍覆盖默认前缀,但自定义 server 的工具不会被切换。 - 仅本机 Web:非 loopback 绑定不注册控制路由;chip 仍会渲染,但请求得到 404 后进入 error 态。
- 新会话 Hero 空态:可能没有输入框工具行,先进入已有会话再切换。
- agent-owned 工具:per-agent/scoped 注册的 REA 工具不在
restrict()的能力范围内,会保持可见并把mode置为partial;执行层仍由 guard 拦截。它们计入visible,但toolBytes/approxTokens只统计全局注册表投影,不包含这些工具。 - prompt shadow 冲突:per-agent MCP server 可能已经拥有同名
mcp:<server>section;此时该 scope 的 prompt shadow 可能注册失败并记录 warn,notice 也可能缺失,执行层 guard 仍会拦截。 - mask 漂移:注册表在 pass 期间变化时可能短暂出现
partial;settle()会退避重试,tools/change也会在 400 ms 防抖后重新 arm;mcpResources延迟激活时,下一次 pass 会补注册资源列表 shadow。 - token 估算:
toolBytes是精确的 schema JSON 字节数,approxTokens按 4 字节/token 粗估;不同模型、语言与 JSON 结构下真实比例会变化。 - 多进程:同源多页面通过
BroadcastChannel、focus、visibilitychange刷新;跨进程依赖下一次GET /state,没有实时订阅。 - 极端并发下的旧快照:焦点/可见性触发的 GET 可能在一次切换 POST 之后返回旧值,chip 会在下一次刷新或 watch 时纠正;服务端状态以最终响应为准。
- 资源列表内部结构:
mcpResources.layers.merge(scope, pick)是宿主内部契约;结构变化时资源列表 shadow 可能降级,但工具 guard 与 mask 不受影响。 - 反向代理:远程部署必须在代理层拦截控制路由;插件会拒绝常见代理头与端口不匹配的请求,但会剥离这些头的代理仍须自行拦截该路径。
- 路径移动:
link:依赖指向绝对路径,工作区移动后需要重新安装。 - 开关不是权限边界:见安全模型。
项目结构
plugins/rea-switch/
├── index.js # host half:状态、guard、mask、prompt、HTTP 路由
├── client.js # client half:chip、样式、i18n、状态同步
├── cordis.patch.yml # 插件插入声明
├── locale/
│ ├── zh.json # 插件管理器元数据(中文)
│ └── en.json # 插件管理器元数据(英文)
├── icon.svg # 插件图标
├── test/
│ └── host.test.mjs # 37 个 node:test 用例(host + client 渲染)
├── README.md
├── CHANGELOG.md
├── SECURITY.md
├── ARCHITECTURE.md
├── LICENSE
├── .gitignore
└── .gitattributes
npm tarball 包含 index.js、client.js、cordis.patch.yml、locale/*.json、icon.svg、README.md、LICENSE、CHANGELOG.md、SECURITY.md、ARCHITECTURE.md;test/ 与 .gitignore 留在仓库。
开发与测试
cd plugins/rea-switch
node --test
当前结果:37 个用例,36 通过,1 跳过(Windows 符号链接用例)。
- 跳过的是
S3: a symlinked state file is refused before it is read:Windows 上创建符号链接需要管理员权限或开发者模式,测试环境缺少该权限时会t.skip。这是环境相关跳过,不是失败。 - 在允许创建符号链接的 Linux/macOS/Windows 环境会执行完整 37 项。
- 新增
S5(非法serverName回退rea)与S6(mcpResources延迟激活后补 prompt shadow);两者都覆盖本版本的行为边界。 - 测试使用 Node 内置
node:test与assert/strict,无外部依赖;每个用例使用临时DSH_HOME或显式stateFile,不会读写真实 profile。 index.js的__test导出只用于测试确定性辅助函数,不属于运行时 API。- 打包检查:
npm pack --dry-run
涉及 host 契约的改动请同时更新 ARCHITECTURE.md 的 API 契约与测试映射。
故障排查
| 现象 | 排查 |
|---|---|
| 输入框没有 chip | 确认插件已加载、cordis.patch.yml 已插入、profile 依赖已安装;新会话 Hero 空态先进入已有会话 |
| chip 显示“开关不可用” | 控制路由未注册或返回 404。检查 webServer.host 是否为 loopback;非 loopback 部署不会注册路由 |
| 浏览器请求返回 403 | Host 不是 loopback、端口与 webServer.port 不一致、带代理头,或 Origin 与 Host 不一致;通过 127.0.0.1 / localhost 直连,并检查代理是否改写 Host 或添加 x-forwarded-* |
POST /set 返回 415 |
Content-Type 必须是 application/json |
POST /set 返回 413 |
请求体超过 1024 字节;只发送 {"enabled":true} 或 {"enabled":false} |
POST /set 返回 400 |
enabled 必须是布尔值;检查是否发送了字符串或数字 |
| 关闭后仍有 REA 工具出现在列表 | 查看 mode 与 visible。partial 通常来自 agent-owned 工具、mask 失败或注册表漂移;执行层 guard 仍会拦截 |
| 状态重启后丢失 | 检查 persisted、note 与状态文件权限;确认 DSH_HOME 与 stateFile 指向预期位置,文件带 version: 1;版本缺失也会按关闭处理 |
| 切换后另一个窗口未变化 | 同源页面依赖 BroadcastChannel/焦点事件;跨 DSH 进程需等待下一次 GET /state |
| 切换后工具前缀不匹配 | 检查 config.serverName 是否与 MCP server 注册名一致、大小写正确,并匹配 ^[A-Za-z0-9_-]{1,32}$;非法名称会回退 rea 并在日志中警告 |
| 升级后路由异常 | 重新加载 profile;热重载时旧 prefix 可能短暂占用路径,插件会在 150 ms 后重试一次注册 |
需要更多诊断信息时,查看 DSH 日志中的 rea-switch: 前缀:注册失败、prompt 服务缺失、mask 失败都会带 agent id 或原因记录,但不会记录请求体。
贡献
- 先读 ARCHITECTURE.md 的 invariants 与 API 契约,避免破坏 guard 的即时性。
- 行为改动附带
node:test用例;涉及安全边界时同时更新 SECURITY.md。 - 文档使用占位路径(
<WORKSPACE>、C:/path/to/...、~/.dsh/...),不要提交个人绝对路径、live 端口或本机状态文件。 - 提交前运行
node --test与npm pack --dry-run。 - 安全问题按 SECURITY.md 的渠道报告,不在公开 issue 中贴利用细节。
许可证
MIT © 2026 REA Switch contributors
English summary
REA Switch is a local DSH plugin that adds a compact chip to conversation.input.left. Clicking it toggles the REA MCP toolset for the current host:
- a global
tools.guarddeniesmcp__<server>__*and the three MCP resource tools aimed at that server on the next call; - every live agent gets a
tools.restrict({ deny })mask, an emptymcp:<server>prompt shadow, a filteredmcp-resource-serverslist, and a short disabled notice; - the chip reports the number of hidden tools and an approximate token cost based on the JSON schema size (
bytes / 4).
Modes idle, offline, restrict, partial, and guard describe how far the mask got; partial and guard still enforce through the guard. This is a local preference switch, not an access-control or permission boundary: the MCP process keeps running, local processes can call the loopback route, and shell/PTC tools can reach the server directly. The control route is registered only for an explicit loopback bind; requests need a loopback Host on the live listener port, no proxy headers, and a same-origin Origin/Sec-Fetch-Site. Bind DSH to loopback and block /dsh-local/rea-switch at any reverse proxy.
Install with a link: or packed tarball dependency, insert the plugin through cordis.patch.yml, reload the profile, and refresh the Web UI. Requires DSH 0.2.2-rc.1 (engine range >=0.2.1-rc.5 <0.3.0-0) and Node >=24.9.0. The package keeps private: true intentionally; rename it and remove that field before publishing to npm. Tests: cd plugins/rea-switch && node --test — 37 cases, 36 pass, 1 environment-dependent Windows symlink skip. Licensed MIT.
No comments yet. Be the first to write one.