diff --git a/docs/plans/2026-09-12-plugin-system-design.md b/docs/plans/2026-09-12-plugin-system-design.md new file mode 100644 index 000000000..0b39e53b6 --- /dev/null +++ b/docs/plans/2026-09-12-plugin-system-design.md @@ -0,0 +1,845 @@ +# GithubStarsManager 插件系统设计提案 + +状态:Draft +日期:2026-09-12 +目标平台:Electron Desktop +适用范围:插件发现、权限声明、受限扩展点、后续沙箱页面与 Host Capability + +## 1. 背景 + +GithubStarsManager 已具备仓库管理、AI 分析、向量搜索、Release 追踪、Gist、Fork、WebDAV、可选后端和 MCP 等能力,但目前没有面向第三方开发者的运行时扩展机制。 + +项目现有前端采用以下分层: + +```text +View → Feature Hook / ViewModel → Application Command → Service → Store +``` + +插件系统必须遵守现有分层,不允许插件绕过 Hook、Service 或 Host API 直接访问 Zustand Store、Electron IPC、凭据和底层运行环境。 + +本提案的核心原则是: + +> Plugins should request capabilities, not inherit Electron privileges. + +即:插件申请经过宿主定义和检查的能力,而不是继承 Electron 或 Node.js 权限。 + +## 2. 目标 + +插件系统最终应支持: + +1. 仓库和批量仓库操作,例如复制信息、生成报告和打开外部页面。 +2. 仓库、Release、Gist 等数据处理器。 +3. Markdown、CSV、JSON 等导出器。 +4. Smart Release Asset Recommendation 等 Release 扩展。 +5. Repo Health、替代品推荐和重复项目检测等分析功能。 +6. 由宿主代理的 GitHub、AI、Web Search 和下载能力。 +7. 使用隔离页面实现完整插件 Dashboard。 +8. 清晰展示插件权限、版本、来源和运行错误。 + +## 3. 非目标 + +Plugin API v1 不提供: + +- 任意 Node.js API。 +- 原始 Electron IPC。 +- GitHub Token、AI API Key、WebDAV 密码、aria2 Secret 等凭据。 +- 任意文件系统读写。 +- 任意进程或 Shell 执行。 +- 不受限制的网络访问。 +- 直接访问或订阅 Zustand Store。 +- 将第三方 React 组件直接 import 到主 Renderer。 +- 远程代码插件商店、自动更新或静默安装。 +- 自动运行软件安装程序。 +- 对恶意本地插件提供完整安全沙箱的承诺。 + +## 4. 风险等级与实施阶段 + +| 等级 | 能力 | 风险 | 计划 | +|---|---|---:|---| +| L1 | 仓库操作、数据处理、导出、插件存储和日志 | 低 | V1 | +| L2 | sandboxed 插件完整页面 | 中低 | V1.2 | +| L3 | GitHub、AI、Web Search、下载和受控网络 Host API | 中 | V1.1~V1.3 | +| L4 | 任意文件系统、Node、Shell、进程执行和自动安装 | 高 | 暂不支持 | + +## 4.1 威胁模型 + +插件系统至少要考虑以下攻击者和失败场景: + +| 场景 | 可能后果 | 主要防线 | +|---|---|---| +| 恶意插件 | 窃取收藏、项目偏好或凭据 | 不暴露凭据、Capability Router、数据裁剪 | +| 被入侵的正常插件 | 更新后新增外传行为 | 锁定来源、哈希/签名、权限增加重新确认 | +| 插件包构造攻击 | ZIP Slip、符号链接逃逸、覆盖宿主文件 | 安全解压、固定安装目录、路径校验 | +| 插件 UI 攻击宿主 | DOM 探测、钓鱼、伪造系统界面 | sandbox iframe、CSP、清晰插件身份标识 | +| SSRF | 探测或攻击 localhost、局域网服务 | HTTPS allowlist、DNS/IP 校验、重定向复检 | +| 资源耗尽 | 无限循环、大响应、存储膨胀 | 超时、终止、大小限制、配额和速率限制 | +| 混淆代理攻击 | 插件借宿主 GitHub/AI 能力执行越权操作 | 语义化 API、固定参数范围、用户作用域检查 | +| 日志泄露 | Token、URL 参数、提示词或私有仓库内容进入日志 | 结构化日志、字段 allowlist、统一脱敏 | + +安全模型必须明确区分: + +- **可信宿主代码**:GithubStarsManager 本身。 +- **用户主动安装的本地插件**:V1 中视为受信任或半受信任,仍执行最小权限。 +- **插件页面内容**:视为不可信内容。 +- **公共商店插件**:未来必须默认视为不可信供应链输入。 + +## 4.2 隐私原则 + +1. **数据最小化**:只向插件发送完成当前操作所需字段,不能默认发送完整 Store snapshot。 +2. **目的绑定**:插件获得的输入仅用于用户当前触发的操作;后台批量读取需单独声明。 +3. **显式触发优先**:V1 action 由用户点击执行,不允许静默监听所有 Store 变化。 +4. **本地优先**:没有联网 capability 的插件必须能够在完全离线状态下运行。 +5. **凭据零暴露**:插件只得到宿主代理结果,任何情况下都不返回 Token、Key、Cookie 或认证 Header。 +6. **私有仓库敏感标记**:向插件传递私有仓库数据前需单独提示;未来可设计 `privateRepositories:read` 独立权限。 +7. **可撤销**:用户可以停用插件、撤销权限并删除插件数据。 +8. **可解释**:设置页显示插件最近调用过哪些 capability、何时联网、目标域名和是否成功。 +9. **可删除**:卸载时提供“保留数据”或“同时删除插件数据”的明确选择。 +10. **不做遥测默认同意**:插件不得继承宿主遥测通道;插件自己的遥测必须声明网络域名和用途。 + +推荐将传给插件的仓库类型单独定义,而不是复用内部 `Repository`: + +```ts +interface PluginRepositorySummary { + id: number; + name: string; + fullName: string; + htmlUrl: string; + description: string | null; + language: string | null; + stars: number; + forks: number; + archived: boolean; + pushedAt: string | null; + latestReleaseAt: string | null; + isPrivate: boolean; +} +``` + +默认不包含 README 全文、本地备注、AI 对话、访问历史、用户身份、内部数据库字段和任何凭据。需要更详细内容时使用独立 capability 按次请求。 + +## 4.3 权限组合风险 + +权限不能只逐项评估,还要评估组合后的能力: + +| 权限组合 | 实际风险 | 建议 | +|---|---|---| +| `repositories:read` + 任意联网 | 可外传全部收藏 | 禁止任意联网 | +| `privateRepositories:read` + 域名联网 | 可外传私有项目元数据 | 高风险提示,默认拒绝后台调用 | +| `ai.generate` + README 内容 | 内容会发送给用户配置的 AI Provider | 调用前说明 Provider 和数据范围 | +| `releases:read` + `downloads:create` | 可诱导下载恶意资产 | 宿主校验来源并要求用户确认 | +| `storage` + 后台运行 | 可持续建立用户画像 | V1 不允许后台常驻触发 | +| `external:open` + 可控 URL | 可用于钓鱼 | HTTPS、URL 展示、危险 scheme 拒绝 | + +安装确认页应突出展示高风险组合,而不是只列一串技术权限名。 + +## 5. 总体架构 + +```text +┌──────────────────────── GithubStarsManager ────────────────────────┐ +│ │ +│ React Renderer Electron Main Process │ +│ ┌────────────────────┐ ┌────────────────────────┐ │ +│ │ Settings Plugin UI │ │ PluginManager │ │ +│ │ Plugin Registry │──受限 IPC──────→│ Manifest Validation │ │ +│ │ Contribution Slots │←───────────────│ Permission Decisions │ │ +│ └────────────────────┘ │ Lifecycle / Timeouts │ │ +│ │ └───────────┬────────────┘ │ +│ │ sandboxed iframe │ │ +│ ↓ ↓ │ +│ ┌────────────────────┐ ┌────────────────────────┐ │ +│ │ Plugin Page │←─postMessage───→│ Capability Router │ │ +│ │ No Node / No Token │ │ GitHub / AI / Download │ │ +│ └────────────────────┘ └───────────┬────────────┘ │ +│ │ │ +│ Isolated Plugin Runtime │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### 5.1 Electron PluginManager + +`PluginManager` 负责: + +- 扫描 `/plugins//`。 +- 读取并验证 `manifest.json`。 +- 检查 `manifestVersion`、`apiVersion`、插件 ID 和入口路径。 +- 管理插件启用、停用、卸载和错误状态。 +- 启动和终止隔离运行时。 +- 注册并清理插件贡献项。 +- 对每次 Host API 调用执行权限检查。 +- 为调用设置超时、请求大小和响应大小限制。 + +建议目录: + +```text +electron/plugins/ +├─ pluginManager.js +├─ pluginRuntime.js +├─ manifestSchema.js +├─ pluginProtocol.js +├─ capabilityRouter.js +└─ pluginStorage.js +``` + +### 5.2 插件运行时 + +第一阶段可使用 `worker_threads.Worker` 获得: + +- 生命周期隔离。 +- 主线程阻塞隔离。 +- 插件崩溃隔离。 +- 超时后终止运行时。 + +Worker 不是安全沙箱。运行普通 Node.js 插件代码时,它仍可能读取文件、环境变量或直接联网。因此 V1 必须将本地插件明确标记为“受信任或半受信任插件”,不能宣称能够安全执行恶意插件。 + +如果未来开放公共第三方生态,应评估独立 utility process、OS sandbox、WASM 或声明式插件等更强边界。 + +### 5.3 Renderer PluginHost + +Renderer 只获取经过序列化和过滤的数据: + +```text +src/plugins/ +├─ types.ts +├─ pluginClient.ts +├─ pluginRegistry.ts +├─ slots.ts +└─ hooks/ + └─ usePluginActions.ts +``` + +插件运行时对象、函数、Worker 和 Electron 对象不得进入 Zustand Store。Renderer 中的 `PluginRegistry` 是可重建的内存状态,启动时通过 IPC 向 `PluginManager` 查询。 + +## 6. 插件包格式 + +```text +repo-health/ +├─ manifest.json +├─ worker.js +└─ ui/ + ├─ index.html + ├─ index.js + └─ styles.css +``` + +最小清单示例: + +```json +{ + "manifestVersion": 1, + "id": "com.example.markdown-exporter", + "name": "Markdown Exporter", + "version": "0.1.0", + "description": "Export selected repositories as Markdown", + "author": "Example", + "apiVersion": "1", + "main": "worker.js", + "permissions": ["repositories:read"], + "contributes": { + "repositoryActions": [ + { + "id": "export-markdown", + "title": "导出为 Markdown", + "placement": "bulk-toolbar" + } + ] + } +} +``` + +完整插件页面示例: + +```json +{ + "manifestVersion": 1, + "id": "com.example.repo-health", + "name": "Repo Health", + "version": "0.1.0", + "apiVersion": "1", + "permissions": ["repositories:read"], + "contributes": { + "pages": [ + { + "id": "dashboard", + "title": "Repository Health", + "entry": "ui/index.html" + } + ] + } +} +``` + +清单校验必须满足: + +- 插件 ID 唯一且安装后不可更改。 +- 入口文件必须位于插件目录内。 +- 拒绝绝对路径、`..` 路径穿越和符号链接逃逸。 +- 未知权限、未知贡献类型和不兼容 API 版本直接拒绝。 +- 同一 major API 内保持向后兼容;breaking change 提升 major。 + +## 7. 扩展点 + +### 7.1 V1 扩展点 + +```ts +type PluginPlacement = 'repository-card' | 'bulk-toolbar'; + +interface RepositoryActionContribution { + pluginId: string; + actionId: string; + title: string; + placement: PluginPlacement; +} + +interface RepositoryActionInput { + repositories: PluginRepository[]; +} + +type PluginActionResult = + | { type: 'text'; content: string; suggestedAction?: 'copy' | 'save' } + | { type: 'open-external'; url: string } + | { type: 'notice'; level: 'info' | 'warning' | 'error'; message: string }; +``` + +插件返回结构化意图,由宿主决定是否复制、保存、打开页面或显示通知。插件本身不直接操作宿主 UI、剪贴板和文件系统。 + +### 7.2 后续扩展点 + +- `repositoryProcessors` +- `releaseProcessors` +- `exporters` +- `pages` +- `repositoryBadges` +- `settings` + +每个新增扩展点都必须有独立输入、输出、权限和失败行为,不能提供一个通用的“运行任意宿主命令”接口。 + +## 8. 权限和 Host Capability + +### 8.1 基础权限 + +| 权限 | 含义 | +|---|---| +| `repositories:read` | 读取脱敏后的仓库信息 | +| `repositories:write` | 通过宿主命令修改允许的标签或分类 | +| `releases:read` | 读取 Release 和 Asset 元数据 | +| `gists:read` | 读取脱敏后的 Gist 元数据 | +| `storage` | 使用插件隔离存储 | +| `clipboard:write` | 请求宿主写入剪贴板 | +| `external:open` | 请求宿主打开经过校验的 HTTPS URL | +| `downloads:create` | 用户确认后,由宿主下载已验证属于当前 Release 的 Asset | + +### 8.2 语义化 Capability 优先 + +优先提供: + +```text +github.searchRepositories +github.getRepository +github.getRelease +ai.generate +web.search +downloads.downloadReleaseAsset +``` + +而不是直接提供: + +```text +fetch(anyUrl) +fs.writeFile(anyPath) +exec(anyCommand) +``` + +GitHub 和 AI 请求由现有宿主 Service 代理。插件只能得到结果,不能得到 Token、Key 或自定义认证 Header。 + +### 8.3 域名级网络权限 + +仅当语义化 Host API 无法满足集成需求时,才考虑: + +```json +{ + "permissions": [ + "network:api.github.com", + "network:gitlab.com" + ] +} +``` + +受控 HTTP API 必须同时执行: + +- 仅允许 HTTPS。 +- hostname 必须与 manifest 授权项精确匹配。 +- 限制 HTTP method。 +- 限制请求体和响应体大小。 +- 设置连接与总响应超时。 +- 禁止自定义敏感 Header。 +- 禁止凭据自动附加。 +- 禁止 `localhost`、loopback、局域网、link-local 和保留地址。 +- 禁止 `file:`、`ftp:` 等非 HTTPS 协议。 +- 每次重定向重新校验目标域名和 IP。 +- DNS 解析后再次阻止私有地址,降低 DNS rebinding 和 SSRF 风险。 + +不提供 `network:any`。 + +### 8.4 永不直接开放的权限 + +```text +node:execute +shell:execute +process:spawn +filesystem:any +network:any +environment:read +credentials:read +ipc:any +``` + +## 9. 完整插件页面 + +完整页面不直接 import 插件的 React、Vue 或 Svelte bundle。宿主只加载插件自己的隔离页面: + +```text +Host Route / Page Slot + ↓ +sandboxed iframe + ↕ postMessage +Plugin Page Bridge + ↓ +Capability Router +``` + +插件页面应满足: + +- 无 Node.js。 +- 无 Electron API。 +- 无宿主 Zustand Store。 +- 无凭据。 +- 无原始 IPC。 +- 无任意文件系统。 +- 默认无任意网络权限。 +- 使用严格 CSP。 +- `postMessage` 校验 source、origin、pluginId、requestId 和消息 schema。 + +完整 UI 与高权限是两件不同的事。插件可以展示复杂 Dashboard,但仍只通过受限 Capability 获取数据。 + +## 10. Smart Release Recommendation 能力映射 + +该插件可作为后续功能验证样例: + +```text +宿主提供 +├─ repository 元数据 +├─ release 元数据 +├─ asset 列表 +└─ hostEnvironment: os + arch + +插件返回 +├─ recommendedAssetId +├─ confidence +└─ reason + +宿主负责 +├─ 验证 asset 属于指定 release +├─ 展示推荐原因 +├─ 用户确认 +├─ 下载路径选择 +├─ 下载进度 +└─ 可选哈希校验 +``` + +第一阶段只推荐和下载,不自动安装。未来若支持安装,也必须由宿主弹出包含来源、文件名和 SHA-256 的单次确认,插件不得直接执行安装程序。 + +## 11. 生命周期 + +```text +discovered + → validated + → disabled / enabled + → activating + → active + → deactivating + → disabled + +任何阶段失败 + → error +``` + +要求: + +- 插件默认禁用或安装后由用户明确确认权限。 +- `activate` 超时后终止运行时并清理注册项。 +- `deactivate` 必须移除插件注册的全部 action、processor 和 page。 +- 单个插件错误不能阻止宿主启动。 +- 插件错误必须可在设置页查看,但日志要脱敏。 + +## 12. 数据与持久化 + +```text +/ +├─ plugins// 插件文件 +├─ plugin-data/.json 插件隔离数据 +└─ plugins-state.json 启用状态、授权权限、错误状态 +``` + +不将插件运行时对象写入现有 Zustand persist snapshot。插件状态由 Electron 管理,Renderer 启动后查询并构造内存 registry。 + +插件存储必须: + +- 按插件 ID 隔离。 +- 限制键和值大小及总配额。 +- 只接受 JSON 可序列化数据。 +- 使用安全写入策略,避免中断导致文件损坏。 + +## 13. IPC 边界 + +`electron/preload.js` 只暴露具体操作,不暴露 `ipcRenderer`: + +```ts +interface ElectronPluginApi { + list(): Promise; + installFromDirectory(): Promise; + enable(pluginId: string): Promise; + disable(pluginId: string): Promise; + uninstall(pluginId: string, removePluginData?: boolean): Promise; + runAction(request: RunPluginActionRequest): Promise; +} +``` + +主进程必须重新验证所有 Renderer 输入,不能因为请求来自宿主页面就跳过 schema 校验。 + +## 14. 安装安全 + +V1 开发模式只支持从本地目录加载。正式安装流程再增加: + +- 用户主动选择文件或目录。 +- 安装前展示 ID、作者、版本、来源、SHA-256 和全部权限。 +- 限制压缩包大小、解压后大小和文件数量。 +- 防止 ZIP Slip、路径穿越和符号链接逃逸。 +- 插件安装目录不可由 manifest 自定义。 +- 更新后权限增加时必须重新确认。 +- 卸载前先停用并终止插件运行时。卸载时提供“保留数据”或“同时删除插件数据” + (隔离 Storage 与日志)的明确选择。 + +插件商店、签名、发布者身份和自动更新属于 V2。 + +## 15. 插件商城审核与发布治理 + +插件商城采用四层防线: + +```text +运行时技术隔离 + → Capability 权限控制 + → 自动化安全扫描 + → 维护者人工审核 + 版本签名 +``` + +人工审核用于发现恶意行为、权限与功能不匹配、低质量实现、抄袭和侵权,但不能替代运行时隔离。审核通过的插件也可能因开发者账号被盗、依赖投毒或后续版本变更而变得不安全。 + +### 15.1 信任等级 + +| 等级 | 含义 | 安装方式 | +|---|---|---| +| `Official` | 由项目维护者开发和维护 | 商城正常安装 | +| `Verified` | 发布者身份已验证,代码和版本经过深度审核 | 商城正常安装 | +| `Reviewed` | 当前版本经过人工审核 | 商城正常安装 | +| `Community` | 仅通过自动检查,未完成人工深审 | 商城警告后安装或暂不开放 | +| `Local` | 用户侧载的本地插件,未经商城审核 | 仅开发者模式 | + +信任等级只描述来源和审核深度,不能解锁更强权限。`Official` 或 `Verified` 插件同样不能直接获得 Node、凭据、任意网络、文件系统或 Shell 权限。 + +### 15.2 发布审核流程 + +```text +开发者提交源码仓库 + 构建产物 + manifest + → 自动校验 manifest、API 版本和权限 + → 固定依赖并生成 SBOM/依赖清单 + → 静态扫描危险 API、秘密信息和已知漏洞 + → 在隔离环境运行测试和行为检查 + → 维护者人工审查用途、源码、权限和网络目标 + → 从审核过的源码进行可复现构建(优先) + → 记录 SHA-256、审核结论和版本签名 + → 发布不可变版本 +``` + +每个插件版本单独审核。旧版本通过审核不代表新版本自动可信。 + +### 15.3 自动检查 + +至少检查: + +- Manifest schema、插件 ID、API 版本和权限声明。 +- 包体积、文件数量、路径穿越和符号链接。 +- `child_process`、`shell`、`eval`、动态代码生成等危险调用。 +- 未声明的网络目标和硬编码上传端点。 +- 明文 Token、Key、私钥和测试凭据。 +- 第三方依赖锁文件、已知高危漏洞和安装脚本。 +- 压缩或混淆代码;商城版本原则上拒绝无法合理审查的混淆产物。 +- 源码与构建产物是否对应,优先要求可复现构建。 +- 插件 UI 的 CSP、外部资源和 `postMessage` 使用方式。 + +自动扫描命中不一定直接判定恶意,但必须阻止自动发布并进入人工复核。 + +### 15.4 人工审核清单 + +审核者至少确认: + +1. 插件描述与实际行为一致。 +2. 每项权限都有可验证的功能理由。 +3. 权限组合没有不必要的数据外传能力。 +4. 网络域名属于插件声明的服务,不包含跟踪或隐藏上传地址。 +5. 插件不会收集超出功能需要的数据。 +6. 私有仓库、README、AI 输入等敏感数据有明确提示。 +7. 插件停用和卸载后不保留后台任务。 +8. 错误和日志不会包含凭据或用户私有内容。 +9. UI 不冒充宿主、系统权限弹窗或其他可信插件。 +10. 许可证、名称、图标和代码来源不存在明显侵权。 + +### 15.5 更新、权限变化和回滚 + +- 商城版本不可原地覆盖;同一版本号必须对应固定哈希。 +- 每个新版本重新运行自动检查,并按风险决定全量或差异人工审核。 +- 新增权限、扩大域名或改变数据用途时必须重新人工审核。 +- 客户端更新前展示新增权限,并要求用户重新同意。 +- 未增加权限的更新也必须显示版本、发布者和变更说明。 +- 商城保留最近的安全版本,支持快速回滚。 +- 插件被撤回后停止新安装;对已安装用户展示明确告警,但不静默删除本地数据。 + +### 15.6 签名、索引和撤销 + +商城索引中的每个版本至少记录: + +```json +{ + "pluginId": "com.example.repo-health", + "version": "1.2.0", + "sha256": "...", + "publisher": "example", + "trustLevel": "Reviewed", + "permissions": ["repositories:read"], + "reviewedCommit": "...", + "reviewedAt": "2026-09-12T00:00:00Z", + "signature": "..." +} +``` + +客户端安装前验证索引签名和插件包 SHA-256。签名私钥不能存放在仓库或普通 CI 日志中,应使用专门的 secrets/签名服务,并保留密钥轮换方案。 + +商城还需要可签名的撤销列表,记录: + +- 被撤销的插件版本。 +- 撤销原因和公告链接。 +- 建议回退版本。 +- 是否存在已知数据泄露或远程执行风险。 + +### 15.7 审核透明度和隐私 + +- 插件详情页公开权限、联网域名、数据用途、源码地址、版本哈希和审核等级。 +- 显示“审核的是哪个版本”,不能只给插件永久的审核徽章。 +- 审核者不得要求或接触用户真实 Token、私有仓库数据和生产凭据。 +- 行为测试使用合成数据和专用测试账户。 +- 审核日志不得公开插件作者或测试者的敏感信息。 +- 提供安全问题举报、紧急下架和发布者申诉流程。 + +### 15.8 治理边界 + +初期维护者人数有限时,不应立刻承诺公共商城。推荐顺序: + +```text +本地开发插件 + → 官方示例插件 + → 少量 Reviewed 插件清单 + → 签名商城 + → 分级审核和社区审核者 +``` + +社区审核者可以参与代码审查,但最终签名和发布权限应限制在最小维护者集合中,并要求双人复核高风险权限插件。 + +## 16. V1 实施切片 + +第一轮 PR 只完成“发现和验证”,不执行插件代码。 + +### 16.1 范围 + +新增: + +```text +electron/plugins/manifestSchema.js +electron/plugins/pluginManager.js +electron/plugins/manifestSchema.test.js +electron/plugins/pluginManager.test.js +``` + +修改: + +```text +electron/main.js +electron/preload.js +package.json +``` + +功能: + +1. 从 `/plugins` 扫描一级子目录。 +2. 读取并验证 `manifest.json`。 +3. 返回合法插件和可解释的无效插件记录。 +4. 通过 `plugins:list` IPC 向 Renderer 暴露只读列表。 +5. 此阶段没有 `require()`、`import()` 或 Worker 执行插件入口。 + +### 16.2 第一轮明确不做 + +- 插件安装 UI。 +- 插件执行。 +- 权限授权 UI。 +- Zustand 接入。 +- 仓库卡片或批量工具栏扩展点。 +- 完整插件页面。 +- 网络 Host API。 + +### 16.3 第一轮验收标准 + +- 合法 manifest 被列出。 +- 缺失字段、错误类型和未知字段得到稳定错误码。 +- 重复插件 ID 被拒绝。 +- 不兼容的 `manifestVersion` 或 `apiVersion` 被拒绝。 +- 入口路径穿越插件目录时被拒绝。 +- 不存在的入口文件被拒绝。 +- 一个损坏插件不会阻止其他插件被扫描。 +- 不读取插件代码内容,不执行插件代码。 +- IPC 返回值经过序列化,不泄露本地绝对路径。 +- 现有 Electron MCP、代理、托盘和前端功能不受影响。 + +## 17. 后续版本路线 + +### V1:基础插件协议 + +- Manifest discovery 和 validation。 +- 启用、停用和卸载。 +- 受信任本地插件运行时。 +- Repository actions。 +- Processors 和 exporters。 +- 插件隔离 storage 和 logging。 + +### V1.1:Release 能力 + +- Release processors。 +- `github.*` Host API。 +- `downloads.downloadReleaseAsset`。 +- Smart Release Recommendation 示例插件。 + +### V1.2:完整插件页面 + +- Plugin page contribution。 +- sandboxed iframe。 +- `postMessage` bridge。 +- CSP 和消息 schema。 + +### V1.3:高级宿主能力 + +- `ai.generate`。 +- `web.search`。 +- 必要时增加 domain-scoped HTTPS。 + +### V2:公共生态 + +- 自动安全扫描与人工审核工作流。 +- 按版本记录的审核等级和不可变 SHA-256。 +- 插件包与商城索引签名。 +- 发布者身份、密钥轮换和撤销机制。 +- 插件 registry/store、更新和回滚。 +- 权限变化重新审核并要求用户重新确认。 +- 更强的插件运行隔离。 + +## 18. 测试策略 + +### 单元测试 + +- Manifest schema 边界。 +- 路径规范化和目录逃逸。 +- API 版本兼容。 +- 权限与 capability 映射。 +- 生命周期状态转换。 +- 插件 storage 配额。 +- 网络目标校验和 SSRF 阻断。 + +### Electron 测试 + +- IPC 参数校验。 +- PluginManager 扫描失败隔离。 +- 插件运行时超时与终止。 +- 禁用后贡献项全部清理。 + +### Renderer 测试 + +- Web 环境显示“插件仅桌面版可用”。 +- 插件 action 能正确出现在指定 slot。 +- action 错误只显示为宿主通知。 +- sandbox 页面消息来源和 schema 校验。 + +### 回归验证 + +- `npm run lint` +- `npm run typecheck` +- `npm run check:boundaries` +- `npm run test:run` +- `npm run build` + +## 19. 提交上游前的沟通方式 + +插件系统属于跨 Electron、Renderer、安全和公共 API 的大型改动。正式实现前应先提交 Discussion 或设计 Issue,明确: + +- Electron-only 起步。 +- 第一轮只做 manifest discovery,不执行第三方代码。 +- 不暴露凭据、Node、任意网络或原始 IPC。 +- 每个阶段独立 PR,可单独审查和回滚。 +- Worker 不被描述为恶意代码安全沙箱。 +- 完整 UI、联网和商店均不进入第一轮 PR。 +- 商城采用自动扫描、人工审核和版本签名,但审核不会扩大插件运行权限。 + +这能让维护者先确认方向,避免一次修改大量文件后因项目范围或安全模型不被接受。 + +## 20. 待维护者确认的问题 + +1. 项目是否愿意接受 Electron-only 的插件能力,还是要求 Web 端也具备等价体验? +2. V1 插件是否明确限定为用户主动安装的受信任本地代码? +3. 第一个扩展点选择 `bulk-toolbar` 是否符合产品优先级? +4. 是否接受插件状态独立保存在 Electron `userData`,不进入 Zustand 和后端同步? +5. Plugin API 是否作为独立长期兼容的公共接口维护? +6. 完整插件页面使用 iframe 是否符合当前 UI 和 CSP 方向? +7. 项目是否愿意长期承担插件审核、签名密钥、撤销公告和安全响应责任? +8. 高风险插件是否要求至少两名维护者复核? + +## 21. 开发起点 + +在维护者认可总体方向前,可以安全开始的工作只有第一轮“Manifest discovery”切片: + +1. 定义最小 manifest schema 和稳定错误码。 +2. 编写失败测试:合法、字段缺失、版本不兼容、重复 ID、路径穿越和损坏 JSON。 +3. 实现纯扫描和验证逻辑。 +4. 增加只读 `plugins:list` IPC。 +5. 运行 Electron 测试和全量回归。 + +完成这一切后,宿主仍不会执行任何插件代码,因此安全影响和审查范围都保持可控。 + +## 22. 实施状态 + +截至 2026-09-13: + +- 第一轮 Manifest discovery 与 validation 已实现。 +- V1 本地插件协议已实现:安装、默认禁用、权限确认、启停、卸载(可选删除隔离数据)、 + Worker 生命周期、Repository Actions、Processors、Exporters、隔离 Storage 和脱敏日志。 + `repositories:read` 覆盖已收藏的私有仓库元数据;`repositories:write` 与 `gists:read` + 仍为预留声明。 +- V1 明确仍是受信任本地插件模型;Worker 不作为恶意代码安全边界。 +- V1.1 Release 能力已实现:Release processors、基于宿主脱敏快照的只读 `github.*`、 + 用户确认后由宿主执行的 Release Asset 下载,以及 Smart Release Recommendation 示例插件。 +- V1.2 完整插件页面已实现:页面贡献、`plugin-page:` 本地资源协议、sandboxed iframe、 + CSP、经校验的 `postMessage` Bridge,以及 Repo Health 页面示例。页面型插件可无 Worker; + 若插件同时包含 Worker,V1 的受信任本地代码限制仍然适用。 +- V1.3 页面高级宿主能力已实现:逐次确认的 `ai.generate` 与 `web.search`; + 用户自行配置 SearXNG HTTPS 实例,插件不可指定任意联网目标。Worker 不获得 + 这两项能力,继续按 V1 受信任本地代码模型运行。V2 公共生态尚未实现。 + +V1 开发协议和示例见 `docs/plugins/v1-development.md` 与 +`examples/plugins/markdown-exporter`;V1.1 示例见 +`examples/plugins/smart-release-recommender`。 +V1.2 页面示例见 `examples/plugins/repo-health-page`。 +V1.3 页面能力协议与隐私限制见 `docs/plugins/v1-development.md`。 diff --git a/docs/plugins/v1-development.md b/docs/plugins/v1-development.md new file mode 100644 index 000000000..546a82193 --- /dev/null +++ b/docs/plugins/v1-development.md @@ -0,0 +1,258 @@ +# V1 本地插件开发指南 + +V1 插件是用户明确安装并授权的本地 Node.js 插件。每个插件运行在独立 +`worker_threads.Worker` 中,用于隔离生命周期、崩溃和超时。 + +Worker 不是安全沙箱。插件代码仍可能直接使用 Node.js 访问本机文件、环境变量和网络, +因此只能启用已经审查并信任的本地插件。宿主不会主动把 GitHub Token、AI Key、 +Electron API 或 Zustand Store 传给插件。 + +V1.1 增加了 Release processor、只读语义化 `github.*` Host API,以及必须由用户确认 +保存位置的 Release Asset 下载。这里的 GitHub API 查询宿主当前已加载的脱敏快照, +不会把 Token、认证 Header 或任意网络请求能力交给插件。 + +V1.2 增加页面贡献。仅含页面、没有 `main` 的插件不会启动 Node Worker;页面在 +`sandbox="allow-scripts"` iframe 中运行,不能读取宿主 DOM、Electron API、Token 或 +Zustand Store。若同时声明 `main`,该 Worker 仍是受信任本地代码,页面隔离不改变其权限。 + +## 最小目录 + +```text +my-plugin/ +├─ manifest.json +└─ worker.js +``` + +可以从设置页的“插件 → 安装本地插件”选择该目录。安装完成后插件默认禁用, +用户确认 Manifest 中列出的全部权限后才会启动。卸载时宿主会删除插件安装目录, +并让用户选择“保留数据并卸载”或“卸载并删除数据”;后者会删除该插件的隔离 +Storage 文件和日志。 + +## 权限 + +V1 接受以下权限。未实现的项可以出现在 Manifest 中,但不会提供对应 Host API: + +| 权限 | V1 行为 | +|---|---| +| `repositories:read` | 读取宿主已加载的仓库快照,**包含私有仓库元数据**。启用时设置页会单独提示。 | +| `privateRepositories:read` | 预留。当前没有独立过滤;声明后与 `repositories:read` 一样覆盖私有仓库。 | +| `releases:read` | 读取宿主已加载的 Release / Asset 快照。 | +| `storage` | 按插件隔离的 JSON 存储。 | +| `clipboard:write` | 允许 Action 建议复制文本。 | +| `external:open` | 允许 Action 打开无凭据的 HTTPS URL。 | +| `downloads:create` | 允许设置页显示“宿主下载”;用户仍需确认保存位置。 | +| `ai:invoke` | 仅页面 Bridge:逐次确认后调用当前 AI Provider。Worker 不获得该方法。 | +| `web:search` | 仅页面 Bridge:逐次确认后查询用户配置的 SearXNG。Worker 不获得该方法。 | +| `repositories:write` | 预留,V1 不提供写入仓库的 Host API。 | +| `gists:read` | 预留,V1 不提供 Gist 查询。 | +| `network:` | 预留,V1.3 不提供通用网络请求。 | + +## Manifest + +```json +{ + "manifestVersion": 1, + "id": "com.example.markdown-exporter", + "name": "Markdown Exporter", + "version": "0.1.0", + "apiVersion": "1", + "main": "worker.js", + "permissions": ["repositories:read", "storage", "clipboard:write"], + "contributes": { + "repositoryActions": [ + { + "id": "copy-repository", + "title": "Copy repository", + "placement": "repository-card" + } + ], + "repositoryProcessors": [ + { "id": "health", "title": "Repository health" } + ], + "releaseProcessors": [ + { "id": "recommend-asset", "title": "Recommend asset" } + ], + "exporters": [ + { + "id": "markdown", + "title": "Markdown", + "fileExtension": ".md", + "mimeType": "text/markdown" + } + ] + } +} +``` + +插件 ID、版本、权限、贡献点和入口路径都会被严格校验。入口必须位于插件目录中; +绝对路径、路径穿越和符号链接会被拒绝。 + +## Worker API + +入口使用 CommonJS 导出: + +```js +module.exports = { + async activate(context) {}, + async deactivate() {}, + async runAction({ actionId, repositories }) {}, + async runProcessor({ processorId, repositories }) {}, + async runReleaseProcessor({ processorId, repository, release, hostEnvironment }) {}, + async runExporter({ exporterId, repositories }) {}, +}; +``` + +`activate(context)` 只收到插件 ID、已授权权限、脱敏日志和按插件隔离的 Storage: + +```js +await context.storage.set('settings', { enabled: true }); +const settings = await context.storage.get('settings'); +await context.storage.delete('settings'); +await context.log.info('Activated', { enabled: settings?.enabled }); +``` + +只有声明并获批 `storage` 权限后才会提供 `context.storage`。日志中的常见 Token、 +Authorization Header 和敏感字段会被脱敏,但插件仍不应主动记录隐私数据。 + +声明 `repositories:read` 或 `releases:read` 后,插件可获得对应的只读查询: + +```js +const matches = await context.github.searchRepositories('electron', { limit: 20 }); +const repository = await context.github.getRepository(repositoryId); +const release = await context.github.getRelease(releaseId); +``` + +这些查询只访问宿主内存中的脱敏快照,不会发起实时 GitHub API 请求。 +`repositories:read` 覆盖你已收藏的私有仓库元数据;V1 没有单独的私有仓库过滤。 + +## 返回结构 + +Repository Action 返回宿主可理解的意图: + +```js +return { type: 'text', content: '...', suggestedAction: 'copy' }; +return { type: 'notice', level: 'info', message: 'Done' }; +return { type: 'open-external', url: 'https://example.com' }; +``` + +- `copy` 需要 `clipboard:write`。 +- `open-external` 需要 `external:open`,且只接受无凭据的 HTTPS URL。 +- 插件不能返回 HTML 或任意宿主命令。 + +Processor 只能返回本次输入中已有仓库 ID 的摘要、标签和分类建议: + +```js +return { + repositories: [{ id: 1, summary: 'Active', tags: ['healthy'] }] +}; +``` + +Exporter 返回纯文本和可选文件名;宿主根据 Manifest 决定扩展名和 MIME 类型: + +```js +return { content: '# Repositories', fileName: 'stars.md' }; +``` + +Release processor 返回当前 Release 内的一个 Asset ID、置信度和简短原因: + +```js +return { + recommendedAssetId: 123, + confidence: 0.92, + reason: 'Windows x64 installer' +}; +``` + +宿主会验证 Asset 归属。只有插件声明并获批 `downloads:create` 后,推荐结果才显示 +“宿主下载”;用户点击后仍需在原生保存对话框中确认文件名、来源和保存位置。插件拿不到 +下载 URL 与本地路径,也不能自动安装文件。 + +## 限制 + +- 单次 Action/Processor/Exporter 最多接收 1000 个仓库。 +- Action/Processor 结果上限 1 MiB,Exporter 文本上限 5 MiB。 +- 单个 Storage 值上限 64 KiB,每个插件总配额 1 MiB。 +- 本地安装包最多 2000 个文件、总计 50 MiB,不允许符号链接。 +- 每次运行时调用默认 5 秒超时;超时或协议错误会终止该插件运行时。 + +完整示例见 `examples/plugins/markdown-exporter` 和 +`examples/plugins/smart-release-recommender`。 + +## V1.2 完整插件页面 + +页面型 Manifest 可省略 `main`: + +```json +{ + "manifestVersion": 1, + "id": "com.example.repo-health-page", + "name": "Repo Health Page", + "version": "0.1.0", + "apiVersion": "1", + "permissions": ["repositories:read"], + "contributes": { + "pages": [{ "id": "dashboard", "title": "Repository Health", "entry": "ui/index.html" }] + } +} +``` + +启用后,在“设置 → 插件”点击对应的“打开页面”。宿主通过 `plugin-page:` 本地协议读取 +页面目录内的 HTML、JS、CSS 和图片;禁止路径穿越及越过页面目录的符号链接。iframe 的 +CSP 默认拒绝联网、嵌套页面、Worker、表单和内联脚本。React/Vue 等框架应预先构建成 +静态文件,使用相对资源路径,不应引入 CDN 运行时代码。 + +页面与宿主通过有版本边界的 `postMessage` 协议通信。宿主在加载时发送 +`plugin-page:init`,包含当前页面的临时 token。页面请求格式为: + +```js +window.parent.postMessage({ + type: 'plugin-page:request', + pluginId: 'com.example.repo-health-page', + pageId: 'dashboard', + requestId: 'request_1', + token, + method: 'repositories.search', + args: { query: 'react', limit: 20 }, +}, '*'); +``` + +可用方法:`repositories.search`、`repositories.get`、`releases.get`、 +`storage.get`、`storage.set`、`storage.delete`。每次请求都在宿主主进程重新检查 +插件状态、页面声明、参数 schema 和 Manifest 权限。返回的是脱敏后的宿主内存快照, +并非实时 GitHub API;没有 `repositories:read`、`releases:read` 或 `storage` 权限时, +对应方法会被拒绝。页面消息还受来源、临时 token、大小、并发数和频率限制。 + +完整可安装示例见 `examples/plugins/repo-health-page`。页面关闭、插件停用或卸载后, +宿主不再提供该页面资源和能力调用。V1.2 仍是本地插件开发功能,不代表插件商店审核 +或对所有恶意本地代码提供完整安全沙箱。 + +## V1.3 页面高级能力 + +页面型插件可额外声明 `ai:invoke` 和/或 `web:search`。这两项只通过受限页面的 +`postMessage` Bridge 提供,不向插件暴露 AI Key、用户配置的搜索服务凭据、任意 +`fetch` 或 Node API。带 `main` 的本地 Worker 仍是受信任代码;它不会因为声明这些 +权限而获得对应的 Worker `context` 方法。 + +```js +// 每次请求均按 V1.2 的消息格式发送,method / args 换为: +method: 'ai.generate', +args: { system: 'Summarize this repository', user: 'Public repository details', maxTokens: 500 }, +// 成功时返回 value: 'generated text' + +method: 'web.search', +args: { query: 'open source alternatives', limit: 5 }, +// 成功时返回 value: [{ title, url, snippet }] +``` + +宿主先检查插件启用状态、页面声明、参数和 Manifest 权限,再逐次显示完整 AI 输入 +或搜索词,说明目标服务,要求用户确认。拒绝则不会向外发送。AI 仅使用宿主当前 +激活的 Provider;宿主可能通过已配置的后端代理转发,插件只收到生成文本。插件 +AI 请求正文不会写入调试日志;关闭页面会中止进行中的 AI 请求。未配置 Provider +时 AI 调用失败,不会自动切换到其他服务。 + +网页搜索由用户在“设置 → 插件”填写可信的 SearXNG HTTPS 实例地址;默认关闭, +没有预设公共实例。该实例须启用 JSON 输出。插件不能指定域名或 URL,只能提交 +最长 200 字符的搜索词及 1~10 条结果上限;宿主拒绝非 HTTPS、带凭据和本地 +地址,联网请求不跟随重定向,并限制超时与响应大小。搜索词会发送给用户所配置的 +实例及其实际使用的搜索引擎,请不要在未经同意时把私有仓库、个人备注或密钥放进 +搜索词。`network:` 仅是 Manifest 保留声明,V1.3 不提供通用网络请求 API。 diff --git a/electron/main.js b/electron/main.js index 58210b2a0..44e45e093 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,9 +1,12 @@ -const { app, BrowserWindow, Menu, Tray, nativeImage, nativeTheme, shell, globalShortcut, ipcMain, net, safeStorage } = require('electron'); +const { app, BrowserWindow, Menu, Tray, nativeImage, nativeTheme, shell, globalShortcut, ipcMain, dialog, net, protocol, safeStorage } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const isDev = process.env.NODE_ENV === 'development'; const { createMcpLocalServer } = require('./mcpLocalServer'); +const { createPluginManager } = require('./plugins/pluginManager'); +const { downloadReleaseAsset } = require('./plugins/releaseDownload'); +const { PAGE_SCHEME, pageCsp } = require('./plugins/pluginPage'); const { DEFAULT_DESKTOP_PREFS, normalizeDesktopPrefs, @@ -34,6 +37,8 @@ const startHidden = process.argv.includes('--hidden'); // instead of spawning a duplicate tray icon. const gotSingleInstanceLock = app.requestSingleInstanceLock(); +protocol.registerSchemesAsPrivileged([{ scheme: PAGE_SCHEME, privileges: { standard: true, secure: true } }]); + function createWindow() { mainWindow = new BrowserWindow({ width: 1200, @@ -43,6 +48,7 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, + sandbox: true, enableRemoteModule: false, // Production: keep same-origin + block mixed content. Local files load via loadFile. // Dev may relax for Vite HMR / local services if needed later — keep secure by default. @@ -233,6 +239,21 @@ function createWindow() { return { action: 'deny' }; }); + mainWindow.webContents.on('will-frame-navigate', (event) => { + if (event.isMainFrame) return; + if (!event.url.startsWith(`${PAGE_SCHEME}://`)) { + event.preventDefault(); + return; + } + const current = event.frame?.url; + if (current?.startsWith(`${PAGE_SCHEME}://`)) { + const previousPage = new URL(current); + const nextPage = new URL(event.url); + if (previousPage.hostname !== nextPage.hostname || + previousPage.pathname.split('/')[1] !== nextPage.pathname.split('/')[1]) event.preventDefault(); + } + }); + mainWindow.on('close', (event) => { // #345: 关闭默认常驻托盘(设置-通用可改)。真退出只走 isQuitting 路径。 if (!isQuitting && desktopPrefs.closeToTray) { @@ -845,6 +866,101 @@ ipcMain.handle('mcp:stop', async () => mcpServer.stop()); ipcMain.handle('mcp:getStatus', async () => mcpServer.getStatus()); +// ── Trusted local plugin host (discovery, lifecycle, and restricted IPC) ── +let pluginManager = null; + +function getPluginManager() { + if (!pluginManager) { + pluginManager = createPluginManager({ + pluginsRoot: path.join(app.getPath('userData'), 'plugins'), + }); + } + return pluginManager; +} + +ipcMain.handle('plugins:list', async () => getPluginManager().list()); +ipcMain.handle('plugins:installFromDirectory', async () => { + const selection = await dialog.showOpenDialog(mainWindow, { + title: 'Select plugin directory', + properties: ['openDirectory'], + }); + if (selection.canceled || selection.filePaths.length !== 1) return { success: false, canceled: true }; + return getPluginManager().installFromDirectory(selection.filePaths[0]); +}); +ipcMain.handle('plugins:enable', async (_event, pluginId, grantedPermissions) => + getPluginManager().enable(pluginId, grantedPermissions) +); +ipcMain.handle('plugins:disable', async (_event, pluginId) => + getPluginManager().disable(pluginId) +); +ipcMain.handle('plugins:uninstall', async (_event, pluginId, removePluginData) => + getPluginManager().uninstall(pluginId, removePluginData) +); +ipcMain.handle('plugins:runAction', async (_event, request) => { + const operation = await getPluginManager().runAction(request); + if (operation.success && operation.result.type === 'open-external') { + try { + await shell.openExternal(operation.result.url); + } catch { + return { + success: false, + error: { code: 'PLUGIN_EXTERNAL_OPEN_FAILED', message: 'Failed to open the external URL' }, + }; + } + } + return operation; +}); +ipcMain.handle('plugins:runProcessor', async (_event, request) => + getPluginManager().runProcessor(request) +); +ipcMain.handle('plugins:pushSnapshot', async (_event, snapshot) => + getPluginManager().updateSnapshot(snapshot) +); +ipcMain.handle('plugins:runReleaseProcessor', async (_event, request) => + getPluginManager().runReleaseProcessor(request) +); +ipcMain.handle('plugins:downloadReleaseAsset', async (_event, request) => { + const resolved = getPluginManager().getDownloadAsset( + request?.pluginId, + request?.releaseId, + request?.assetId + ); + if (!resolved.success) return resolved; + return downloadReleaseAsset({ + fetchImpl: (url, options) => net.fetch(url, options), + showSaveDialog: (...args) => dialog.showSaveDialog(...args), + ownerWindow: mainWindow, + ...resolved.value, + }); +}); +ipcMain.handle('plugins:runExporter', async (_event, request) => + getPluginManager().runExporter(request) +); +function isMainPluginFrame(event) { + return mainWindow && event.sender === mainWindow.webContents && + event.senderFrame === mainWindow.webContents.mainFrame; +} +ipcMain.handle('plugins:getPage', async (event, pluginId, pageId) => { + if (!isMainPluginFrame(event)) return { success: false, error: { code: 'PLUGIN_IPC_DENIED', message: 'Plugin IPC requires the main frame' } }; + return getPluginManager().getPage(pluginId, pageId); +}); +ipcMain.handle('plugins:requestPageCapability', async (event, request) => { + if (!isMainPluginFrame(event)) return { success: false, error: { code: 'PLUGIN_IPC_DENIED', message: 'Plugin IPC requires the main frame' } }; + return getPluginManager().requestPageCapability(request); +}); +ipcMain.handle('plugins:getSearchEndpoint', async (event) => { + if (!isMainPluginFrame(event)) return { endpoint: null }; + return getPluginManager().getSearchEndpoint(); +}); +ipcMain.handle('plugins:configureWebSearch', async (event, endpoint) => { + if (!isMainPluginFrame(event)) return { success: false, error: { code: 'PLUGIN_IPC_DENIED', message: 'Plugin IPC requires the main frame' } }; + return getPluginManager().configureWebSearch(endpoint); +}); +ipcMain.handle('plugins:searchWeb', async (event, request) => { + if (!isMainPluginFrame(event)) return { success: false, error: { code: 'PLUGIN_IPC_DENIED', message: 'Plugin IPC requires the main frame' } }; + return getPluginManager().searchWeb(request); +}); + if (!gotSingleInstanceLock) { app.quit(); } else { @@ -854,7 +970,23 @@ if (!gotSingleInstanceLock) { } app.whenReady().then(() => { + protocol.handle(PAGE_SCHEME, (request) => { + const resource = getPluginManager().readPageResource(request.url); + if (!resource) return new Response('Not Found', { status: 404 }); + return new Response(resource.body, { + headers: { + 'Content-Type': resource.mimeType, + 'Content-Security-Policy': pageCsp(new URL(request.url).hostname), + 'Access-Control-Allow-Origin': 'null', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + }, + }); + }); reloadDesktopPrefs(); + void getPluginManager().initialize().catch((error) => { + console.error('Failed to initialize plugins:', error instanceof Error ? error.message : 'Unknown error'); + }); // Self-heal the OS login item on every start (e.g. path changed after update). if (desktopPrefs.autoLaunch) { void applyAutoLaunch(true).then((result) => { @@ -898,10 +1030,11 @@ app.on('will-quit', () => { globalShortcut.unregisterAll(); destroyTray(); void mcpServer.stop(); + pluginManager?.shutdown(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } -}); \ No newline at end of file +}); diff --git a/electron/plugins/capabilityRouter.js b/electron/plugins/capabilityRouter.js new file mode 100644 index 000000000..87edf5f5d --- /dev/null +++ b/electron/plugins/capabilityRouter.js @@ -0,0 +1,68 @@ +'use strict'; + +const { protocolError } = require('./pluginProtocol'); + +function createCapabilityRouter({ storage, logger, catalog }) { + return { + async handle(permissions, request) { + if (!request || typeof request !== 'object' || typeof request.capability !== 'string') { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'Capability request is invalid'); + } + if (request.capability === 'log') { + logger.log(request.operation, request.args?.message, request.args?.metadata); + return null; + } + if (request.capability === 'ai') { + if (request.operation !== 'generate') { + throw protocolError('PLUGIN_CAPABILITY_UNKNOWN', `Unknown AI operation '${request.operation}'`); + } + if (!permissions.includes('ai:invoke')) { + throw protocolError('PLUGIN_PERMISSION_DENIED', "Permission 'ai:invoke' is required"); + } + // The renderer owns AI configuration; this checks authorization only. + return null; + } + if (request.capability === 'web') { + if (request.operation !== 'search') { + throw protocolError('PLUGIN_CAPABILITY_UNKNOWN', `Unknown web operation '${request.operation}'`); + } + if (!permissions.includes('web:search')) { + throw protocolError('PLUGIN_PERMISSION_DENIED', "Permission 'web:search' is required"); + } + // The user-configured search endpoint is contacted only after confirmation. + return null; + } + if (request.capability === 'github') { + if (request.operation === 'getRelease') { + if (!permissions.includes('releases:read')) { + throw protocolError('PLUGIN_PERMISSION_DENIED', "Permission 'releases:read' is required"); + } + return catalog.getRelease(request.args?.releaseId); + } + if (!permissions.includes('repositories:read') && !permissions.includes('privateRepositories:read')) { + throw protocolError('PLUGIN_PERMISSION_DENIED', "Permission 'repositories:read' is required"); + } + if (request.operation === 'getRepository') return catalog.getRepository(request.args?.repositoryId); + if (request.operation === 'searchRepositories') { + return catalog.searchRepositories(request.args?.query, request.args?.limit); + } + throw protocolError('PLUGIN_CAPABILITY_UNKNOWN', `Unknown GitHub operation '${request.operation}'`); + } + if (request.capability !== 'storage') { + throw protocolError('PLUGIN_CAPABILITY_UNKNOWN', `Unknown capability '${request.capability}'`); + } + if (!permissions.includes('storage')) { + throw protocolError('PLUGIN_PERMISSION_DENIED', "Permission 'storage' is required"); + } + if (request.operation === 'get') return storage.get(request.args?.key); + if (request.operation === 'set') { + storage.set(request.args?.key, request.args?.value); + return null; + } + if (request.operation === 'delete') return storage.delete(request.args?.key); + throw protocolError('PLUGIN_CAPABILITY_UNKNOWN', `Unknown storage operation '${request.operation}'`); + }, + }; +} + +module.exports = { createCapabilityRouter }; diff --git a/electron/plugins/capabilityRouter.test.js b/electron/plugins/capabilityRouter.test.js new file mode 100644 index 000000000..0c18511bb --- /dev/null +++ b/electron/plugins/capabilityRouter.test.js @@ -0,0 +1,78 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createCapabilityRouter } = require('./capabilityRouter'); + +test('routes only declared storage capability and always allows sanitized logging', async () => { + const events = []; + const router = createCapabilityRouter({ + storage: { + get: (key) => { events.push(['get', key]); return 'value'; }, + set: (key, value) => events.push(['set', key, value]), + delete: (key) => { events.push(['delete', key]); return true; }, + }, + logger: { log: (...args) => events.push(['log', ...args]) }, + }); + + assert.equal(await router.handle(['storage'], { + capability: 'storage', operation: 'get', args: { key: 'settings' }, + }), 'value'); + await router.handle([], { capability: 'log', operation: 'info', args: { message: 'hello' } }); + await assert.rejects(router.handle([], { + capability: 'storage', operation: 'get', args: { key: 'settings' }, + }), { code: 'PLUGIN_PERMISSION_DENIED' }); + await assert.rejects(router.handle(['storage'], { capability: 'network', operation: 'fetch' }), { + code: 'PLUGIN_CAPABILITY_UNKNOWN', + }); + assert.deepEqual(events, [['get', 'settings'], ['log', 'info', 'hello', undefined]]); +}); + +test('routes GitHub semantic reads through the Host catalog with exact permissions', async () => { + const router = createCapabilityRouter({ + storage: {}, logger: { log() {} }, + catalog: { + getRepository: (id) => ({ id }), + getRelease: (id) => ({ id }), + searchRepositories: (query, limit) => [{ query, limit }], + }, + }); + assert.deepEqual(await router.handle(['repositories:read'], { + capability: 'github', operation: 'searchRepositories', args: { query: 'electron', limit: 5 }, + }), [{ query: 'electron', limit: 5 }]); + assert.deepEqual(await router.handle(['releases:read'], { + capability: 'github', operation: 'getRelease', args: { releaseId: 2 }, + }), { id: 2 }); + await assert.rejects(router.handle([], { + capability: 'github', operation: 'getRepository', args: { repositoryId: 1 }, + }), { code: 'PLUGIN_PERMISSION_DENIED' }); + assert.deepEqual(await router.handle(['privateRepositories:read'], { + capability: 'github', operation: 'getRepository', args: { repositoryId: 9 }, + }), { id: 9 }); + assert.deepEqual(await router.handle(['privateRepositories:read'], { + capability: 'github', operation: 'searchRepositories', args: { query: 'private', limit: 2 }, + }), [{ query: 'private', limit: 2 }]); +}); + +test('AI authorization requires an explicit permission and rejects other operations', async () => { + const router = createCapabilityRouter({ storage: {}, logger: { log() {} }, catalog: {} }); + await assert.rejects(router.handle([], { + capability: 'ai', operation: 'generate', args: {}, + }), { code: 'PLUGIN_PERMISSION_DENIED' }); + assert.equal(await router.handle(['ai:invoke'], { + capability: 'ai', operation: 'generate', args: {}, + }), null); + await assert.rejects(router.handle(['ai:invoke'], { + capability: 'ai', operation: 'other', args: {}, + }), { code: 'PLUGIN_CAPABILITY_UNKNOWN' }); +}); + +test('web search authorization does not grant arbitrary network access', async () => { + const router = createCapabilityRouter({ storage: {}, logger: { log() {} }, catalog: {} }); + await assert.rejects(router.handle([], { capability: 'web', operation: 'search', args: {} }), { + code: 'PLUGIN_PERMISSION_DENIED', + }); + assert.equal(await router.handle(['web:search'], { capability: 'web', operation: 'search', args: {} }), null); + await assert.rejects(router.handle(['web:search'], { capability: 'web', operation: 'fetch', args: {} }), { + code: 'PLUGIN_CAPABILITY_UNKNOWN', + }); +}); diff --git a/electron/plugins/manifestSchema.js b/electron/plugins/manifestSchema.js new file mode 100644 index 000000000..43070c4a0 --- /dev/null +++ b/electron/plugins/manifestSchema.js @@ -0,0 +1,312 @@ +'use strict'; + +const MANIFEST_VERSION = 1; +const PLUGIN_API_VERSION = '1'; + +const TOP_LEVEL_FIELDS = new Set([ + 'manifestVersion', + 'id', + 'name', + 'version', + 'description', + 'author', + 'apiVersion', + 'main', + 'permissions', + 'contributes', +]); +const CONTRIBUTION_FIELDS = new Set(['repositoryActions', 'repositoryProcessors', 'releaseProcessors', 'exporters', 'pages']); +const REPOSITORY_ACTION_FIELDS = new Set(['id', 'title', 'icon', 'placement']); +const PROCESSOR_FIELDS = new Set(['id', 'title']); +const EXPORTER_FIELDS = new Set(['id', 'title', 'fileExtension', 'mimeType']); +const PAGE_FIELDS = new Set(['id', 'title', 'entry']); +const BASE_PERMISSIONS = new Set([ + 'repositories:read', + 'repositories:write', + 'privateRepositories:read', + 'releases:read', + 'gists:read', + 'storage', + 'clipboard:write', + 'external:open', + 'downloads:create', + 'ai:invoke', + 'web:search', +]); +const PLUGIN_ID_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/; +const CONTRIBUTION_ID_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const DOMAIN_RE = /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; +const MAX_MANIFEST_BYTES = 256 * 1024; + +function failure(code, message) { + return { success: false, code, message }; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function firstUnknownField(value, allowed) { + return Object.keys(value).find((key) => !allowed.has(key)); +} + +function requiredString(manifest, field) { + if (!(field in manifest)) { + return failure('MANIFEST_FIELD_REQUIRED', `Manifest field '${field}' is required`); + } + if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${field}' must be a non-empty string`); + } + return null; +} + +function validatePermission(permission) { + if (BASE_PERMISSIONS.has(permission)) return true; + if (!permission.startsWith('network:')) return false; + const domain = permission.slice('network:'.length); + return DOMAIN_RE.test(domain) && domain.toLowerCase() !== 'localhost'; +} + +function validateRepositoryActions(actions) { + if (!Array.isArray(actions)) { + return failure('MANIFEST_FIELD_INVALID', "Manifest field 'contributes.repositoryActions' must be an array"); + } + const ids = new Set(); + for (let index = 0; index < actions.length; index += 1) { + const action = actions[index]; + const prefix = `contributes.repositoryActions[${index}]`; + if (!isRecord(action)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}' must be an object`); + } + const unknown = firstUnknownField(action, REPOSITORY_ACTION_FIELDS); + if (unknown) { + return failure('MANIFEST_UNKNOWN_FIELD', `Unknown manifest field '${prefix}.${unknown}'`); + } + for (const field of ['id', 'title', 'placement']) { + if (typeof action[field] !== 'string' || action[field].trim() === '') { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.${field}' must be a non-empty string`); + } + } + if (!CONTRIBUTION_ID_RE.test(action.id)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.id' has an invalid format`); + } + if (ids.has(action.id)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.id' must be unique`); + } + ids.add(action.id); + if (!['repository-card', 'bulk-toolbar'].includes(action.placement)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.placement' is unsupported`); + } + if ('icon' in action && (typeof action.icon !== 'string' || action.icon.trim() === '')) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.icon' must be a non-empty string`); + } + } + return null; +} + +function validatePages(pages) { + if (!Array.isArray(pages)) { + return failure('MANIFEST_FIELD_INVALID', "Manifest field 'contributes.pages' must be an array"); + } + const ids = new Set(); + for (let index = 0; index < pages.length; index += 1) { + const page = pages[index]; + const prefix = `contributes.pages[${index}]`; + if (!isRecord(page)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}' must be an object`); + } + const unknown = firstUnknownField(page, PAGE_FIELDS); + if (unknown) { + return failure('MANIFEST_UNKNOWN_FIELD', `Unknown manifest field '${prefix}.${unknown}'`); + } + for (const field of ['id', 'title', 'entry']) { + if (typeof page[field] !== 'string' || page[field].trim() === '') { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.${field}' must be a non-empty string`); + } + } + if (!page.entry.toLowerCase().endsWith('.html')) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.entry' must name an HTML file`); + } + if (!CONTRIBUTION_ID_RE.test(page.id) || ids.has(page.id)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.id' must be valid and unique`); + } + ids.add(page.id); + } + return null; +} + +function validateSimpleContributions(items, field, allowedFields, requiredFields) { + if (!Array.isArray(items)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field 'contributes.${field}' must be an array`); + } + const ids = new Set(); + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const prefix = `contributes.${field}[${index}]`; + if (!isRecord(item)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}' must be an object`); + } + const unknown = firstUnknownField(item, allowedFields); + if (unknown) return failure('MANIFEST_UNKNOWN_FIELD', `Unknown manifest field '${prefix}.${unknown}'`); + for (const requiredField of requiredFields) { + if (typeof item[requiredField] !== 'string' || item[requiredField].trim() === '') { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.${requiredField}' must be a non-empty string`); + } + } + if (!CONTRIBUTION_ID_RE.test(item.id) || ids.has(item.id)) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${prefix}.id' must be valid and unique`); + } + ids.add(item.id); + } + return null; +} + +function validateManifest(input) { + if (!isRecord(input)) { + return failure('MANIFEST_FIELD_INVALID', 'Manifest must be a JSON object'); + } + + const unknown = firstUnknownField(input, TOP_LEVEL_FIELDS); + if (unknown) return failure('MANIFEST_UNKNOWN_FIELD', `Unknown manifest field '${unknown}'`); + + for (const field of ['id', 'name', 'version', 'apiVersion']) { + const error = requiredString(input, field); + if (error) return error; + } + if (!('manifestVersion' in input)) { + return failure('MANIFEST_FIELD_REQUIRED', "Manifest field 'manifestVersion' is required"); + } + if (input.manifestVersion !== MANIFEST_VERSION) { + return failure( + 'MANIFEST_VERSION_UNSUPPORTED', + `Unsupported manifestVersion '${input.manifestVersion}'; expected ${MANIFEST_VERSION}` + ); + } + if (input.apiVersion !== PLUGIN_API_VERSION) { + return failure( + 'PLUGIN_API_VERSION_UNSUPPORTED', + `Unsupported apiVersion '${input.apiVersion}'; expected ${PLUGIN_API_VERSION}` + ); + } + if (!PLUGIN_ID_RE.test(input.id)) { + return failure('MANIFEST_FIELD_INVALID', "Manifest field 'id' has an invalid format"); + } + if (!SEMVER_RE.test(input.version)) { + return failure('MANIFEST_FIELD_INVALID', "Manifest field 'version' must use semantic versioning"); + } + for (const field of ['description', 'author', 'main']) { + if (field in input && (typeof input[field] !== 'string' || input[field].trim() === '')) { + return failure('MANIFEST_FIELD_INVALID', `Manifest field '${field}' must be a non-empty string`); + } + } + + if (!Array.isArray(input.permissions)) { + return failure('MANIFEST_FIELD_REQUIRED', "Manifest field 'permissions' is required and must be an array"); + } + const permissions = new Set(); + for (const permission of input.permissions) { + if (typeof permission !== 'string' || !validatePermission(permission)) { + return failure('MANIFEST_PERMISSION_UNKNOWN', `Unknown plugin permission '${String(permission)}'`); + } + if (permissions.has(permission)) { + return failure('MANIFEST_FIELD_INVALID', `Plugin permission '${permission}' is duplicated`); + } + permissions.add(permission); + } + + if (!isRecord(input.contributes)) { + return failure('MANIFEST_FIELD_REQUIRED', "Manifest field 'contributes' is required and must be an object"); + } + const unknownContribution = firstUnknownField(input.contributes, CONTRIBUTION_FIELDS); + if (unknownContribution) { + return failure( + 'MANIFEST_UNKNOWN_FIELD', + `Unknown manifest field 'contributes.${unknownContribution}'` + ); + } + if ('repositoryActions' in input.contributes) { + const error = validateRepositoryActions(input.contributes.repositoryActions); + if (error) return error; + } + if ('pages' in input.contributes) { + const error = validatePages(input.contributes.pages); + if (error) return error; + } + if ('repositoryProcessors' in input.contributes) { + const error = validateSimpleContributions( + input.contributes.repositoryProcessors, + 'repositoryProcessors', + PROCESSOR_FIELDS, + ['id', 'title'] + ); + if (error) return error; + } + if ('releaseProcessors' in input.contributes) { + const error = validateSimpleContributions( + input.contributes.releaseProcessors, + 'releaseProcessors', + PROCESSOR_FIELDS, + ['id', 'title'] + ); + if (error) return error; + } + if ('exporters' in input.contributes) { + const error = validateSimpleContributions( + input.contributes.exporters, + 'exporters', + EXPORTER_FIELDS, + ['id', 'title', 'fileExtension', 'mimeType'] + ); + if (error) return error; + for (const exporter of input.contributes.exporters) { + if (!/^\.[a-z0-9]{1,10}$/i.test(exporter.fileExtension) || !/^[\w.+-]+\/[\w.+-]+$/.test(exporter.mimeType)) { + return failure('MANIFEST_FIELD_INVALID', 'Exporter fileExtension or mimeType is invalid'); + } + } + } + if ( + [input.contributes.repositoryActions, input.contributes.repositoryProcessors, input.contributes.exporters] + .some((items) => Array.isArray(items) && items.length > 0) && + !permissions.has('repositories:read') && + !permissions.has('privateRepositories:read') + ) { + return failure( + 'MANIFEST_PERMISSION_REQUIRED', + "Repository contributions require permission 'repositories:read' or 'privateRepositories:read'" + ); + } + if ( + Array.isArray(input.contributes.releaseProcessors) && + input.contributes.releaseProcessors.length > 0 && + !permissions.has('releases:read') + ) { + return failure( + 'MANIFEST_PERMISSION_REQUIRED', + "Release contributions require permission 'releases:read'" + ); + } + const hasMain = typeof input.main === 'string'; + const hasPage = Array.isArray(input.contributes.pages) && input.contributes.pages.length > 0; + if (!hasMain && !hasPage) { + return failure('MANIFEST_FIELD_REQUIRED', "Manifest requires either 'main' or a contributed page entry"); + } + const hasRuntimeContribution = [ + input.contributes.repositoryActions, + input.contributes.repositoryProcessors, + input.contributes.releaseProcessors, + input.contributes.exporters, + ].some((items) => Array.isArray(items) && items.length > 0); + if (!hasMain && hasRuntimeContribution) { + return failure('MANIFEST_FIELD_REQUIRED', "Manifest field 'main' is required for runtime contributions"); + } + + return { success: true, data: JSON.parse(JSON.stringify(input)) }; +} + +module.exports = { + MANIFEST_VERSION, + PLUGIN_API_VERSION, + MAX_MANIFEST_BYTES, + validateManifest, +}; diff --git a/electron/plugins/manifestSchema.test.js b/electron/plugins/manifestSchema.test.js new file mode 100644 index 000000000..750975917 --- /dev/null +++ b/electron/plugins/manifestSchema.test.js @@ -0,0 +1,159 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + MANIFEST_VERSION, + PLUGIN_API_VERSION, + validateManifest, +} = require('./manifestSchema'); + +function manifest(overrides = {}) { + return { + manifestVersion: MANIFEST_VERSION, + id: 'com.example.markdown-exporter', + name: 'Markdown Exporter', + version: '0.1.0', + apiVersion: PLUGIN_API_VERSION, + main: 'worker.js', + permissions: ['repositories:read'], + contributes: { + repositoryActions: [ + { + id: 'export-markdown', + title: 'Export as Markdown', + placement: 'bulk-toolbar', + }, + ], + }, + ...overrides, + }; +} + +test('accepts the v1 manifest contract', () => { + const input = manifest(); + const result = validateManifest(input); + + assert.equal(result.success, true); + assert.equal(result.data.id, 'com.example.markdown-exporter'); + assert.notEqual(result.data, input); + assert.notEqual(result.data.contributes, input.contributes); +}); + +test('rejects missing required fields with a stable error code', () => { + const input = manifest(); + delete input.name; + + assert.deepEqual(validateManifest(input), { + success: false, + code: 'MANIFEST_FIELD_REQUIRED', + message: "Manifest field 'name' is required", + }); +}); + +test('rejects unknown top-level and contribution fields', () => { + assert.equal(validateManifest(manifest({ surprise: true })).code, 'MANIFEST_UNKNOWN_FIELD'); + + const input = manifest(); + input.contributes.repositoryActions[0].handler = 'run'; + assert.equal(validateManifest(input).code, 'MANIFEST_UNKNOWN_FIELD'); +}); + +test('rejects incompatible manifest and API versions', () => { + assert.equal( + validateManifest(manifest({ manifestVersion: 2 })).code, + 'MANIFEST_VERSION_UNSUPPORTED' + ); + assert.equal( + validateManifest(manifest({ apiVersion: '2' })).code, + 'PLUGIN_API_VERSION_UNSUPPORTED' + ); +}); + +test('rejects malformed ids, versions, permissions, and contributions', () => { + assert.equal(validateManifest(manifest({ id: '../escape' })).code, 'MANIFEST_FIELD_INVALID'); + assert.equal(validateManifest(manifest({ version: 'latest' })).code, 'MANIFEST_FIELD_INVALID'); + assert.equal( + validateManifest(manifest({ permissions: ['credentials:read'] })).code, + 'MANIFEST_PERMISSION_UNKNOWN' + ); + + const input = manifest(); + input.contributes.repositoryActions[0].placement = 'header'; + assert.equal(validateManifest(input).code, 'MANIFEST_FIELD_INVALID'); +}); + +test('accepts a page-only manifest and domain-scoped HTTPS permission declaration', () => { + const input = manifest({ + main: undefined, + permissions: ['repositories:read', 'network:api.github.com'], + contributes: { + pages: [{ id: 'dashboard', title: 'Repository Health', entry: 'ui/index.html' }], + }, + }); + delete input.main; + + const result = validateManifest(input); + assert.equal(result.success, true); + assert.equal(result.data.contributes.pages[0].entry, 'ui/index.html'); +}); + +test('rejects page-only manifests that declare Worker contributions', () => { + const input = manifest({ + main: undefined, + permissions: ['repositories:read'], + contributes: { + pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'ui/index.html' }], + repositoryActions: [{ id: 'export-markdown', title: 'Export as Markdown', placement: 'bulk-toolbar' }], + }, + }); + delete input.main; + + const result = validateManifest(input); + assert.equal(result.success, false); + assert.equal(result.code, 'MANIFEST_FIELD_REQUIRED'); + assert.equal(result.message, "Manifest field 'main' is required for runtime contributions"); +}); + +test('requires contributed page entries to be HTML documents', () => { + const input = manifest(); + delete input.main; + input.contributes = { pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'ui/index.js' }] }; + const result = validateManifest(input); + assert.equal(result.success, false); + assert.equal(result.code, 'MANIFEST_FIELD_INVALID'); +}); + +test('requires repositories:read for repository contributions', () => { + assert.deepEqual(validateManifest(manifest({ permissions: [] })), { + success: false, + code: 'MANIFEST_PERMISSION_REQUIRED', + message: "Repository contributions require permission 'repositories:read' or 'privateRepositories:read'", + }); + assert.equal(validateManifest(manifest({ permissions: ['privateRepositories:read'] })).success, true); +}); + +test('validates repository processors and exporters', () => { + const result = validateManifest(manifest({ + contributes: { + repositoryProcessors: [{ id: 'health', title: 'Analyze health' }], + exporters: [{ id: 'markdown', title: 'Markdown', fileExtension: '.md', mimeType: 'text/markdown' }], + }, + })); + assert.equal(result.success, true); + + const invalid = manifest({ + contributes: { + exporters: [{ id: 'bad', title: 'Bad', fileExtension: '../exe', mimeType: 'anything' }], + }, + }); + assert.equal(validateManifest(invalid).code, 'MANIFEST_FIELD_INVALID'); +}); + +test('requires releases:read for release processors and accepts download permission', () => { + const contribution = { releaseProcessors: [{ id: 'recommend', title: 'Recommend asset' }] }; + assert.equal(validateManifest(manifest({ permissions: [], contributes: contribution })).code, 'MANIFEST_PERMISSION_REQUIRED'); + assert.equal(validateManifest(manifest({ + permissions: ['releases:read', 'downloads:create'], + contributes: contribution, + })).success, true); +}); diff --git a/electron/plugins/pluginCatalog.js b/electron/plugins/pluginCatalog.js new file mode 100644 index 000000000..165c3b443 --- /dev/null +++ b/electron/plugins/pluginCatalog.js @@ -0,0 +1,115 @@ +'use strict'; + +const { protocolError, sanitizeRelease, sanitizeRepository } = require('./pluginProtocol'); + +const MAX_SNAPSHOT_REPOSITORIES = 10000; +const MAX_SNAPSHOT_RELEASES = 20000; +const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024; + +function validateAssetDownloadUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Release asset download URL is invalid'); + } + if (url.protocol !== 'https:' || url.username || url.password || url.hostname.toLowerCase() !== 'github.com') { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Release asset download URL must be an HTTPS github.com URL'); + } + return url.toString(); +} + +function createPluginCatalog() { + let repositories = new Map(); + let releases = new Map(); + + function normalizeRelease(release) { + const sanitized = sanitizeRelease(release); + const assets = sanitized.assets.map((asset) => { + const source = release.assets.find((candidate) => candidate?.id === asset.id); + return { ...asset, browser_download_url: validateAssetDownloadUrl(source?.browser_download_url) }; + }); + return { public: sanitized, assets }; + } + + return { + update(snapshot) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Plugin data snapshot must be an object'); + } + let snapshotBytes; + try { + snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8'); + } catch { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Plugin data snapshot must be JSON serializable'); + } + if (snapshotBytes > MAX_SNAPSHOT_BYTES) { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Plugin data snapshot exceeds the size limit'); + } + if (!Array.isArray(snapshot.repositories) || snapshot.repositories.length > MAX_SNAPSHOT_REPOSITORIES) { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Plugin repository snapshot is invalid'); + } + if (!Array.isArray(snapshot.releases) || snapshot.releases.length > MAX_SNAPSHOT_RELEASES) { + throw protocolError('PLUGIN_SNAPSHOT_INVALID', 'Plugin release snapshot is invalid'); + } + const nextRepositories = new Map(); + for (const repository of snapshot.repositories) { + const sanitized = sanitizeRepository(repository); + nextRepositories.set(sanitized.id, sanitized); + } + const nextReleases = new Map(); + for (const release of snapshot.releases) { + const normalized = normalizeRelease(release); + nextReleases.set(normalized.public.id, normalized); + } + repositories = nextRepositories; + releases = nextReleases; + return { repositories: repositories.size, releases: releases.size }; + }, + upsert(repository, release) { + const sanitizedRepository = repository === undefined + ? null + : sanitizeRepository(repository); + const normalizedRelease = normalizeRelease(release); + if (sanitizedRepository) { + repositories.set(sanitizedRepository.id, sanitizedRepository); + } + releases.set(normalizedRelease.public.id, normalizedRelease); + }, + searchRepositories(query, limit = 20) { + if (typeof query !== 'string' || query.trim().length === 0 || query.length > 200) { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'GitHub repository query is invalid'); + } + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'GitHub repository result limit must be between 1 and 100'); + } + const needle = query.trim().toLowerCase(); + return [...repositories.values()] + .filter((repository) => `${repository.full_name} ${repository.description || ''} ${repository.topics.join(' ')}`.toLowerCase().includes(needle)) + .slice(0, limit); + }, + getRepository(repositoryId) { + if (!Number.isSafeInteger(repositoryId)) { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'GitHub repository id is invalid'); + } + return repositories.get(repositoryId) || null; + }, + getRelease(releaseId) { + if (!Number.isSafeInteger(releaseId)) { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'GitHub release id is invalid'); + } + return releases.get(releaseId)?.public || null; + }, + getDownloadAsset(releaseId, assetId) { + if (!Number.isSafeInteger(releaseId) || !Number.isSafeInteger(assetId)) { + throw protocolError('PLUGIN_CAPABILITY_REQUEST_INVALID', 'Release or asset id is invalid'); + } + const release = releases.get(releaseId); + if (!release) return null; + const asset = release.assets.find((candidate) => candidate.id === assetId); + return asset ? { release: release.public, asset } : null; + }, + }; +} + +module.exports = { createPluginCatalog }; diff --git a/electron/plugins/pluginCatalog.test.js b/electron/plugins/pluginCatalog.test.js new file mode 100644 index 000000000..701d5cea9 --- /dev/null +++ b/electron/plugins/pluginCatalog.test.js @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createPluginCatalog } = require('./pluginCatalog'); + +function repository() { + return { + id: 1, name: 'project', full_name: 'owner/project', description: 'Desktop manager', + html_url: 'https://github.com/owner/project', stargazers_count: 4, forks_count: 1, + language: 'TypeScript', created_at: '2026-01-01', updated_at: '2026-01-02', + pushed_at: '2026-01-03', owner: { login: 'owner', avatar_url: 'private' }, topics: ['electron'], + }; +} + +function release() { + return { + id: 2, tag_name: 'v1.0.0', name: 'One', body: 'Notes', published_at: '2026-01-04', + html_url: 'https://github.com/owner/project/releases/tag/v1.0.0', repository: { id: 1, full_name: 'owner/project', name: 'project' }, + assets: [{ + id: 3, name: 'project-x64.exe', size: 100, download_count: 5, + browser_download_url: 'https://github.com/owner/project/releases/download/v1.0.0/project-x64.exe', + content_type: 'application/octet-stream', created_at: '2026-01-04', updated_at: '2026-01-04', + }], + }; +} + +test('stores trusted download URLs but returns only sanitized GitHub data', () => { + const catalog = createPluginCatalog(); + assert.deepEqual(catalog.update({ repositories: [repository()], releases: [release()] }), { repositories: 1, releases: 1 }); + assert.equal(catalog.searchRepositories('electron')[0].full_name, 'owner/project'); + assert.equal('avatar_url' in catalog.getRepository(1).owner, false); + assert.equal('browser_download_url' in catalog.getRelease(2).assets[0], false); + assert.equal(catalog.getDownloadAsset(2, 3).asset.browser_download_url.includes('/project-x64.exe'), true); +}); + +test('rejects non-GitHub and non-HTTPS asset URLs', () => { + const catalog = createPluginCatalog(); + const input = release(); + input.assets[0].browser_download_url = 'https://evil.example/file.exe'; + assert.throws(() => catalog.update({ repositories: [], releases: [input] }), { code: 'PLUGIN_SNAPSHOT_INVALID' }); +}); + +test('does not partially update a repository when release validation fails', () => { + const catalog = createPluginCatalog(); + const invalidRelease = release(); + invalidRelease.assets[0].browser_download_url = 'https://evil.example/file.exe'; + + assert.throws(() => catalog.upsert(repository(), invalidRelease), { code: 'PLUGIN_SNAPSHOT_INVALID' }); + assert.equal(catalog.getRepository(1), null); + assert.equal(catalog.getRelease(2), null); +}); diff --git a/electron/plugins/pluginIntegration.test.js b/electron/plugins/pluginIntegration.test.js new file mode 100644 index 000000000..d383323ff --- /dev/null +++ b/electron/plugins/pluginIntegration.test.js @@ -0,0 +1,157 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createPluginManager } = require('./pluginManager'); + +function repository() { + return { + id: 1, + name: 'project', + full_name: 'owner/project', + description: 'Example repository', + html_url: 'https://github.com/owner/project', + stargazers_count: 42, + forks_count: 3, + language: 'TypeScript', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + pushed_at: '2026-01-03T00:00:00Z', + owner: { login: 'owner', avatar_url: 'https://example.com/avatar.png' }, + topics: ['desktop'], + }; +} + +test('runs the full trusted-local V1 lifecycle with the example plugin', async (t) => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-e2e-')); + t.after(() => fs.rmSync(workspace, { recursive: true, force: true })); + const manager = createPluginManager({ + pluginsRoot: path.join(workspace, 'plugins'), + statePath: path.join(workspace, 'plugins-state.json'), + dataRoot: path.join(workspace, 'plugin-data'), + logsRoot: path.join(workspace, 'plugin-logs'), + runtimeTimeoutMs: 2000, + }); + const source = path.resolve(__dirname, '../../examples/plugins/markdown-exporter'); + const pluginId = 'com.githubstarsmanager.markdown-exporter'; + const permissions = ['repositories:read', 'storage', 'clipboard:write']; + + assert.deepEqual(manager.installFromDirectory(source), { success: true, pluginId }); + assert.deepEqual(await manager.enable(pluginId, permissions), { success: true }); + + const action = await manager.runAction({ + pluginId, + actionId: 'copy-repository', + repositories: [repository()], + }); + assert.equal(action.success, true); + assert.match(action.result.content, /\[owner\/project\]\(https:\/\/github\.com\/owner\/project\)/); + + const processor = await manager.runProcessor({ + pluginId, + processorId: 'activity-summary', + repositories: [repository()], + }); + assert.deepEqual(processor, { + success: true, + result: { + repositories: [{ id: 1, summary: 'owner/project: 42 stars', tags: ['has-push-history'] }], + }, + }); + + const exporter = await manager.runExporter({ + pluginId, + exporterId: 'markdown', + repositories: [repository()], + }); + assert.equal(exporter.success, true); + assert.equal(exporter.result.fileName, 'github-stars.md'); + assert.equal(exporter.result.mimeType, 'text/markdown'); + + assert.deepEqual(await manager.disable(pluginId), { success: true }); + assert.equal((await manager.list()).plugins[0].status, 'disabled'); + assert.equal( + JSON.parse(fs.readFileSync(path.join(workspace, 'plugin-data', `${pluginId}.json`), 'utf8')).activations, + 1 + ); + assert.match( + fs.readFileSync(path.join(workspace, 'plugin-logs', `${pluginId}.log`), 'utf8'), + /Markdown exporter deactivated/ + ); +}); + +test('runs the V1.1 Smart Release Recommendation example without exposing download URLs', async (t) => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-release-e2e-')); + t.after(() => fs.rmSync(workspace, { recursive: true, force: true })); + const manager = createPluginManager({ + pluginsRoot: path.join(workspace, 'plugins'), + statePath: path.join(workspace, 'plugins-state.json'), + runtimeTimeoutMs: 2000, + }); + const source = path.resolve(__dirname, '../../examples/plugins/smart-release-recommender'); + const pluginId = 'com.githubstarsmanager.smart-release-recommender'; + assert.deepEqual(manager.installFromDirectory(source), { success: true, pluginId }); + assert.deepEqual(await manager.enable(pluginId, ['releases:read', 'downloads:create']), { success: true }); + const names = { + win32: `project-${process.arch}-setup.exe`, + darwin: `project-${process.arch}.dmg`, + linux: `project-${process.arch}.AppImage`, + }; + const selectedName = names[process.platform] || `project-${process.arch}-installer`; + const release = { + id: 20, tag_name: 'v2', name: 'Two', body: 'Notes', published_at: '2026-02-01', + html_url: 'https://github.com/owner/project/releases/tag/v2', + repository: { id: 1, full_name: 'owner/project', name: 'project' }, + assets: [ + { id: 21, name: selectedName, size: 100, download_count: 1, + browser_download_url: `https://github.com/owner/project/releases/download/v2/${selectedName}`, + content_type: 'application/octet-stream', created_at: '2026-02-01', updated_at: '2026-02-01' }, + { id: 22, name: 'source-code.zip', size: 50, download_count: 1, + browser_download_url: 'https://github.com/owner/project/releases/download/v2/source-code.zip', + content_type: 'application/zip', created_at: '2026-02-01', updated_at: '2026-02-01' }, + ], + }; + const operation = await manager.runReleaseProcessor({ pluginId, processorId: 'recommend-platform-asset', release }); + assert.equal(operation.success, true); + assert.equal(operation.result.recommendedAssetId, 21); + assert.equal(manager.getDownloadAsset(pluginId, 20, 21).success, true); + const incompatibleNames = { + win32: `project-${process.arch}.AppImage`, + darwin: `project-${process.arch}.AppImage`, + linux: `project-${process.arch}.dmg`, + }; + const incompatibleName = incompatibleNames[process.platform] || 'project-x64.AppImage'; + const incompatibleRelease = { + ...release, + id: 23, + assets: [{ ...release.assets[0], id: 24, name: incompatibleName, + browser_download_url: `https://github.com/owner/project/releases/download/v2/${incompatibleName}` }], + }; + const incompatible = await manager.runReleaseProcessor({ + pluginId, processorId: 'recommend-platform-asset', release: incompatibleRelease, + }); + assert.equal(incompatible.success, false); + assert.equal(incompatible.error.code, 'NO_COMPATIBLE_RELEASE_ASSET'); + manager.shutdown(); +}); + +test('installs and serves the V1.2 page-only example without starting a Worker', async (t) => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-page-e2e-')); + t.after(() => fs.rmSync(workspace, { recursive: true, force: true })); + const manager = createPluginManager({ + pluginsRoot: path.join(workspace, 'plugins'), + statePath: path.join(workspace, 'plugins-state.json'), + }); + const source = path.resolve(__dirname, '../../examples/plugins/repo-health-page'); + const pluginId = 'com.example.repo-health-page'; + assert.deepEqual(manager.installFromDirectory(source), { success: true, pluginId }); + assert.deepEqual(await manager.enable(pluginId, ['repositories:read']), { success: true }); + const page = manager.getPage(pluginId, 'dashboard'); + assert.equal(page.success, true); + assert.match(manager.readPageResource(page.url).body.toString(), /Repository Health/); + assert.match(manager.readPageResource(page.url.replace('index.html', 'index.js')).body.toString(), /repositories\.search/); + await manager.disable(pluginId); + assert.equal(manager.readPageResource(page.url), null); +}); diff --git a/electron/plugins/pluginLogger.js b/electron/plugins/pluginLogger.js new file mode 100644 index 000000000..5f6629cef --- /dev/null +++ b/electron/plugins/pluginLogger.js @@ -0,0 +1,74 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const MAX_LOG_BYTES = 1024 * 1024; +const MAX_LOG_MESSAGE_LENGTH = 4000; +const SENSITIVE_KEY_RE = /(?:authorization|api[-_]?key|token|secret|password|credential)/i; +const TOKEN_RE = /(?:Bearer\s+)[^\s"']+|(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]+/gi; + +function sanitizeText(value) { + return String(value).slice(0, MAX_LOG_MESSAGE_LENGTH).replace(TOKEN_RE, '[REDACTED]'); +} + +function sanitizeMetadata(value, depth = 0) { + if (depth > 4) return '[TRUNCATED]'; + if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeMetadata(item, depth + 1)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, item]) => [ + key, + SENSITIVE_KEY_RE.test(key) ? '[REDACTED]' : sanitizeMetadata(item, depth + 1), + ])); + } + return typeof value === 'string' ? sanitizeText(value) : value; +} + +function createPluginLogger({ logsRoot, pluginId }) { + const logPath = path.join(path.resolve(logsRoot), `${pluginId}.log`); + + return { + log(level, message, metadata) { + const normalizedLevel = ['debug', 'info', 'warning', 'error'].includes(level) ? level : 'info'; + const entry = { + at: new Date().toISOString(), + level: normalizedLevel, + message: sanitizeText(message), + ...(metadata === undefined ? {} : { metadata: sanitizeMetadata(metadata) }), + }; + const line = `${JSON.stringify(entry)}\n`; + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + try { + if (fs.statSync(logPath).size + Buffer.byteLength(line, 'utf8') > MAX_LOG_BYTES) { + const rotatedPath = `${logPath}.1`; + try { fs.unlinkSync(rotatedPath); } catch {} + fs.renameSync(logPath, rotatedPath); + } + } catch {} + fs.appendFileSync(logPath, line, { encoding: 'utf8', mode: 0o600 }); + }, + }; +} + +/** + * Deletes the log file and its rotated copy of an uninstalled plugin. + * Missing files are not an error; returns false when a file could not be removed. + */ +function removePluginLogs({ logsRoot, pluginId }) { + if (typeof logsRoot !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)+$/.test(pluginId)) { + throw new TypeError('Plugin logs require a logs root and valid plugin id'); + } + const logPath = path.join(path.resolve(logsRoot), `${pluginId}.log`); + let removed = true; + for (const target of [logPath, `${logPath}.1`]) { + if (!fs.existsSync(target)) continue; + try { + fs.rmSync(target, { force: true }); + } catch { + removed = false; + } + } + return removed; +} + +module.exports = { MAX_LOG_BYTES, createPluginLogger, removePluginLogs, sanitizeMetadata, sanitizeText }; diff --git a/electron/plugins/pluginLogger.test.js b/electron/plugins/pluginLogger.test.js new file mode 100644 index 000000000..b68d7c440 --- /dev/null +++ b/electron/plugins/pluginLogger.test.js @@ -0,0 +1,37 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createPluginLogger, removePluginLogs } = require('./pluginLogger'); + +test('redacts tokens and sensitive metadata from plugin logs', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-logs-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const logger = createPluginLogger({ logsRoot: root, pluginId: 'com.example.logger' }); + + logger.log('info', 'Authorization: Bearer secret-value', { + apiKey: 'secret-key', + nested: { token: 'secret-token', safe: 'visible' }, + }); + + const text = fs.readFileSync(path.join(root, 'com.example.logger.log'), 'utf8'); + assert.equal(text.includes('secret-value'), false); + assert.equal(text.includes('secret-key'), false); + assert.equal(text.includes('secret-token'), false); + assert.equal(text.includes('visible'), true); +}); + +test('removePluginLogs deletes the current and rotated log files', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-logs-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const logger = createPluginLogger({ logsRoot: root, pluginId: 'com.example.logger' }); + logger.log('info', 'keep', {}); + fs.writeFileSync(path.join(root, 'com.example.logger.log.1'), 'rotated'); + + assert.equal(removePluginLogs({ logsRoot: root, pluginId: 'com.example.logger' }), true); + assert.equal(fs.existsSync(path.join(root, 'com.example.logger.log')), false); + assert.equal(fs.existsSync(path.join(root, 'com.example.logger.log.1')), false); + assert.equal(removePluginLogs({ logsRoot: root, pluginId: 'com.example.logger' }), true); +}); diff --git a/electron/plugins/pluginManager.js b/electron/plugins/pluginManager.js new file mode 100644 index 000000000..c5a523fb8 --- /dev/null +++ b/electron/plugins/pluginManager.js @@ -0,0 +1,776 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { MAX_MANIFEST_BYTES, validateManifest } = require('./manifestSchema'); +const { createPluginRuntime } = require('./pluginRuntime'); +const { + validateRunActionRequest, + validateRunProcessorRequest, + validateRunExporterRequest, + validateRunReleaseProcessorRequest, +} = require('./pluginProtocol'); +const { createPluginStateStore } = require('./pluginState'); +const { createPluginStorage, removePluginStorage } = require('./pluginStorage'); +const { createPluginLogger, removePluginLogs, sanitizeText } = require('./pluginLogger'); +const { createCapabilityRouter } = require('./capabilityRouter'); +const { createPluginCatalog } = require('./pluginCatalog'); +const { pageUrl, readPageResource } = require('./pluginPage'); +const { validatePageCapabilityRequest } = require('./pluginPageBridge'); +const { searchUrl, searchSearxng } = require('./webSearch'); + +const MAX_PLUGIN_PACKAGE_FILES = 2000; +const MAX_PLUGIN_PACKAGE_BYTES = 50 * 1024 * 1024; + +function invalid(directoryName, code, message) { + return { directoryName, code, message }; +} + +function isInside(parent, candidate) { + const relative = path.relative(parent, candidate); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +function validateEntry(pluginDirectory, relativeEntry) { + if (typeof relativeEntry !== 'string' || path.isAbsolute(relativeEntry)) { + return { code: 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY', message: 'Plugin entry must be a relative path' }; + } + const candidate = path.resolve(pluginDirectory, relativeEntry); + if (!isInside(pluginDirectory, candidate)) { + return { code: 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY', message: 'Plugin entry resolves outside its plugin directory' }; + } + + let stat; + try { + stat = fs.statSync(candidate); + } catch (error) { + if (error && error.code === 'ENOENT') { + return { code: 'PLUGIN_ENTRY_NOT_FOUND', message: `Plugin entry '${relativeEntry}' does not exist` }; + } + return { code: 'PLUGIN_ENTRY_UNREADABLE', message: `Plugin entry '${relativeEntry}' cannot be inspected` }; + } + if (!stat.isFile()) { + return { code: 'PLUGIN_ENTRY_NOT_FILE', message: `Plugin entry '${relativeEntry}' is not a file` }; + } + + try { + const realPluginDirectory = fs.realpathSync(pluginDirectory); + const realCandidate = fs.realpathSync(candidate); + if (!isInside(realPluginDirectory, realCandidate)) { + return { code: 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY', message: 'Plugin entry symlink resolves outside its plugin directory' }; + } + } catch { + return { code: 'PLUGIN_ENTRY_UNREADABLE', message: `Plugin entry '${relativeEntry}' cannot be resolved` }; + } + return null; +} + +function safeError(error, fallbackCode = 'PLUGIN_OPERATION_FAILED') { + return { + code: typeof error?.code === 'string' ? error.code : fallbackCode, + message: sanitizeText(error instanceof Error ? error.message : 'Plugin operation failed'), + }; +} + +function samePermissions(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; + // Compare unique sets so duplicated or extra entries cannot satisfy the confirmation check. + const expected = new Set(left); + if (expected.size !== left.length) return false; + const provided = new Set(right); + if (provided.size !== right.length) return false; + return right.every((permission) => expected.has(permission)); +} + +function inspectPluginSource(sourceDirectory) { + const resolvedSource = path.resolve(sourceDirectory); + let manifest; + try { + const manifestPath = path.join(resolvedSource, 'manifest.json'); + if (fs.statSync(manifestPath).size > MAX_MANIFEST_BYTES) { + throw Object.assign(new Error('Plugin manifest exceeds the size limit'), { code: 'MANIFEST_TOO_LARGE' }); + } + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + const code = error?.code === 'MANIFEST_TOO_LARGE' + ? error.code + : error instanceof SyntaxError ? 'MANIFEST_JSON_INVALID' : 'MANIFEST_UNREADABLE'; + throw Object.assign(new Error(code === 'MANIFEST_TOO_LARGE' ? error.message : 'Plugin manifest cannot be read'), { code }); + } + const validation = validateManifest(manifest); + if (!validation.success) throw Object.assign(new Error(validation.message), { code: validation.code }); + manifest = validation.data; + const entries = [manifest.main, ...(manifest.contributes.pages || []).map((page) => page.entry)].filter(Boolean); + for (const relativeEntry of entries) { + const error = validateEntry(resolvedSource, relativeEntry); + if (error) throw Object.assign(new Error(error.message), { code: error.code }); + } + return { resolvedSource, manifest }; +} + +function copyPluginPackage(sourceDirectory, targetDirectory) { + let fileCount = 0; + let totalBytes = 0; + + function copyDirectory(source, target) { + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name); + const targetPath = path.join(target, entry.name); + if (entry.isSymbolicLink()) { + throw Object.assign(new Error('Plugin packages cannot contain symbolic links'), { code: 'PLUGIN_PACKAGE_SYMLINK' }); + } + if (entry.isDirectory()) { + copyDirectory(sourcePath, targetPath); + continue; + } + if (!entry.isFile()) continue; + fileCount += 1; + totalBytes += fs.statSync(sourcePath).size; + if (fileCount > MAX_PLUGIN_PACKAGE_FILES || totalBytes > MAX_PLUGIN_PACKAGE_BYTES) { + throw Object.assign(new Error('Plugin package exceeds the installation limits'), { code: 'PLUGIN_PACKAGE_TOO_LARGE' }); + } + fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL); + } + } + + copyDirectory(sourceDirectory, targetDirectory); +} + +function createPluginManager({ + pluginsRoot, + statePath, + runtimeFactory = createPluginRuntime, + runtimeTimeoutMs, + dataRoot, + logsRoot, + catalog = createPluginCatalog(), + webSearch = searchSearxng, +}) { + if (typeof pluginsRoot !== 'string' || pluginsRoot.trim() === '') { + throw new TypeError('pluginsRoot must be a non-empty string'); + } + const resolvedRoot = path.resolve(pluginsRoot); + const stateStore = createPluginStateStore( + statePath || path.join(path.dirname(resolvedRoot), 'plugins-state.json') + ); + const resolvedDataRoot = path.resolve(dataRoot || path.join(path.dirname(resolvedRoot), 'plugin-data')); + const resolvedLogsRoot = path.resolve(logsRoot || path.join(path.dirname(resolvedRoot), 'plugin-logs')); + let state = stateStore.load(); + const runtimes = new Map(); + const activations = new Map(); + const lifecycleQueues = new Map(); + let initialized = false; + let scanCache = null; + + function saveState() { + stateStore.save(state); + } + + function stateFor(pluginId) { + return state.plugins[pluginId] || { enabled: false, grantedPermissions: [] }; + } + + function runLifecycle(pluginId, operation) { + const previous = lifecycleQueues.get(pluginId) || Promise.resolve(); + const next = previous.catch(() => {}).then(operation); + const tracked = next.finally(() => { + if (lifecycleQueues.get(pluginId) === tracked) lifecycleQueues.delete(pluginId); + }); + lifecycleQueues.set(pluginId, tracked); + return tracked; + } + + function recordError(pluginId, error) { + const current = stateFor(pluginId); + state.plugins[pluginId] = { + ...current, + enabled: false, + lastError: { ...safeError(error), at: new Date().toISOString() }, + }; + saveState(); + } + + function failRuntime(pluginId, error) { + runtimes.get(pluginId)?.terminate(); + runtimes.delete(pluginId); + recordError(pluginId, error); + } + + function findPlugin(pluginId) { + const scanResult = scanCache || scan(); + return scanResult.plugins.find((plugin) => plugin.manifest.id === pluginId) || null; + } + + async function authorizePageRequest(request) { + let validated; + try { validated = validatePageCapabilityRequest(request); } + catch (error) { return { success: false, error: safeError(error) }; } + const plugin = findPlugin(validated.pluginId); + if (!plugin?.manifest.contributes.pages?.some((page) => page.id === validated.pageId)) { + return { success: false, error: { code: 'PLUGIN_PAGE_NOT_FOUND', message: 'Plugin page was not found' } }; + } + if (!stateFor(validated.pluginId).enabled || (plugin.manifest.main && !runtimes.has(validated.pluginId))) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + try { + const router = createCapabilityRouter({ + storage: createPluginStorage({ dataRoot: resolvedDataRoot, pluginId: validated.pluginId }), + logger: createPluginLogger({ logsRoot: resolvedLogsRoot, pluginId: validated.pluginId }), + catalog, + }); + const value = await router.handle(plugin.manifest.permissions, validated); + return { success: true, value }; + } catch (error) { + return { success: false, error: safeError(error) }; + } + } + + function activatePlugin(plugin) { + if (!plugin.manifest.main) return Promise.resolve(); + const pluginId = plugin.manifest.id; + const inFlight = activations.get(pluginId); + if (inFlight) return inFlight; + if (runtimes.has(pluginId)) return Promise.resolve(); + + const activation = (async () => { + const entryPath = path.join(resolvedRoot, plugin.directoryName, plugin.manifest.main); + const permissions = plugin.manifest.permissions; + const capabilityRouter = createCapabilityRouter({ + storage: createPluginStorage({ dataRoot: resolvedDataRoot, pluginId }), + logger: createPluginLogger({ logsRoot: resolvedLogsRoot, pluginId }), + catalog, + }); + const runtime = runtimeFactory({ + entryPath, + pluginId, + permissions, + capabilityHandler: (request) => capabilityRouter.handle(permissions, request), + ...(runtimeTimeoutMs === undefined ? {} : { timeoutMs: runtimeTimeoutMs }), + }); + runtimes.set(pluginId, runtime); + try { + await runtime.activate(); + } catch (error) { + runtimes.delete(pluginId); + runtime.terminate(); + throw error; + } + })(); + + const tracked = activation.finally(() => { + if (activations.get(pluginId) === tracked) activations.delete(pluginId); + }); + activations.set(pluginId, tracked); + return tracked; + } + + function scanFresh() { + if (!fs.existsSync(resolvedRoot)) return { plugins: [], invalidPlugins: [] }; + + let rootRealPath; + let entries; + try { + rootRealPath = fs.realpathSync(resolvedRoot); + entries = fs.readdirSync(resolvedRoot, { withFileTypes: true }); + } catch { + return { + plugins: [], + invalidPlugins: [invalid('.', 'PLUGIN_ROOT_UNREADABLE', 'Plugin root cannot be read')], + }; + } + + const plugins = []; + const invalidPlugins = []; + const ids = new Map(); + entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); + + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const directoryName = entry.name; + const pluginDirectory = path.join(resolvedRoot, directoryName); + + let pluginDirectoryRealPath; + try { + pluginDirectoryRealPath = fs.realpathSync(pluginDirectory); + } catch { + invalidPlugins.push(invalid(directoryName, 'PLUGIN_DIRECTORY_UNREADABLE', 'Plugin directory cannot be resolved')); + continue; + } + if (!isInside(rootRealPath, pluginDirectoryRealPath)) { + invalidPlugins.push(invalid(directoryName, 'PLUGIN_DIRECTORY_OUTSIDE_ROOT', 'Plugin directory symlink resolves outside the plugin root')); + continue; + } + + const manifestPath = path.join(pluginDirectory, 'manifest.json'); + let manifestText; + try { + if (fs.statSync(manifestPath).size > MAX_MANIFEST_BYTES) { + invalidPlugins.push(invalid(directoryName, 'MANIFEST_TOO_LARGE', 'Plugin manifest exceeds the size limit')); + continue; + } + manifestText = fs.readFileSync(manifestPath, 'utf8'); + } catch (error) { + const code = error && error.code === 'ENOENT' ? 'MANIFEST_NOT_FOUND' : 'MANIFEST_UNREADABLE'; + invalidPlugins.push(invalid(directoryName, code, 'Plugin manifest cannot be read')); + continue; + } + + let manifest; + try { + manifest = JSON.parse(manifestText); + } catch { + invalidPlugins.push(invalid(directoryName, 'MANIFEST_JSON_INVALID', 'Plugin manifest is not valid JSON')); + continue; + } + + const validation = validateManifest(manifest); + if (!validation.success) { + invalidPlugins.push(invalid(directoryName, validation.code, validation.message)); + continue; + } + manifest = validation.data; + + if (ids.has(manifest.id)) { + invalidPlugins.push(invalid( + directoryName, + 'PLUGIN_ID_DUPLICATE', + `Plugin id '${manifest.id}' is already provided by '${ids.get(manifest.id)}'` + )); + continue; + } + + const entryPaths = []; + if (manifest.main) entryPaths.push(manifest.main); + for (const page of manifest.contributes.pages || []) entryPaths.push(page.entry); + const entryError = entryPaths.map((entryPath) => validateEntry(pluginDirectory, entryPath)).find(Boolean); + if (entryError) { + invalidPlugins.push(invalid(directoryName, entryError.code, entryError.message)); + continue; + } + + ids.set(manifest.id, directoryName); + plugins.push({ directoryName, manifest }); + } + + return { plugins, invalidPlugins }; + } + + function scan() { + scanCache = scanFresh(); + return scanCache; + } + + return { + scan, + updateSnapshot(snapshot) { + try { + return { success: true, counts: catalog.update(snapshot) }; + } catch (error) { + return { success: false, error: safeError(error, 'PLUGIN_SNAPSHOT_INVALID') }; + } + }, + async initialize() { + if (initialized) return; + initialized = true; + const { plugins } = scan(); + for (const plugin of plugins) { + const pluginState = stateFor(plugin.manifest.id); + if (!pluginState.enabled) continue; + if (!samePermissions(plugin.manifest.permissions, pluginState.grantedPermissions)) { + recordError(plugin.manifest.id, { + code: 'PLUGIN_PERMISSIONS_CHANGED', + message: 'Plugin permissions changed and must be confirmed again', + }); + continue; + } + try { + await activatePlugin(plugin); + } catch (error) { + recordError(plugin.manifest.id, error); + } + } + }, + async list() { + await this.initialize(); + const result = scan(); + return { + ...result, + plugins: result.plugins.map((plugin) => { + const pluginState = stateFor(plugin.manifest.id); + return { + ...plugin, + enabled: pluginState.enabled, + status: pluginState.lastError + ? 'error' + : pluginState.enabled + ? (plugin.manifest.main && !runtimes.has(plugin.manifest.id) ? 'error' : 'active') + : 'disabled', + grantedPermissions: [...pluginState.grantedPermissions], + ...(pluginState.lastError ? { lastError: { ...pluginState.lastError } } : {}), + }; + }), + }; + }, + installFromDirectory(sourceDirectory) { + if (typeof sourceDirectory !== 'string' || sourceDirectory.trim() === '') { + return { success: false, error: { code: 'PLUGIN_INSTALL_SOURCE_INVALID', message: 'Plugin source directory is invalid' } }; + } + let inspected; + try { + inspected = inspectPluginSource(sourceDirectory); + } catch (error) { + return { success: false, error: safeError(error, 'PLUGIN_INSTALL_SOURCE_INVALID') }; + } + const targetDirectory = path.join(resolvedRoot, inspected.manifest.id); + if (fs.existsSync(targetDirectory)) { + return { success: false, error: { code: 'PLUGIN_ALREADY_INSTALLED', message: 'Plugin is already installed' } }; + } + fs.mkdirSync(resolvedRoot, { recursive: true }); + const temporaryDirectory = path.join( + resolvedRoot, + `.installing-${inspected.manifest.id}-${process.pid}-${Date.now()}` + ); + try { + copyPluginPackage(inspected.resolvedSource, temporaryDirectory); + const copied = inspectPluginSource(temporaryDirectory); + if (copied.manifest.id !== inspected.manifest.id) { + throw Object.assign(new Error('Plugin manifest changed during installation'), { code: 'PLUGIN_INSTALL_CHANGED' }); + } + fs.renameSync(temporaryDirectory, targetDirectory); + scanCache = null; + return { success: true, pluginId: inspected.manifest.id }; + } catch (error) { + try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch {} + return { success: false, error: safeError(error, 'PLUGIN_INSTALL_FAILED') }; + } + }, + async enable(pluginId, grantedPermissions) { + return runLifecycle(pluginId, async () => { + const plugin = findPlugin(pluginId); + if (!plugin) { + return { success: false, error: { code: 'PLUGIN_NOT_FOUND', message: 'Plugin was not found' } }; + } + if (!samePermissions(plugin.manifest.permissions, grantedPermissions)) { + return { + success: false, + error: { + code: 'PLUGIN_PERMISSION_CONFIRMATION_REQUIRED', + message: 'All requested plugin permissions must be confirmed', + }, + }; + } + if (stateFor(pluginId).enabled && (!plugin.manifest.main || runtimes.has(pluginId))) return { success: true }; + try { + await activatePlugin(plugin); + state.plugins[pluginId] = { + enabled: true, + grantedPermissions: [...grantedPermissions], + }; + saveState(); + return { success: true }; + } catch (error) { + recordError(pluginId, error); + return { success: false, error: safeError(error) }; + } + }); + }, + async disable(pluginId) { + return runLifecycle(pluginId, async () => { + const plugin = findPlugin(pluginId); + if (!plugin) { + return { success: false, error: { code: 'PLUGIN_NOT_FOUND', message: 'Plugin was not found' } }; + } + const pendingActivation = activations.get(pluginId); + if (pendingActivation) { + // Wait for the in-flight activation, otherwise its Worker would stay alive after this disable. + try { + await pendingActivation; + } catch { + // The activation caller reports its own failure; disable still has to clean up. + } + } + const runtime = runtimes.get(pluginId); + try { + if (runtime) await runtime.deactivate(); + } catch (error) { + runtime.terminate(); + } finally { + runtimes.delete(pluginId); + } + state.plugins[pluginId] = { + enabled: false, + grantedPermissions: [...stateFor(pluginId).grantedPermissions], + }; + saveState(); + return { success: true }; + }); + }, + getPage(pluginId, pageId) { + const plugin = findPlugin(pluginId); + const page = plugin?.manifest.contributes.pages?.find((item) => item.id === pageId); + if (!plugin || !page) { + return { success: false, error: { code: 'PLUGIN_PAGE_NOT_FOUND', message: 'Plugin page was not found' } }; + } + if (!stateFor(pluginId).enabled || (plugin.manifest.main && !runtimes.has(pluginId))) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + return { success: true, url: pageUrl(pluginId, pageId) }; + }, + readPageResource(urlValue) { + let pluginId; + try { pluginId = new URL(urlValue).hostname; } catch { return null; } + const plugin = findPlugin(pluginId); + if (!plugin || !stateFor(pluginId).enabled || (plugin.manifest.main && !runtimes.has(pluginId))) return null; + return readPageResource(urlValue, path.join(resolvedRoot, plugin.directoryName), plugin.manifest); + }, + async requestPageCapability(request) { + return authorizePageRequest(request); + }, + getSearchEndpoint() { + return { endpoint: state.searchEndpoint }; + }, + configureWebSearch(endpoint) { + if (endpoint !== null) { + try { searchUrl(endpoint); } + catch (error) { return { success: false, error: safeError(error) }; } + } + state.searchEndpoint = endpoint; + saveState(); + return { success: true }; + }, + async searchWeb(request) { + const authorization = await authorizePageRequest({ ...request, method: 'web.search' }); + if (!authorization.success) return authorization; + if (!state.searchEndpoint) { + return { success: false, error: { code: 'PLUGIN_SEARCH_NOT_CONFIGURED', message: 'Web search service is not configured' } }; + } + try { + return { success: true, value: await webSearch(state.searchEndpoint, request.args) }; + } catch (error) { + return { success: false, error: safeError(error) }; + } + }, + async uninstall(pluginId, removePluginData) { + if (removePluginData !== undefined && typeof removePluginData !== 'boolean') { + return { + success: false, + error: { code: 'PLUGIN_UNINSTALL_OPTIONS_INVALID', message: 'Plugin uninstall options are invalid' }, + }; + } + const plugin = findPlugin(pluginId); + if (!plugin) { + return { success: false, error: { code: 'PLUGIN_NOT_FOUND', message: 'Plugin was not found' } }; + } + const disabled = await this.disable(pluginId); + if (!disabled.success) return disabled; + + const pluginDirectory = path.join(resolvedRoot, plugin.directoryName); + try { + const rootRealPath = fs.realpathSync(resolvedRoot); + const pluginRealPath = fs.realpathSync(pluginDirectory); + if (!isInside(rootRealPath, pluginRealPath)) { + return { + success: false, + error: { code: 'PLUGIN_DIRECTORY_OUTSIDE_ROOT', message: 'Plugin directory is outside the plugin root' }, + }; + } + fs.rmSync(pluginDirectory, { recursive: true, force: false }); + delete state.plugins[pluginId]; + saveState(); + scanCache = null; + } catch (error) { + return { success: false, error: safeError(error, 'PLUGIN_UNINSTALL_FAILED') }; + } + // The installed directory is already gone, so a data-removal failure is reported instead of + // being thrown: the caller can tell the user that files are still on disk. + let dataRemoved = false; + if (removePluginData) { + try { + const storageRemoved = removePluginStorage({ dataRoot: resolvedDataRoot, pluginId }); + const logsRemoved = removePluginLogs({ logsRoot: resolvedLogsRoot, pluginId }); + dataRemoved = storageRemoved && logsRemoved; + } catch { + dataRemoved = false; + } + } + return { success: true, dataRemoved }; + }, + async runAction(request) { + let validated; + try { + validated = validateRunActionRequest(request); + } catch (error) { + return { success: false, error: safeError(error) }; + } + const plugin = findPlugin(validated.pluginId); + if (!plugin) { + return { success: false, error: { code: 'PLUGIN_NOT_FOUND', message: 'Plugin was not found' } }; + } + const action = (plugin.manifest.contributes.repositoryActions || []) + .find((contribution) => contribution.id === validated.actionId); + if (!action) { + return { success: false, error: { code: 'PLUGIN_ACTION_NOT_FOUND', message: 'Plugin action was not found' } }; + } + if (!stateFor(validated.pluginId).enabled || !runtimes.has(validated.pluginId)) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + try { + const result = await runtimes.get(validated.pluginId).runAction({ + actionId: validated.actionId, + repositories: validated.repositories, + }); + if (result.type === 'open-external' && !plugin.manifest.permissions.includes('external:open')) { + return { + success: false, + error: { code: 'PLUGIN_PERMISSION_DENIED', message: "Permission 'external:open' is required" }, + }; + } + if ( + result.type === 'text' && + result.suggestedAction === 'copy' && + !plugin.manifest.permissions.includes('clipboard:write') + ) { + return { + success: false, + error: { code: 'PLUGIN_PERMISSION_DENIED', message: "Permission 'clipboard:write' is required" }, + }; + } + return { success: true, result }; + } catch (error) { + failRuntime(validated.pluginId, error); + return { success: false, error: safeError(error) }; + } + }, + async runProcessor(request) { + let validated; + try { + validated = validateRunProcessorRequest(request); + } catch (error) { + return { success: false, error: safeError(error) }; + } + const plugin = findPlugin(validated.pluginId); + const contribution = plugin?.manifest.contributes.repositoryProcessors?.find( + (processor) => processor.id === validated.processorId + ); + if (!plugin || !contribution) { + return { success: false, error: { code: 'PLUGIN_PROCESSOR_NOT_FOUND', message: 'Plugin processor was not found' } }; + } + if (!stateFor(validated.pluginId).enabled || !runtimes.has(validated.pluginId)) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + try { + return { + success: true, + result: await runtimes.get(validated.pluginId).runProcessor({ + processorId: validated.processorId, + repositories: validated.repositories, + }), + }; + } catch (error) { + failRuntime(validated.pluginId, error); + return { success: false, error: safeError(error) }; + } + }, + async runReleaseProcessor(request) { + let validated; + try { + validated = validateRunReleaseProcessorRequest(request); + } catch (error) { + return { success: false, error: safeError(error) }; + } + const plugin = findPlugin(validated.pluginId); + const contribution = plugin?.manifest.contributes.releaseProcessors?.find( + (processor) => processor.id === validated.processorId + ); + if (!plugin || !contribution) { + return { success: false, error: { code: 'PLUGIN_RELEASE_PROCESSOR_NOT_FOUND', message: 'Plugin release processor was not found' } }; + } + if (!stateFor(validated.pluginId).enabled || !runtimes.has(validated.pluginId)) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + // Update the Host snapshot before the plugin can query it, but keep a rejected snapshot from + // being recorded as a plugin runtime failure. + try { + catalog.upsert(request.repository, request.release); + } catch (error) { + return { success: false, error: safeError(error, 'PLUGIN_RELEASE_SNAPSHOT_INVALID') }; + } + try { + return { + success: true, + result: await runtimes.get(validated.pluginId).runReleaseProcessor({ + processorId: validated.processorId, + release: validated.release, + ...(validated.repository ? { repository: validated.repository } : {}), + hostEnvironment: { os: process.platform, arch: process.arch }, + }), + }; + } catch (error) { + failRuntime(validated.pluginId, error); + return { success: false, error: safeError(error) }; + } + }, + getDownloadAsset(pluginId, releaseId, assetId) { + const plugin = findPlugin(pluginId); + if (!plugin) return { success: false, error: { code: 'PLUGIN_NOT_FOUND', message: 'Plugin was not found' } }; + if (!stateFor(pluginId).enabled || !runtimes.has(pluginId)) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + if (!plugin.manifest.permissions.includes('downloads:create')) { + return { success: false, error: { code: 'PLUGIN_PERMISSION_DENIED', message: "Permission 'downloads:create' is required" } }; + } + let resolved; + try { + resolved = catalog.getDownloadAsset(releaseId, assetId); + } catch (error) { + return { success: false, error: safeError(error) }; + } + if (!resolved) { + return { success: false, error: { code: 'PLUGIN_ASSET_NOT_FOUND', message: 'Release asset was not found in the Host snapshot' } }; + } + return { success: true, value: resolved }; + }, + async runExporter(request) { + let validated; + try { + validated = validateRunExporterRequest(request); + } catch (error) { + return { success: false, error: safeError(error) }; + } + const plugin = findPlugin(validated.pluginId); + const contribution = plugin?.manifest.contributes.exporters?.find( + (exporter) => exporter.id === validated.exporterId + ); + if (!plugin || !contribution) { + return { success: false, error: { code: 'PLUGIN_EXPORTER_NOT_FOUND', message: 'Plugin exporter was not found' } }; + } + if (!stateFor(validated.pluginId).enabled || !runtimes.has(validated.pluginId)) { + return { success: false, error: { code: 'PLUGIN_NOT_ACTIVE', message: 'Plugin is not active' } }; + } + try { + const result = await runtimes.get(validated.pluginId).runExporter({ + exporterId: validated.exporterId, + repositories: validated.repositories, + }); + const defaultName = `${validated.pluginId}-${validated.exporterId}${contribution.fileExtension}`; + const fileName = result.fileName?.toLowerCase().endsWith(contribution.fileExtension.toLowerCase()) + ? result.fileName + : `${result.fileName || defaultName}${result.fileName ? contribution.fileExtension : ''}`; + return { + success: true, + result: { content: result.content, fileName, mimeType: contribution.mimeType }, + }; + } catch (error) { + failRuntime(validated.pluginId, error); + return { success: false, error: safeError(error) }; + } + }, + shutdown() { + for (const runtime of runtimes.values()) runtime.terminate(); + runtimes.clear(); + }, + }; +} + +module.exports = { createPluginManager }; diff --git a/electron/plugins/pluginManager.test.js b/electron/plugins/pluginManager.test.js new file mode 100644 index 000000000..ca46710f5 --- /dev/null +++ b/electron/plugins/pluginManager.test.js @@ -0,0 +1,761 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createPluginManager } = require('./pluginManager'); + +function createWorkspace(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugins-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function writePlugin(root, directoryName, manifest, files = {}) { + const directory = path.join(root, directoryName); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); + for (const [relativePath, content] of Object.entries(files)) { + const target = path.join(directory, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return directory; +} + +function validManifest(id, overrides = {}) { + return { + manifestVersion: 1, + id, + name: id, + version: '1.0.0', + apiVersion: '1', + main: 'worker.js', + permissions: [], + contributes: {}, + ...overrides, + }; +} + +test('scans valid first-level plugin directories without exposing absolute paths', (t) => { + const root = createWorkspace(t); + writePlugin(root, 'markdown', validManifest('com.example.markdown'), { + 'worker.js': 'module.exports = {};', + }); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + + assert.deepEqual(result.invalidPlugins, []); + assert.equal(result.plugins.length, 1); + assert.deepEqual(result.plugins[0], { + directoryName: 'markdown', + manifest: validManifest('com.example.markdown'), + }); + assert.equal(JSON.stringify(result).includes(root), false); +}); + +test('reports damaged plugins without preventing valid plugins from loading', (t) => { + const root = createWorkspace(t); + writePlugin(root, 'valid', validManifest('com.example.valid'), { 'worker.js': '' }); + const damaged = path.join(root, 'damaged'); + fs.mkdirSync(damaged); + fs.writeFileSync(path.join(damaged, 'manifest.json'), '{broken'); + fs.mkdirSync(path.join(root, 'missing-manifest')); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + + assert.deepEqual(result.plugins.map((plugin) => plugin.manifest.id), ['com.example.valid']); + assert.deepEqual( + result.invalidPlugins.map((plugin) => [plugin.directoryName, plugin.code]), + [ + ['damaged', 'MANIFEST_JSON_INVALID'], + ['missing-manifest', 'MANIFEST_NOT_FOUND'], + ] + ); +}); + +test('rejects duplicate plugin ids deterministically', (t) => { + const root = createWorkspace(t); + writePlugin(root, 'alpha', validManifest('com.example.duplicate'), { 'worker.js': '' }); + writePlugin(root, 'beta', validManifest('com.example.duplicate'), { 'worker.js': '' }); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + + assert.deepEqual(result.plugins.map((plugin) => plugin.directoryName), ['alpha']); + assert.deepEqual(result.invalidPlugins, [ + { + directoryName: 'beta', + code: 'PLUGIN_ID_DUPLICATE', + message: "Plugin id 'com.example.duplicate' is already provided by 'alpha'", + }, + ]); +}); + +test('rejects escaping, missing, and directory entry paths', (t) => { + const root = createWorkspace(t); + fs.writeFileSync(path.join(root, 'outside.js'), ''); + writePlugin(root, 'escape', validManifest('com.example.escape', { main: '../outside.js' })); + writePlugin(root, 'missing', validManifest('com.example.missing')); + writePlugin(root, 'directory', validManifest('com.example.directory', { main: 'runtime' }), { + 'runtime/.keep': '', + }); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + + assert.deepEqual( + result.invalidPlugins.map((plugin) => [plugin.directoryName, plugin.code]), + [ + ['directory', 'PLUGIN_ENTRY_NOT_FILE'], + ['escape', 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY'], + ['missing', 'PLUGIN_ENTRY_NOT_FOUND'], + ] + ); +}); + +test('scanning reads metadata but never executes plugin code', (t) => { + const root = createWorkspace(t); + const marker = path.join(root, 'executed.txt'); + writePlugin(root, 'side-effect', validManifest('com.example.side-effect'), { + 'worker.js': `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'executed');`, + }); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + + assert.equal(result.plugins.length, 1); + assert.equal(fs.existsSync(marker), false); +}); + +test('reuses cached discovery for lookups and scan explicitly refreshes it', (t) => { + const root = createWorkspace(t); + writePlugin(root, 'page', validManifest('com.example.cached', { + main: undefined, + contributes: { pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'index.html' }] }, + }), { 'index.html': '' }); + const manager = createPluginManager({ pluginsRoot: root }); + const originalRead = fs.readFileSync; + let manifestReads = 0; + fs.readFileSync = function (...args) { + if (String(args[0]).endsWith('manifest.json')) manifestReads += 1; + return originalRead.apply(this, args); + }; + t.after(() => { fs.readFileSync = originalRead; }); + + manager.scan(); + manager.getPage('com.example.cached', 'dashboard'); + manager.getPage('com.example.cached', 'dashboard'); + assert.equal(manifestReads, 1); + manager.scan(); + assert.equal(manifestReads, 2); +}); + +test('rejects page entry paths that escape or do not exist', (t) => { + const root = createWorkspace(t); + const pageManifest = validManifest('com.example.page', { + main: undefined, + contributes: { + pages: [{ id: 'dashboard', title: 'Dashboard', entry: '../outside.html' }], + }, + }); + delete pageManifest.main; + writePlugin(root, 'page', pageManifest); + + const result = createPluginManager({ pluginsRoot: root }).scan(); + assert.equal(result.invalidPlugins[0].code, 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY'); +}); + +test('rejects symlinked plugin directories and entry files that escape the plugin root', (t) => { + const root = createWorkspace(t); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-outside-')); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + fs.writeFileSync(path.join(outside, 'worker.js'), ''); + fs.writeFileSync( + path.join(outside, 'manifest.json'), + JSON.stringify(validManifest('com.example.linked-directory')) + ); + + const directoryLink = path.join(root, 'linked-directory'); + const entryDirectory = writePlugin(root, 'linked-entry', validManifest('com.example.linked-entry')); + try { + fs.symlinkSync(outside, directoryLink, 'junction'); + fs.symlinkSync(path.join(outside, 'worker.js'), path.join(entryDirectory, 'worker.js'), 'file'); + } catch (error) { + t.skip(`Symlink creation is unavailable: ${error.message}`); + return; + } + + const result = createPluginManager({ pluginsRoot: root }).scan(); + assert.deepEqual( + result.invalidPlugins.map((plugin) => [plugin.directoryName, plugin.code]), + [ + ['linked-directory', 'PLUGIN_DIRECTORY_OUTSIDE_ROOT'], + ['linked-entry', 'PLUGIN_ENTRY_OUTSIDE_DIRECTORY'], + ] + ); +}); + +function createFakeRuntime(events, actionResult = { type: 'text', content: 'ok' }) { + return { + async activate() { events.push('activate'); }, + async deactivate() { events.push('deactivate'); }, + async runAction(input) { events.push(['runAction', input]); return actionResult; }, + async runProcessor(input) { events.push(['runProcessor', input]); return { repositories: [] }; }, + async runReleaseProcessor(input) { events.push(['runReleaseProcessor', input]); return { recommendedAssetId: 3, confidence: 0.9, reason: 'Windows x64' }; }, + async runExporter(input) { events.push(['runExporter', input]); return { content: '# Export' }; }, + terminate() { events.push('terminate'); }, + }; +} + +test('requires exact permission confirmation before enabling and persists lifecycle state', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'exporter', validManifest('com.example.exporter', { + permissions: ['repositories:read'], + contributes: { + repositoryActions: [{ id: 'export', title: 'Export', placement: 'bulk-toolbar' }], + }, + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + + assert.equal((await manager.list()).plugins[0].status, 'disabled'); + assert.equal((await manager.enable('com.example.exporter', [])).error.code, 'PLUGIN_PERMISSION_CONFIRMATION_REQUIRED'); + assert.deepEqual(await manager.enable('com.example.exporter', ['repositories:read']), { success: true }); + assert.equal((await manager.list()).plugins[0].status, 'active'); + assert.deepEqual(events, ['activate']); + + assert.deepEqual(await manager.disable('com.example.exporter'), { success: true }); + assert.equal((await manager.list()).plugins[0].status, 'disabled'); + assert.deepEqual(events, ['activate', 'deactivate']); + assert.equal(JSON.parse(fs.readFileSync(statePath, 'utf8')).plugins['com.example.exporter'].enabled, false); +}); + +test('rejects duplicated or unknown granted permissions instead of enabling', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'duplicates', validManifest('com.example.duplicates', { + permissions: ['repositories:read', 'ai:invoke'], + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + + const duplicated = await manager.enable('com.example.duplicates', ['repositories:read', 'repositories:read']); + assert.equal(duplicated.error.code, 'PLUGIN_PERMISSION_CONFIRMATION_REQUIRED'); + const unknown = await manager.enable('com.example.duplicates', ['repositories:read', 'made:up']); + assert.equal(unknown.error.code, 'PLUGIN_PERMISSION_CONFIRMATION_REQUIRED'); + assert.deepEqual(events, []); + + assert.deepEqual( + await manager.enable('com.example.duplicates', ['repositories:read', 'ai:invoke']), + { success: true } + ); + assert.deepEqual(events, ['activate']); +}); + +test('coalesces concurrent activation requests for the same plugin', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'shared', validManifest('com.example.shared'), { 'worker.js': '' }); + const events = []; + let runtimeCount = 0; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => { runtimeCount += 1; return createFakeRuntime(events); }, + }); + + const [first, second] = await Promise.all([ + manager.enable('com.example.shared', []), + manager.enable('com.example.shared', []), + ]); + + assert.deepEqual(first, { success: true }); + assert.deepEqual(second, { success: true }); + assert.equal(runtimeCount, 1); + assert.deepEqual(events, ['activate']); +}); + +test('concurrent enable waits for a failing in-flight activation instead of reporting success', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'failing', validManifest('com.example.failing'), { 'worker.js': '' }); + let releaseActivation; + const gate = new Promise((resolve) => { releaseActivation = resolve; }); + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => ({ + async activate() { + await gate; + throw Object.assign(new Error('startup failed'), { code: 'PLUGIN_RUNTIME_ERROR' }); + }, + terminate() {}, + }), + }); + + const first = manager.enable('com.example.failing', []); + const second = manager.enable('com.example.failing', []); + releaseActivation(); + const [left, right] = await Promise.all([first, second]); + assert.equal(left.success, false); + assert.equal(right.success, false); + assert.equal(left.error.code, 'PLUGIN_RUNTIME_ERROR'); + assert.equal(right.error.code, 'PLUGIN_RUNTIME_ERROR'); + assert.equal((await manager.list()).plugins[0].status, 'error'); +}); + +test('never leaves an active Worker when a plugin is disabled during activation', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'slow', validManifest('com.example.slow'), { 'worker.js': '' }); + const events = []; + let releaseActivation; + const gate = new Promise((resolve) => { releaseActivation = resolve; }); + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => ({ + async activate() { events.push('activate'); await gate; events.push('activated'); }, + async deactivate() { events.push('deactivate'); }, + async runAction() { return { type: 'text', content: 'ok' }; }, + terminate() { events.push('terminate'); }, + }), + }); + + const enabling = manager.enable('com.example.slow', []); + releaseActivation(); + const disabled = await manager.disable('com.example.slow'); + + assert.deepEqual(await enabling, { success: true }); + assert.deepEqual(disabled, { success: true }); + assert.deepEqual(events, ['activate', 'activated', 'deactivate']); + assert.equal((await manager.list()).plugins[0].status, 'disabled'); +}); + +test('serializes overlapping enable and disable so a later disable wins', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'overlap', validManifest('com.example.overlap'), { 'worker.js': '' }); + const events = []; + let releaseDisable; + const gate = new Promise((resolve) => { releaseDisable = resolve; }); + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => ({ + async activate() { events.push('activate'); }, + async deactivate() { events.push('deactivate-start'); await gate; events.push('deactivate-end'); }, + terminate() { events.push('terminate'); }, + }), + }); + + assert.deepEqual(await manager.enable('com.example.overlap', []), { success: true }); + const disabling = manager.disable('com.example.overlap'); + const reenabled = manager.enable('com.example.overlap', []); + releaseDisable(); + assert.deepEqual(await disabling, { success: true }); + assert.deepEqual(await reenabled, { success: true }); + assert.deepEqual(events, ['activate', 'deactivate-start', 'deactivate-end', 'activate']); + assert.equal((await manager.list()).plugins[0].status, 'active'); +}); + +test('restores enabled plugins and disables them when permissions change', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'plugin', validManifest('com.example.restore'), { 'worker.js': '' }); + fs.writeFileSync(statePath, JSON.stringify({ + version: 1, + plugins: { + 'com.example.restore': { enabled: true, grantedPermissions: [] }, + }, + })); + const restoredEvents = []; + const restored = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(restoredEvents), + }); + await restored.initialize(); + assert.deepEqual(restoredEvents, ['activate']); + + const manifestPath = path.join(root, 'plugin', 'manifest.json'); + const changed = validManifest('com.example.restore', { permissions: ['storage'] }); + fs.writeFileSync(manifestPath, JSON.stringify(changed)); + const changedManager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime([]), + }); + const listed = await changedManager.list(); + assert.equal(listed.plugins[0].enabled, false); + assert.equal(listed.plugins[0].lastError.code, 'PLUGIN_PERMISSIONS_CHANGED'); +}); + +test('runs only declared actions for active plugins with sanitized repository data', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'actions', validManifest('com.example.actions', { + permissions: ['repositories:read'], + contributes: { + repositoryActions: [{ id: 'export', title: 'Export', placement: 'bulk-toolbar' }], + }, + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + await manager.enable('com.example.actions', ['repositories:read']); + + const missing = await manager.runAction({ + pluginId: 'com.example.actions', actionId: 'missing', repositories: [], + }); + assert.equal(missing.error.code, 'PLUGIN_ACTION_NOT_FOUND'); + + const result = await manager.runAction({ + pluginId: 'com.example.actions', + actionId: 'export', + repositories: [{ + id: 1, + name: 'project', + full_name: 'owner/project', + description: null, + html_url: 'https://github.com/owner/project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + pushed_at: '2026-01-01T00:00:00Z', + owner: { login: 'owner', avatar_url: 'private' }, + topics: [], + token: 'must not cross', + }], + }); + assert.deepEqual(result, { success: true, result: { type: 'text', content: 'ok' } }); + const actionInput = events.find((event) => Array.isArray(event))[1]; + assert.equal('token' in actionInput.repositories[0], false); + assert.equal('avatar_url' in actionInput.repositories[0].owner, false); +}); + +test('disables a plugin before uninstalling only its validated directory', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + const pluginDirectory = writePlugin(root, 'remove-me', validManifest('com.example.remove'), { + 'worker.js': '', + }); + const sibling = path.join(root, 'keep.txt'); + fs.writeFileSync(sibling, 'keep'); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + await manager.enable('com.example.remove', []); + + assert.deepEqual(await manager.uninstall('com.example.remove'), { success: true, dataRemoved: false }); + assert.deepEqual(events, ['activate', 'deactivate']); + assert.equal(fs.existsSync(pluginDirectory), false); + assert.equal(fs.readFileSync(sibling, 'utf8'), 'keep'); + assert.equal((await manager.list()).plugins.length, 0); +}); + +test('keeps or deletes isolated plugin storage and logs according to uninstall choice', async (t) => { + const workspace = createWorkspace(t); + const pluginsRoot = path.join(workspace, 'plugins'); + const dataRoot = path.join(workspace, 'plugin-data'); + const logsRoot = path.join(workspace, 'plugin-logs'); + const statePath = path.join(workspace, 'plugins-state.json'); + writePlugin(pluginsRoot, 'keep-data', validManifest('com.example.keep-data', { permissions: ['storage'] }), { + 'worker.js': '', + }); + writePlugin(pluginsRoot, 'wipe-data', validManifest('com.example.wipe-data', { permissions: ['storage'] }), { + 'worker.js': '', + }); + fs.mkdirSync(dataRoot, { recursive: true }); + fs.mkdirSync(logsRoot, { recursive: true }); + fs.writeFileSync(path.join(dataRoot, 'com.example.keep-data.json'), '{"settings":true}\n'); + fs.writeFileSync(path.join(logsRoot, 'com.example.keep-data.log'), 'keep\n'); + fs.writeFileSync(path.join(dataRoot, 'com.example.wipe-data.json'), '{"settings":true}\n'); + fs.writeFileSync(path.join(logsRoot, 'com.example.wipe-data.log'), 'wipe\n'); + fs.writeFileSync(path.join(logsRoot, 'com.example.wipe-data.log.1'), 'rotated\n'); + const manager = createPluginManager({ + pluginsRoot, + statePath, + dataRoot, + logsRoot, + runtimeFactory: () => createFakeRuntime([]), + }); + await manager.enable('com.example.keep-data', ['storage']); + await manager.enable('com.example.wipe-data', ['storage']); + + assert.deepEqual(await manager.uninstall('com.example.keep-data', false), { success: true, dataRemoved: false }); + assert.equal(fs.existsSync(path.join(dataRoot, 'com.example.keep-data.json')), true); + assert.equal(fs.existsSync(path.join(logsRoot, 'com.example.keep-data.log')), true); + + assert.deepEqual(await manager.uninstall('com.example.wipe-data', true), { success: true, dataRemoved: true }); + assert.equal(fs.existsSync(path.join(dataRoot, 'com.example.wipe-data.json')), false); + assert.equal(fs.existsSync(path.join(logsRoot, 'com.example.wipe-data.log')), false); + assert.equal(fs.existsSync(path.join(logsRoot, 'com.example.wipe-data.log.1')), false); + assert.equal((await manager.uninstall('com.example.wipe-data', 'yes')).error.code, 'PLUGIN_UNINSTALL_OPTIONS_INVALID'); +}); + +test('does not disable a plugin when a release snapshot is invalid', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'release', validManifest('com.example.release', { + permissions: ['releases:read'], + contributes: { releaseProcessors: [{ id: 'recommend', title: 'Recommend' }] }, + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + await manager.enable('com.example.release', ['releases:read']); + const result = await manager.runReleaseProcessor({ + pluginId: 'com.example.release', + processorId: 'recommend', + release: { + id: 20, + tag_name: 'v1', + published_at: '2026-01-01', + html_url: 'https://github.com/owner/project/releases/tag/v1', + repository: { id: 1, full_name: 'owner/project', name: 'project' }, + assets: [{ + id: 21, + name: 'app.zip', + size: 1, + download_count: 0, + browser_download_url: 'https://evil.example/app.zip', + content_type: 'application/zip', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }], + }, + }); + assert.equal(result.success, false); + assert.equal(result.error.code, 'PLUGIN_SNAPSHOT_INVALID'); + assert.equal((await manager.list()).plugins[0].status, 'active'); + assert.deepEqual(events, ['activate']); +}); + +test('runs declared processors and exporters for active plugins', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'tools', validManifest('com.example.tools', { + permissions: ['repositories:read'], + contributes: { + repositoryProcessors: [{ id: 'health', title: 'Health' }], + exporters: [{ id: 'markdown', title: 'Markdown', fileExtension: '.md', mimeType: 'text/markdown' }], + }, + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ + pluginsRoot: root, + statePath, + runtimeFactory: () => createFakeRuntime(events), + }); + await manager.enable('com.example.tools', ['repositories:read']); + const repositories = [{ + id: 1, name: 'p', full_name: 'o/p', html_url: 'https://github.com/o/p', + created_at: '2026-01-01', updated_at: '2026-01-01', pushed_at: '2026-01-01', + owner: { login: 'o' }, topics: [], + }]; + + assert.deepEqual(await manager.runProcessor({ + pluginId: 'com.example.tools', processorId: 'health', repositories, + }), { success: true, result: { repositories: [] } }); + assert.deepEqual(await manager.runExporter({ + pluginId: 'com.example.tools', exporterId: 'markdown', repositories, + }), { + success: true, + result: { + content: '# Export', + fileName: 'com.example.tools-markdown.md', + mimeType: 'text/markdown', + }, + }); +}); + +test('runs declared release processors and authorizes only Host-known asset downloads', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + writePlugin(root, 'release', validManifest('com.example.release', { + permissions: ['releases:read', 'downloads:create'], + contributes: { releaseProcessors: [{ id: 'recommend', title: 'Recommend' }] }, + }), { 'worker.js': '' }); + const events = []; + const manager = createPluginManager({ pluginsRoot: root, statePath, runtimeFactory: () => createFakeRuntime(events) }); + await manager.enable('com.example.release', ['releases:read', 'downloads:create']); + const release = { + id: 2, tag_name: 'v1', name: null, body: null, published_at: '2026-01-01', + html_url: 'https://github.com/o/p/releases/tag/v1', repository: { id: 1, full_name: 'o/p', name: 'p' }, + assets: [{ id: 3, name: 'p-x64.exe', size: 10, download_count: 0, + browser_download_url: 'https://github.com/o/p/releases/download/v1/p-x64.exe', + content_type: 'application/octet-stream', created_at: '2026-01-01', updated_at: '2026-01-01' }], + }; + assert.equal((await manager.runReleaseProcessor({ + pluginId: 'com.example.release', processorId: 'recommend', release, + })).success, true); + assert.equal(manager.getDownloadAsset('com.example.release', 2, 3).success, true); + assert.equal(manager.getDownloadAsset('com.example.release', 2, 4).error.code, 'PLUGIN_ASSET_NOT_FOUND'); + const payload = events.find((event) => Array.isArray(event) && event[0] === 'runReleaseProcessor')[1]; + assert.equal('browser_download_url' in payload.release.assets[0], false); + assert.equal(typeof payload.hostEnvironment.os, 'string'); +}); + +test('installs a validated local directory as disabled without copying symlinks', async (t) => { + const root = createWorkspace(t); + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-source-')); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + const source = writePlugin(sourceRoot, 'source', validManifest('com.example.install'), { + 'worker.js': 'module.exports = {};', + }); + const manager = createPluginManager({ pluginsRoot: root }); + + assert.deepEqual(manager.installFromDirectory(source), { + success: true, + pluginId: 'com.example.install', + }); + const listed = await manager.list(); + assert.equal(listed.plugins[0].manifest.id, 'com.example.install'); + assert.equal(listed.plugins[0].enabled, false); + assert.equal(fs.existsSync(path.join(root, 'com.example.install', 'worker.js')), true); + + const linked = writePlugin(sourceRoot, 'linked', validManifest('com.example.linked-install'), { + 'worker.js': 'module.exports = {};', + }); + try { + fs.symlinkSync(path.join(source, 'worker.js'), path.join(linked, 'extra-link.js'), 'file'); + } catch (error) { + t.diagnostic(`Symlink creation unavailable: ${error.message}`); + return; + } + assert.equal(manager.installFromDirectory(linked).error.code, 'PLUGIN_PACKAGE_SYMLINK'); + assert.equal(fs.existsSync(path.join(root, 'com.example.linked-install')), false); +}); + +test('rejects oversized manifests and activates page-only plugins without a Worker', async (t) => { + const root = createWorkspace(t); + const oversized = path.join(root, 'oversized'); + fs.mkdirSync(oversized); + fs.writeFileSync(path.join(oversized, 'manifest.json'), ' '.repeat(256 * 1024 + 1)); + + const pageManifest = validManifest('com.example.page-only', { + contributes: { pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'index.html' }] }, + }); + delete pageManifest.main; + writePlugin(root, 'page-only', pageManifest, { 'index.html': '' }); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + const manager = createPluginManager({ pluginsRoot: root, statePath }); + + const listed = await manager.list(); + assert.equal(listed.invalidPlugins[0].code, 'MANIFEST_TOO_LARGE'); + const enabled = await manager.enable('com.example.page-only', []); + assert.deepEqual(enabled, { success: true }); + assert.equal(manager.getPage('com.example.page-only', 'dashboard').url, + 'plugin-page://com.example.page-only/dashboard/index.html'); + assert.equal((await manager.list()).plugins[0].status, 'active'); + assert.deepEqual(await manager.disable('com.example.page-only'), { success: true }); + assert.equal(manager.getPage('com.example.page-only', 'dashboard').error.code, 'PLUGIN_NOT_ACTIVE'); +}); + +test('page requests use the declared Host capability and stop after disable', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + const manifest = validManifest('com.example.health-page', { + permissions: ['repositories:read'], + contributes: { pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'ui/index.html' }] }, + }); + delete manifest.main; + writePlugin(root, 'health-page', manifest, { 'ui/index.html': '' }); + const manager = createPluginManager({ pluginsRoot: root, statePath }); + manager.updateSnapshot({ repositories: [{ + id: 1, name: 'repo', full_name: 'owner/repo', html_url: 'https://github.com/owner/repo', + description: 'Example', stargazers_count: 5, created_at: '2026-01-01', + updated_at: '2026-01-01', pushed_at: '2026-01-01', owner: { login: 'owner' }, topics: [], + }], releases: [] }); + assert.deepEqual(await manager.enable(manifest.id, manifest.permissions), { success: true }); + const result = await manager.requestPageCapability({ + pluginId: manifest.id, pageId: 'dashboard', method: 'repositories.search', args: { query: 'owner', limit: 5 }, + }); + assert.equal(result.success, true); + assert.equal(result.value[0].full_name, 'owner/repo'); + assert.equal((await manager.requestPageCapability({ + pluginId: manifest.id, pageId: 'dashboard', method: 'releases.get', args: { releaseId: 1 }, + })).error.code, 'PLUGIN_PERMISSION_DENIED'); + await manager.disable(manifest.id); + assert.equal((await manager.requestPageCapability({ + pluginId: manifest.id, pageId: 'dashboard', method: 'repositories.search', args: { query: 'owner' }, + })).error.code, 'PLUGIN_NOT_ACTIVE'); +}); + +test('AI page requests only authorize a declared and enabled permission', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + const manifest = validManifest('com.example.ai-page', { + permissions: ['ai:invoke'], + contributes: { pages: [{ id: 'dashboard', title: 'AI', entry: 'ui/index.html' }] }, + }); + delete manifest.main; + writePlugin(root, 'ai-page', manifest, { 'ui/index.html': '' }); + const manager = createPluginManager({ pluginsRoot: root, statePath }); + const request = { pluginId: manifest.id, pageId: 'dashboard', method: 'ai.generate', args: { system: '', user: 'Example' } }; + assert.equal((await manager.requestPageCapability(request)).error.code, 'PLUGIN_NOT_ACTIVE'); + assert.deepEqual(await manager.enable(manifest.id, manifest.permissions), { success: true }); + assert.deepEqual(await manager.requestPageCapability(request), { success: true, value: null }); + await manager.disable(manifest.id); + assert.equal((await manager.requestPageCapability(request)).error.code, 'PLUGIN_NOT_ACTIVE'); +}); + +test('web search uses only the user-configured endpoint after page authorization', async (t) => { + const root = createWorkspace(t); + const statePath = path.join(root, '..', `${path.basename(root)}-state.json`); + t.after(() => fs.rmSync(statePath, { force: true })); + const manifest = validManifest('com.example.search-page', { + permissions: ['web:search'], + contributes: { pages: [{ id: 'dashboard', title: 'Search', entry: 'ui/index.html' }] }, + }); + delete manifest.main; + writePlugin(root, 'search-page', manifest, { 'ui/index.html': '' }); + const calls = []; + const manager = createPluginManager({ pluginsRoot: root, statePath, + webSearch: async (endpoint, args) => { calls.push([endpoint, args]); return [{ title: 'Example' }]; }, + }); + const request = { pluginId: manifest.id, pageId: 'dashboard', args: { query: 'Example', limit: 2 } }; + assert.equal((await manager.searchWeb(request)).error.code, 'PLUGIN_NOT_ACTIVE'); + assert.deepEqual(await manager.enable(manifest.id, manifest.permissions), { success: true }); + assert.equal((await manager.searchWeb(request)).error.code, 'PLUGIN_SEARCH_NOT_CONFIGURED'); + assert.equal(manager.configureWebSearch('http://localhost:8888').error.code, 'PLUGIN_SEARCH_ENDPOINT_INVALID'); + assert.deepEqual(manager.configureWebSearch('https://search.example.com'), { success: true }); + assert.deepEqual(await manager.searchWeb(request), { success: true, value: [{ title: 'Example' }] }); + assert.deepEqual(calls, [['https://search.example.com', { query: 'Example', limit: 2 }]]); + assert.deepEqual(manager.getSearchEndpoint(), { endpoint: 'https://search.example.com' }); + await manager.disable(manifest.id); + assert.equal((await manager.searchWeb(request)).error.code, 'PLUGIN_NOT_ACTIVE'); + assert.equal(calls.length, 1); +}); diff --git a/electron/plugins/pluginPage.js b/electron/plugins/pluginPage.js new file mode 100644 index 000000000..112f5da15 --- /dev/null +++ b/electron/plugins/pluginPage.js @@ -0,0 +1,80 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const PAGE_SCHEME = 'plugin-page'; +function pageCsp(pluginId) { + const localSource = `${PAGE_SCHEME}://${pluginId}`; + return [ + "default-src 'none'", + `script-src ${localSource}`, + `style-src ${localSource}`, + `img-src ${localSource} data:`, + `font-src ${localSource} data:`, + "connect-src 'none'", + "frame-src 'none'", + "worker-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join('; '); +} +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function isInsideOrEqual(parent, candidate) { + const relative = path.relative(parent, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function pageUrl(pluginId, pageId) { + return `${PAGE_SCHEME}://${pluginId}/${pageId}/index.html`; +} + +function readPageResource(urlValue, pluginDirectory, manifest) { + let url; + try { url = new URL(urlValue); } catch { return null; } + if (url.protocol !== `${PAGE_SCHEME}:` || url.hostname !== manifest.id || url.search || url.hash) return null; + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length < 2) return null; + let decoded; + try { decoded = segments.map((segment) => decodeURIComponent(segment)); } catch { return null; } + if (decoded.some((segment) => !segment || segment === '.' || segment === '..' || /[\\/:\x00-\x1f]/.test(segment))) return null; + const [pageId, ...resourceParts] = decoded; + const page = manifest.contributes.pages?.find((item) => item.id === pageId); + if (!page) return null; + const pageRoot = path.resolve(pluginDirectory, path.dirname(page.entry)); + const resource = resourceParts.join('/') === 'index.html' + ? path.resolve(pluginDirectory, page.entry) + : path.resolve(pageRoot, ...resourceParts); + if (!isInsideOrEqual(pageRoot, resource)) return null; + if (resource === path.resolve(pluginDirectory, 'manifest.json') || + (manifest.main && resource === path.resolve(pluginDirectory, manifest.main))) return null; + try { + if (!isInsideOrEqual(fs.realpathSync(pageRoot), fs.realpathSync(resource))) return null; + const stat = fs.statSync(resource); + if (!stat.isFile() || stat.size > 20 * 1024 * 1024) return null; + const mimeType = MIME_TYPES[path.extname(resource).toLowerCase()]; + if (!mimeType) return null; + return { body: fs.readFileSync(resource), mimeType }; + } catch { + return null; + } +} + +module.exports = { PAGE_SCHEME, pageCsp, pageUrl, readPageResource }; diff --git a/electron/plugins/pluginPage.test.js b/electron/plugins/pluginPage.test.js new file mode 100644 index 000000000..fb9bba92f --- /dev/null +++ b/electron/plugins/pluginPage.test.js @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { pageCsp, pageUrl, readPageResource } = require('./pluginPage'); + +test('serves only declared page resources within the page directory', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsm-plugin-page-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'ui')); + fs.writeFileSync(path.join(root, 'ui', 'index.html'), ''); + fs.writeFileSync(path.join(root, 'ui', 'index.js'), 'document.body.textContent = "safe";'); + fs.writeFileSync(path.join(root, 'worker.js'), 'private worker source'); + const manifest = { + id: 'com.example.page', main: 'worker.js', + contributes: { pages: [{ id: 'dashboard', title: 'Dashboard', entry: 'ui/index.html' }] }, + }; + const url = pageUrl(manifest.id, 'dashboard'); + assert.equal(readPageResource(url, root, manifest).mimeType, 'text/html; charset=utf-8'); + assert.match(readPageResource(url.replace('index.html', 'index.js'), root, manifest).body.toString(), /safe/); + assert.equal(readPageResource('plugin-page://com.example.page/dashboard/%2e%2e/worker.js', root, manifest), null); + assert.equal(readPageResource('plugin-page://com.example.page/dashboard/worker.js', root, manifest), null); + assert.equal(readPageResource('plugin-page://com.other.page/dashboard/index.html', root, manifest), null); + assert.match(pageCsp(manifest.id), /connect-src 'none'/); + assert.match(pageCsp(manifest.id), /script-src plugin-page:\/\/com\.example\.page/); + assert.doesNotMatch(pageCsp(manifest.id), /unsafe-inline/); +}); diff --git a/electron/plugins/pluginPageBridge.js b/electron/plugins/pluginPageBridge.js new file mode 100644 index 000000000..9b720d460 --- /dev/null +++ b/electron/plugins/pluginPageBridge.js @@ -0,0 +1,61 @@ +'use strict'; + +const { protocolError } = require('./pluginProtocol'); + +const METHODS = { + 'repositories.search': { capability: 'github', operation: 'searchRepositories', fields: ['query', 'limit'] }, + 'repositories.get': { capability: 'github', operation: 'getRepository', fields: ['repositoryId'] }, + 'releases.get': { capability: 'github', operation: 'getRelease', fields: ['releaseId'] }, + 'storage.get': { capability: 'storage', operation: 'get', fields: ['key'] }, + 'storage.set': { capability: 'storage', operation: 'set', fields: ['key', 'value'] }, + 'storage.delete': { capability: 'storage', operation: 'delete', fields: ['key'] }, + 'ai.generate': { capability: 'ai', operation: 'generate', fields: ['system', 'user', 'maxTokens'] }, + 'web.search': { capability: 'web', operation: 'search', fields: ['query', 'limit'] }, +}; + +function validatePageCapabilityRequest(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) || + Object.keys(input).some((field) => !['pluginId', 'pageId', 'method', 'args'].includes(field))) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Page request is invalid'); + } + const { pluginId, pageId, method, args = {} } = input; + if (typeof pluginId !== 'string' || typeof pageId !== 'string' || typeof method !== 'string' || + !Object.hasOwn(METHODS, method) || !args || typeof args !== 'object' || Array.isArray(args)) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Page request is invalid'); + } + const definition = METHODS[method]; + if (Object.keys(args).some((field) => !definition.fields.includes(field))) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Page request has unknown arguments'); + } + if (Buffer.byteLength(JSON.stringify(args), 'utf8') > 1024 * 1024) { + throw protocolError('PLUGIN_PAGE_REQUEST_TOO_LARGE', 'Page request is too large'); + } + if (method === 'repositories.search' && + (typeof args.query !== 'string' || args.query.length > 200 || + (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 100)))) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Repository search arguments are invalid'); + } + if (method === 'repositories.get' && (!Number.isSafeInteger(args.repositoryId) || args.repositoryId <= 0)) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Repository id is invalid'); + } + if (method === 'releases.get' && (!Number.isSafeInteger(args.releaseId) || args.releaseId <= 0)) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Release id is invalid'); + } + if (method.startsWith('storage.') && (typeof args.key !== 'string' || !args.key)) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Storage key is invalid'); + } + if (method === 'ai.generate' && + (typeof args.system !== 'string' || args.system.length > 2000 || + typeof args.user !== 'string' || !args.user.trim() || args.user.length > 8000 || + (args.maxTokens !== undefined && (!Number.isInteger(args.maxTokens) || args.maxTokens < 1 || args.maxTokens > 4000)))) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'AI request arguments are invalid'); + } + if (method === 'web.search' && + (typeof args.query !== 'string' || !args.query.trim() || args.query.length > 200 || + (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 10)))) { + throw protocolError('PLUGIN_PAGE_REQUEST_INVALID', 'Web search arguments are invalid'); + } + return { pluginId, pageId, capability: definition.capability, operation: definition.operation, args }; +} + +module.exports = { validatePageCapabilityRequest }; diff --git a/electron/plugins/pluginPageBridge.test.js b/electron/plugins/pluginPageBridge.test.js new file mode 100644 index 000000000..dabd9c2e6 --- /dev/null +++ b/electron/plugins/pluginPageBridge.test.js @@ -0,0 +1,53 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { validatePageCapabilityRequest } = require('./pluginPageBridge'); + +test('maps only declared semantic page requests', () => { + assert.deepEqual(validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'repositories.search', args: { query: 'react', limit: 10 }, + }), { + pluginId: 'com.example.page', pageId: 'dashboard', capability: 'github', + operation: 'searchRepositories', args: { query: 'react', limit: 10 }, + }); + assert.throws(() => validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'network.fetch', args: {}, + }), { code: 'PLUGIN_PAGE_REQUEST_INVALID' }); + assert.throws(() => validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'repositories.get', args: { repositoryId: 1, token: 'x' }, + }), { code: 'PLUGIN_PAGE_REQUEST_INVALID' }); +}); + +test('AI page requests require bounded prompts and cannot carry credentials', () => { + assert.deepEqual(validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'ai.generate', + args: { system: 'Summarize', user: 'Example repository', maxTokens: 500 }, + }), { + pluginId: 'com.example.page', pageId: 'dashboard', capability: 'ai', operation: 'generate', + args: { system: 'Summarize', user: 'Example repository', maxTokens: 500 }, + }); + for (const args of [ + { system: '', user: '' }, + { system: 'x'.repeat(2001), user: 'example' }, + { system: '', user: 'example', maxTokens: 4001 }, + { system: '', user: 'example', apiKey: 'secret' }, + ]) { + assert.throws(() => validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'ai.generate', args, + }), { code: 'PLUGIN_PAGE_REQUEST_INVALID' }); + } +}); + +test('web search requests accept only a bounded query and result limit', () => { + assert.deepEqual(validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'web.search', args: { query: 'Example', limit: 3 }, + }), { + pluginId: 'com.example.page', pageId: 'dashboard', capability: 'web', operation: 'search', + args: { query: 'Example', limit: 3 }, + }); + for (const args of [{ query: '' }, { query: 'x'.repeat(201) }, { query: 'Example', limit: 11 }, { query: 'Example', url: 'https://evil.example' }]) { + assert.throws(() => validatePageCapabilityRequest({ + pluginId: 'com.example.page', pageId: 'dashboard', method: 'web.search', args, + }), { code: 'PLUGIN_PAGE_REQUEST_INVALID' }); + } +}); diff --git a/electron/plugins/pluginProtocol.js b/electron/plugins/pluginProtocol.js new file mode 100644 index 000000000..108f28b69 --- /dev/null +++ b/electron/plugins/pluginProtocol.js @@ -0,0 +1,321 @@ +'use strict'; + +const MAX_REPOSITORIES_PER_ACTION = 1000; +const MAX_RESULT_BYTES = 1024 * 1024; +const PLUGIN_ID_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/; +const ACTION_ID_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +const MAX_RELEASE_ASSETS = 500; + +function protocolError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isJsonSerializable(value) { + try { + JSON.stringify(value); + return true; + } catch { + return false; + } +} + +function validateRunActionRequest(input) { + return validateRepositoryRequest(input, 'actionId'); +} + +function validateRepositoryRequest(input, contributionField) { + if (!isRecord(input)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin action request must be an object'); + } + const unknown = Object.keys(input).find( + (key) => !['pluginId', contributionField, 'repositories'].includes(key) + ); + if (unknown) { + throw protocolError('PLUGIN_REQUEST_INVALID', `Unknown plugin action field '${unknown}'`); + } + if (typeof input.pluginId !== 'string' || !PLUGIN_ID_RE.test(input.pluginId)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin id is invalid'); + } + if (typeof input[contributionField] !== 'string' || !ACTION_ID_RE.test(input[contributionField])) { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin ${contributionField} is invalid`); + } + if (!Array.isArray(input.repositories) || input.repositories.length > MAX_REPOSITORIES_PER_ACTION) { + throw protocolError( + 'PLUGIN_REQUEST_INVALID', + `Plugin action repositories must be an array with at most ${MAX_REPOSITORIES_PER_ACTION} items` + ); + } + + const repositories = input.repositories.map(sanitizeRepository); + + return { pluginId: input.pluginId, [contributionField]: input[contributionField], repositories }; +} + +function sanitizeRepository(repository) { + if (!isRecord(repository) || !Number.isSafeInteger(repository.id)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin repository id must be a safe integer'); + } + for (const field of ['name', 'full_name', 'html_url', 'created_at', 'updated_at', 'pushed_at']) { + if (typeof repository[field] !== 'string') { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin repository field '${field}' must be a string`); + } + } + if (!isRecord(repository.owner) || typeof repository.owner.login !== 'string') { + throw protocolError('PLUGIN_REQUEST_INVALID', "Plugin repository field 'owner.login' must be a string"); + } + + return { + id: repository.id, + name: repository.name, + full_name: repository.full_name, + description: typeof repository.description === 'string' ? repository.description : null, + html_url: repository.html_url, + stargazers_count: Number.isFinite(repository.stargazers_count) ? repository.stargazers_count : 0, + forks_count: Number.isFinite(repository.forks_count) ? repository.forks_count : 0, + language: typeof repository.language === 'string' ? repository.language : null, + created_at: repository.created_at, + updated_at: repository.updated_at, + pushed_at: repository.pushed_at, + owner: { login: repository.owner.login }, + topics: Array.isArray(repository.topics) + ? repository.topics.filter((topic) => typeof topic === 'string').slice(0, 100) + : [], + license: typeof repository.license === 'string' ? repository.license : null, + }; +} + +function sanitizeRelease(input) { + if (!isRecord(input) || !Number.isSafeInteger(input.id)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin release id must be a safe integer'); + } + for (const field of ['tag_name', 'published_at', 'html_url']) { + if (typeof input[field] !== 'string') { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin release field '${field}' must be a string`); + } + } + if (!isRecord(input.repository) || !Number.isSafeInteger(input.repository.id)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin release repository is invalid'); + } + for (const field of ['full_name', 'name']) { + if (typeof input.repository[field] !== 'string') { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin release repository field '${field}' must be a string`); + } + } + if (!Array.isArray(input.assets) || input.assets.length > MAX_RELEASE_ASSETS) { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin release assets must contain at most ${MAX_RELEASE_ASSETS} items`); + } + const assets = input.assets.map((asset) => { + if (!isRecord(asset) || !Number.isSafeInteger(asset.id)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin release asset id must be a safe integer'); + } + for (const field of ['name', 'content_type', 'created_at', 'updated_at']) { + if (typeof asset[field] !== 'string') { + throw protocolError('PLUGIN_REQUEST_INVALID', `Plugin release asset field '${field}' must be a string`); + } + } + return { + id: asset.id, + name: asset.name, + size: Number.isFinite(asset.size) && asset.size >= 0 ? asset.size : 0, + download_count: Number.isFinite(asset.download_count) && asset.download_count >= 0 + ? asset.download_count : 0, + content_type: asset.content_type, + created_at: asset.created_at, + updated_at: asset.updated_at, + }; + }); + return { + id: input.id, + tag_name: input.tag_name, + name: typeof input.name === 'string' ? input.name : null, + body: typeof input.body === 'string' ? input.body.slice(0, 256 * 1024) : null, + published_at: input.published_at, + html_url: input.html_url, + prerelease: input.prerelease === true, + repository: { + id: input.repository.id, + full_name: input.repository.full_name, + name: input.repository.name, + }, + assets, + }; +} + +function validateRunReleaseProcessorRequest(input) { + if (!isRecord(input)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Release processor request must be an object'); + } + const unknown = Object.keys(input).find( + (key) => !['pluginId', 'processorId', 'release', 'repository'].includes(key) + ); + if (unknown) throw protocolError('PLUGIN_REQUEST_INVALID', `Unknown release processor field '${unknown}'`); + if (typeof input.pluginId !== 'string' || !PLUGIN_ID_RE.test(input.pluginId)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin id is invalid'); + } + if (typeof input.processorId !== 'string' || !ACTION_ID_RE.test(input.processorId)) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Plugin processorId is invalid'); + } + const release = sanitizeRelease(input.release); + let repository; + if (input.repository !== undefined) { + repository = validateRepositoryRequest({ + pluginId: input.pluginId, + actionId: input.processorId, + repositories: [input.repository], + }, 'actionId').repositories[0]; + if (repository.id !== release.repository.id) { + throw protocolError('PLUGIN_REQUEST_INVALID', 'Release does not belong to the supplied repository'); + } + } + return { pluginId: input.pluginId, processorId: input.processorId, release, ...(repository ? { repository } : {}) }; +} + +function validatePluginReleaseProcessorResult(input, allowedAssetIds) { + if (!isRecord(input)) throw protocolError('PLUGIN_RESULT_INVALID', 'Release processor result must be an object'); + const unknown = Object.keys(input).find((key) => !['recommendedAssetId', 'confidence', 'reason'].includes(key)); + if (unknown) throw protocolError('PLUGIN_RESULT_INVALID', `Unknown release processor result field '${unknown}'`); + if (!Number.isSafeInteger(input.recommendedAssetId) || !new Set(allowedAssetIds).has(input.recommendedAssetId)) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Recommended asset does not belong to the requested release'); + } + if (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Release recommendation confidence must be between 0 and 1'); + } + if (typeof input.reason !== 'string' || input.reason.trim() === '' || input.reason.length > 2000) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Release recommendation reason must be a non-empty string'); + } + return { + recommendedAssetId: input.recommendedAssetId, + confidence: input.confidence, + reason: input.reason, + }; +} + +function validateRunProcessorRequest(input) { + return validateRepositoryRequest(input, 'processorId'); +} + +function validateRunExporterRequest(input) { + return validateRepositoryRequest(input, 'exporterId'); +} + +function validatePluginActionResult(input) { + if (!isRecord(input) || typeof input.type !== 'string' || !isJsonSerializable(input)) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Plugin action result must be a JSON-serializable object'); + } + if (Buffer.byteLength(JSON.stringify(input), 'utf8') > MAX_RESULT_BYTES) { + throw protocolError('PLUGIN_RESULT_TOO_LARGE', 'Plugin action result exceeds the size limit'); + } + + if (input.type === 'text') { + if (typeof input.content !== 'string') { + throw protocolError('PLUGIN_RESULT_INVALID', "Text result field 'content' must be a string"); + } + if ( + input.suggestedAction !== undefined && + !['copy', 'save'].includes(input.suggestedAction) + ) { + throw protocolError('PLUGIN_RESULT_INVALID', "Text result field 'suggestedAction' is invalid"); + } + return { + type: 'text', + content: input.content, + ...(input.suggestedAction ? { suggestedAction: input.suggestedAction } : {}), + }; + } + + if (input.type === 'open-external') { + let url; + try { + url = new URL(input.url); + } catch { + throw protocolError('PLUGIN_RESULT_INVALID', 'External URL is invalid'); + } + if (url.protocol !== 'https:' || url.username || url.password) { + throw protocolError('PLUGIN_RESULT_INVALID', 'External URL must use HTTPS without credentials'); + } + return { type: 'open-external', url: url.toString() }; + } + + if (input.type === 'notice') { + if (!['info', 'warning', 'error'].includes(input.level) || typeof input.message !== 'string') { + throw protocolError('PLUGIN_RESULT_INVALID', 'Plugin notice result is invalid'); + } + return { type: 'notice', level: input.level, message: input.message }; + } + + throw protocolError('PLUGIN_RESULT_INVALID', `Unsupported plugin result type '${input.type}'`); +} + +function validatePluginProcessorResult(input, allowedRepositoryIds) { + if (!isRecord(input) || !Array.isArray(input.repositories) || input.repositories.length > MAX_REPOSITORIES_PER_ACTION) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Processor result must contain a repositories array'); + } + const allowed = new Set(allowedRepositoryIds); + const repositories = input.repositories.map((repository) => { + if (!isRecord(repository) || !Number.isSafeInteger(repository.id) || !allowed.has(repository.id)) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Processor result contains an unknown repository id'); + } + const result = { id: repository.id }; + if ('summary' in repository) { + if (typeof repository.summary !== 'string') throw protocolError('PLUGIN_RESULT_INVALID', 'Processor summary must be a string'); + result.summary = repository.summary; + } + if ('category' in repository) { + if (typeof repository.category !== 'string') throw protocolError('PLUGIN_RESULT_INVALID', 'Processor category must be a string'); + result.category = repository.category; + } + if ('tags' in repository) { + if (!Array.isArray(repository.tags) || repository.tags.some((tag) => typeof tag !== 'string')) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Processor tags must be an array of strings'); + } + result.tags = repository.tags.slice(0, 100); + } + return result; + }); + if (Buffer.byteLength(JSON.stringify(repositories), 'utf8') > MAX_RESULT_BYTES) { + throw protocolError('PLUGIN_RESULT_TOO_LARGE', 'Processor result exceeds the size limit'); + } + return { repositories }; +} + +function validatePluginExporterResult(input) { + if (!isRecord(input) || typeof input.content !== 'string') { + throw protocolError('PLUGIN_RESULT_INVALID', 'Exporter result content must be a string'); + } + if (Buffer.byteLength(input.content, 'utf8') > 5 * MAX_RESULT_BYTES) { + throw protocolError('PLUGIN_RESULT_TOO_LARGE', 'Exporter result exceeds the size limit'); + } + if (input.fileName !== undefined && ( + typeof input.fileName !== 'string' || + input.fileName.length === 0 || + input.fileName !== input.fileName.replace(/[\\/]/g, '') + )) { + throw protocolError('PLUGIN_RESULT_INVALID', 'Exporter fileName must not contain a path'); + } + return { + content: input.content, + ...(input.fileName ? { fileName: input.fileName } : {}), + }; +} + +module.exports = { + MAX_REPOSITORIES_PER_ACTION, + MAX_RESULT_BYTES, + protocolError, + validateRunActionRequest, + validateRunProcessorRequest, + validateRunExporterRequest, + validateRunReleaseProcessorRequest, + validatePluginActionResult, + validatePluginProcessorResult, + validatePluginExporterResult, + validatePluginReleaseProcessorResult, + sanitizeRepository, + sanitizeRelease, +}; diff --git a/electron/plugins/pluginProtocol.test.js b/electron/plugins/pluginProtocol.test.js new file mode 100644 index 000000000..664cbeacb --- /dev/null +++ b/electron/plugins/pluginProtocol.test.js @@ -0,0 +1,134 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + MAX_RESULT_BYTES, + validateRunActionRequest, + validatePluginActionResult, + validateRunProcessorRequest, + validatePluginProcessorResult, + validatePluginExporterResult, + validateRunReleaseProcessorRequest, + validatePluginReleaseProcessorResult, +} = require('./pluginProtocol'); + +function repository(overrides = {}) { + return { + id: 1, + name: 'project', + full_name: 'owner/project', + description: 'Example', + html_url: 'https://github.com/owner/project', + stargazers_count: 10, + forks_count: 2, + language: 'TypeScript', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + pushed_at: '2026-01-03T00:00:00Z', + owner: { login: 'owner', avatar_url: 'https://example.com/avatar.png' }, + topics: ['desktop'], + ai_summary: 'must not cross the plugin boundary', + ...overrides, + }; +} + +test('sanitizes repository action input to the public plugin shape', () => { + const result = validateRunActionRequest({ + pluginId: 'com.example.exporter', + actionId: 'export-markdown', + repositories: [repository()], + }); + + assert.equal(result.repositories[0].full_name, 'owner/project'); + assert.equal('ai_summary' in result.repositories[0], false); + assert.equal('avatar_url' in result.repositories[0].owner, false); +}); + +test('rejects malformed action input and unknown fields', () => { + assert.throws( + () => validateRunActionRequest({ pluginId: '../bad', actionId: 'run', repositories: [] }), + { code: 'PLUGIN_REQUEST_INVALID' } + ); + assert.throws( + () => validateRunActionRequest({ + pluginId: 'com.example.valid', + actionId: 'run', + repositories: [repository()], + token: 'secret', + }), + { code: 'PLUGIN_REQUEST_INVALID' } + ); +}); + +test('accepts structured action results and normalizes HTTPS URLs', () => { + assert.deepEqual(validatePluginActionResult({ type: 'text', content: 'ok', suggestedAction: 'copy' }), { + type: 'text', + content: 'ok', + suggestedAction: 'copy', + }); + assert.deepEqual(validatePluginActionResult({ type: 'open-external', url: 'https://example.com' }), { + type: 'open-external', + url: 'https://example.com/', + }); +}); + +test('rejects unsafe, unknown, or oversized action results', () => { + assert.throws( + () => validatePluginActionResult({ type: 'open-external', url: 'file:///tmp/secret' }), + { code: 'PLUGIN_RESULT_INVALID' } + ); + assert.throws(() => validatePluginActionResult({ type: 'html', content: ' + + +
+

Repository Health

+

This page reads the Host's sanitized local repository snapshot. It cannot access your GitHub token or make network requests.

+ +
+ + +
+

Waiting for the Host bridge…

+
    +
    + + diff --git a/examples/plugins/repo-health-page/ui/index.js b/examples/plugins/repo-health-page/ui/index.js new file mode 100644 index 000000000..f0deec14d --- /dev/null +++ b/examples/plugins/repo-health-page/ui/index.js @@ -0,0 +1,67 @@ +const pluginId = 'com.example.repo-health-page'; +const pageId = 'dashboard'; +let token = null; +let nextRequestId = 0; +let latestSearchId = 0; +const pending = new Map(); + +function request(method, args) { + if (!token) return Promise.reject(new Error('Host bridge is not ready')); + const requestId = String(++nextRequestId); + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }); + window.parent.postMessage({ + type: 'plugin-page:request', pluginId, pageId, requestId, token, method, args, + }, '*'); + }); +} + +function renderRepositories(repositories) { + const list = document.getElementById('results'); + list.replaceChildren(); + for (const repository of repositories) { + const item = document.createElement('li'); + const title = document.createElement('strong'); + title.textContent = repository.full_name; + const summary = document.createElement('small'); + const lastPush = repository.pushed_at ? repository.pushed_at.slice(0, 10) : 'unknown'; + summary.textContent = `★ ${repository.stargazers_count} · Last push: ${lastPush}`; + item.append(title, summary); + list.append(item); + } +} + +async function search() { + const status = document.getElementById('status'); + const searchId = ++latestSearchId; + try { + status.textContent = 'Searching…'; + const repositories = await request('repositories.search', { + query: document.getElementById('query').value.trim(), limit: 30, + }); + if (searchId !== latestSearchId) return; + renderRepositories(repositories); + status.textContent = `${repositories.length} repositories found in the local Host snapshot.`; + } catch (error) { + if (searchId !== latestSearchId) return; + status.textContent = error instanceof Error ? error.message : 'Search failed'; + } +} + +window.addEventListener('message', (event) => { + if (event.source !== window.parent || !event.data || + event.data.pluginId !== pluginId || event.data.pageId !== pageId) return; + if (event.data.type === 'plugin-page:init' && typeof event.data.token === 'string') { + token = event.data.token; + void search(); + return; + } + if (event.data.type !== 'plugin-page:response' || event.data.token !== token) return; + const handler = pending.get(event.data.requestId); + if (!handler) return; + pending.delete(event.data.requestId); + if (event.data.success) handler.resolve(event.data.value); + else handler.reject(new Error(event.data.error?.message || 'Host request failed')); +}); + +document.getElementById('search').addEventListener('click', () => void search()); diff --git a/examples/plugins/repo-health-page/ui/style.css b/examples/plugins/repo-health-page/ui/style.css new file mode 100644 index 000000000..a022f0ff9 --- /dev/null +++ b/examples/plugins/repo-health-page/ui/style.css @@ -0,0 +1,11 @@ +:root { color-scheme: light; font-family: system-ui, sans-serif; } +body { margin: 0; background: #f8fafc; color: #0f172a; } +main { max-width: 760px; margin: 0 auto; padding: 2rem; } +h1 { margin-top: 0; } +p { line-height: 1.5; } +label { display: block; margin-bottom: .5rem; font-weight: 600; } +.search-row { display: flex; gap: .5rem; } +input { flex: 1; padding: .65rem; border: 1px solid #94a3b8; border-radius: .35rem; } +button { padding: .65rem 1rem; border: 0; border-radius: .35rem; background: #2563eb; color: white; cursor: pointer; } +li { margin: .75rem 0; padding: .75rem; border: 1px solid #cbd5e1; border-radius: .35rem; background: white; } +small { display: block; color: #475569; } diff --git a/examples/plugins/smart-release-recommender/README.md b/examples/plugins/smart-release-recommender/README.md new file mode 100644 index 000000000..e40f71cc5 --- /dev/null +++ b/examples/plugins/smart-release-recommender/README.md @@ -0,0 +1,10 @@ +# Smart Release Recommender + +V1.1 Release processor 示例。它只读取宿主提供的脱敏 Release/Asset 元数据,根据当前操作系统和 CPU 架构匹配文件名,不读取 Token,也不自行联网。 + +安装后需明确批准: + +- `releases:read`:读取脱敏 Release 和 Asset 元数据。 +- `downloads:create`:允许用户从推荐结果触发宿主下载。 + +下载按钮由 GithubStarsManager 渲染。插件只返回 `recommendedAssetId`、`confidence` 和 `reason`;宿主会再次确认 Asset 归属、显示保存对话框并执行下载。 diff --git a/examples/plugins/smart-release-recommender/manifest.json b/examples/plugins/smart-release-recommender/manifest.json new file mode 100644 index 000000000..85b564925 --- /dev/null +++ b/examples/plugins/smart-release-recommender/manifest.json @@ -0,0 +1,17 @@ +{ + "manifestVersion": 1, + "id": "com.githubstarsmanager.smart-release-recommender", + "name": "Smart Release Recommender", + "version": "0.1.0", + "description": "Recommends a release asset for the current operating system and CPU architecture.", + "author": "GithubStarsManager", + "apiVersion": "1", + "main": "worker.js", + "permissions": ["releases:read", "downloads:create"], + "contributes": { + "releaseProcessors": [{ + "id": "recommend-platform-asset", + "title": "Recommend for this device" + }] + } +} diff --git a/examples/plugins/smart-release-recommender/worker.js b/examples/plugins/smart-release-recommender/worker.js new file mode 100644 index 000000000..751d17c87 --- /dev/null +++ b/examples/plugins/smart-release-recommender/worker.js @@ -0,0 +1,61 @@ +'use strict'; + +let host; + +const SOURCE_ARCHIVE = /(?:source[-_. ]?code|源码)/i; +const PLATFORM_PATTERNS = { + win32: /(?:windows|win32|win64|win[-_.]?x64|\.exe$|\.msi$)/i, + darwin: /(?:macos|mac[-_.]?os|darwin|osx|\.dmg$|\.pkg$)/i, + linux: /(?:linux|appimage|\.deb$|\.rpm$)/i, +}; +const ARCH_PATTERNS = { + x64: /(?:x86[_-]?64|amd64|x64|win64)/i, + arm64: /(?:aarch64|arm64)/i, + // 'win32' is a platform token (Electron names Windows builds win32-*), not a 32-bit marker. + ia32: /(?:x86(?![_-]?64)|i[3-6]86)/i, +}; + +function matchingKeys(patterns, name) { + return Object.entries(patterns) + .filter(([, pattern]) => pattern.test(name)) + .map(([key]) => key); +} + +function scoreAsset(asset, environment) { + const name = asset.name.toLowerCase(); + if (SOURCE_ARCHIVE.test(name) || /(?:checksum|sha256|\.sig$|\.asc$)/i.test(name)) return -100; + // Reject a name that declares another platform or architecture, but keep names that also + // declare the Host's own value (e.g. 'app-win32-x64.zip' on a 64-bit Windows Host). + const platforms = matchingKeys(PLATFORM_PATTERNS, name); + if (platforms.some((platform) => platform !== environment.os)) return -100; + const architectures = matchingKeys(ARCH_PATTERNS, name); + if (architectures.some((architecture) => architecture !== environment.arch)) return -100; + let score = 0; + if (PLATFORM_PATTERNS[environment.os]?.test(name)) score += 50; + if (ARCH_PATTERNS[environment.arch]?.test(name)) score += 35; + if (/(?:setup|installer|portable|appimage|\.msi$|\.dmg$|\.deb$|\.rpm$)/i.test(name)) score += 10; + return score; +} + +module.exports = { + activate(context) { + host = context; + }, + async runReleaseProcessor({ release: inputRelease, hostEnvironment }) { + const release = await host.github.getRelease(inputRelease.id) || inputRelease; + const ranked = release.assets + .map((asset) => ({ asset, score: scoreAsset(asset, hostEnvironment) })) + .sort((left, right) => right.score - left.score); + if (ranked.length === 0 || ranked[0].score <= 0) { + const error = new Error('No asset clearly matches this operating system and architecture'); + error.code = 'NO_COMPATIBLE_RELEASE_ASSET'; + throw error; + } + const best = ranked[0]; + return { + recommendedAssetId: best.asset.id, + confidence: Math.min(0.99, Math.max(0.35, best.score / 100)), + reason: `Best filename match for ${hostEnvironment.os}/${hostEnvironment.arch}`, + }; + }, +}; diff --git a/package.json b/package.json index 3901440ce..6f4563b24 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "typecheck": "tsc -b --noEmit", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run && npm run test:electron:mcp && npm run test:update-version", + "test:run": "vitest run && npm run test:electron:mcp && npm run test:electron:plugins && npm run test:update-version", "test:electron:mcp": "node --test electron/mcpLocalServer.test.js electron/desktopPrefs.test.js electron/xAuthStorage.test.js", + "test:electron:plugins": "node --test electron/plugins/*.test.js", "test:update-version": "node --test scripts/update-version.test.cjs", "test:coverage": "vitest run --coverage", "build:desktop": "node scripts/build-desktop.js", diff --git a/src/components/BulkActionToolbar.tsx b/src/components/BulkActionToolbar.tsx index 246b7dfb2..2bd30f2a6 100644 --- a/src/components/BulkActionToolbar.tsx +++ b/src/components/BulkActionToolbar.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef } from 'react'; -import { X, Star, FolderOpen, Bot, Bell, BellOff, CheckSquare, Square, Loader2, Lock, Unlock, RotateCcw } from 'lucide-react'; +import { X, Star, FolderOpen, Bot, Bell, BellOff, CheckSquare, Square, Loader2, Lock, Unlock, RotateCcw, Plug } from 'lucide-react'; import { Repository } from '../types'; import { useAppStore } from '../store/useAppStore'; import { useShallow } from 'zustand/react/shallow'; import { Button } from './ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; +import { usePluginActions } from '../plugins/hooks/usePluginActions'; +import { applyPluginActionResult } from '../plugins/applyPluginActionResult'; +import { useDialog } from '../hooks/useDialog'; +import { usePluginExporters } from '../plugins/hooks/usePluginExporters'; +import { pluginClient } from '../plugins/pluginClient'; +import type { RegisteredPluginAction } from '../plugins/types'; interface BulkActionToolbarProps { selectedCount: number; @@ -22,6 +29,98 @@ interface TooltipState { y: number; } +interface RegisteredExporter { + id: string; + title: string; + fileExtension: string; + mimeType: string; + pluginId: string; + pluginName: string; +} + +const PluginBulkMenu: React.FC<{ + actions: RegisteredPluginAction[]; + exporters: RegisteredExporter[]; + repositories: Repository[]; + language: 'zh' | 'en'; + disabled: boolean; + onBusyChange: (busy: boolean) => void; +}> = ({ actions, exporters, repositories, language, disabled, onBusyChange }) => { + const { toast } = useDialog(); + const t = (zh: string, en: string) => language === 'zh' ? zh : en; + + const runAction = async (action: RegisteredPluginAction) => { + onBusyChange(true); + try { + const operation = await pluginClient.runAction({ + pluginId: action.pluginId, + actionId: action.id, + repositories, + }); + if (!operation.success) return toast(operation.error.message, 'error'); + await applyPluginActionResult(operation.result, toast, language); + } catch { + toast(t('插件操作失败', 'Plugin action failed'), 'error'); + } finally { + onBusyChange(false); + } + }; + + const runExporter = async (exporter: RegisteredExporter) => { + onBusyChange(true); + try { + const operation = await pluginClient.runExporter({ + pluginId: exporter.pluginId, + exporterId: exporter.id, + repositories, + }); + if (!operation.success) return toast(operation.error.message, 'error'); + const url = URL.createObjectURL(new Blob([operation.result.content], { type: operation.result.mimeType })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = operation.result.fileName; + anchor.click(); + URL.revokeObjectURL(url); + toast(t('导出完成', 'Export complete'), 'success'); + } catch { + toast(t('插件导出失败', 'Plugin export failed'), 'error'); + } finally { + onBusyChange(false); + } + }; + + return ( + + + + + + {actions.map((action) => ( + void runAction(action)}> + + {action.title} + + ))} + {exporters.map((exporter) => ( + void runExporter(exporter)}> + + {exporter.title} + + ))} + + + ); +}; + export const BulkActionToolbar: React.FC = ({ selectedCount, repositories, @@ -34,6 +133,8 @@ export const BulkActionToolbar: React.FC = ({ const { language } = useAppStore(useShallow((state) => ({ language: state.language, }))); + const pluginActions = usePluginActions('bulk-toolbar'); + const pluginExporters = usePluginExporters(); const [isProcessing, setIsProcessing] = useState(false); const [showConfirm, setShowConfirm] = useState(null); const [isClosing, setIsClosing] = useState(false); @@ -374,6 +475,17 @@ export const BulkActionToolbar: React.FC = ({ )} + {(pluginActions.actions.length > 0 || pluginExporters.exporters.length > 0) && ( + + )} +
    + + {error ?

    {error}

    : + url ?